개발/클론코딩

[Nomad Coders ]Flutter로 웹툰 앱 만들기

걍판자 2024. 1. 11. 20:35
반응형

Nomad Coders 님의 플러터 강의를 정리한 것입니다.

해당 강의는 현재 무료로 누구나 쉽게 들을 수 있습니다.

 

https://nomadcoders.co/flutter-for-beginners

 

Flutter 로 웹툰 앱 만들기 – 노마드 코더 Nomad Coders

Flutter for Beginners

nomadcoders.co

 

해당 내용에 대한 이전 게시글

2024.01.10 - [개발/클론코딩] - [Nomad Coders] Flutter로 Pomodoros 앱 만들기

 

[Nomad Coders] Flutter로 Pomodoros 앱 만들기

https://nomadcoders.co/flutter-for-beginners Flutter 로 웹툰 앱 만들기 – 노마드 코더 Nomad Coders Flutter for Beginners nomadcoders.co 이 글은 Nomad Coders 님의 강의를 참고해 작성하였습니다. 작성 날짜 기준 무료 강

juneforpay.tistory.com

 

 

Webtoon App

Appbar

import 'package:flutter/material.dart';
import 'package:toonflix/screens/home_screen.dart';

void main() {
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});//키 반환

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HomeScreen(),
    );
  }
}

import 'package:flutter/material.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,//넓은 배경색
      appBar: AppBar(
        elevation: 2,//그림자효과
        backgroundColor: Colors.white,//배경색
        foregroundColor: Colors.green,//글자색
        title: const Text(
          "어늘의 웹툰",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
    );
  }
Expand Down

  • 각 위젯에는 식별자 id 역할을 하는 key가 있다.
  • AppBar로 제일 위 바의 음영, 글자색, 텍스트, 음영 등을 조정할 수 있다.

 

 

 

Data Fetching

import 'package:http/http.dart' as http;

class ApiService {
  final String baseUrl = "<https://webtoon-crawler.nomadcoders.workers.dev>";
  final String today = "today";

  void getTodaysToons() async {
    final url = Uri.parse('$baseUrl/$today');//uri에서 url만 빼서 파싱한다,
    final response = await http.get(url); // 나중에 받을것이기에 awiat
    if (response.statusCode == 200) {
      print(response.body); // get으로 받아온 값 로그찍기
      return;
    }
    throw Error(); //서버요청 200 즉 실패하면 에러 발생
  }
}
  • 여기서부터 api를 불러오기 위해서는 패키지를 설치해서 진행하여야 한다.
  • import한 url을 http로 부르고 http.get()을 통해 해당 url에서 서버 응답을 받아올 수 있다.
  • 여기서 get은 future타입을 반환한다.
  • future은 미래에 받을 코드를 알려준다. 그리고 나중에 response를 반환한다. api 같은 경우 네트워크 문제로 인해 받아오는데 시간이 걸릴 수 있다. 그래서 바로 끝내는 게 아닌 api 요청을 처리할 때까지 응답을 기다려야 한다.
  • 이를 비동기 프로그래밍이라 한다. async programming. 완료할때까지 기다리는 것이다.
  • 이렇게 요청을 기다리게 하고 싶으면 async 함수 안에서 코드 앞에서 await을 써야 한다. 2가지 조건이 충족되어야 한다.

 

 

FromJson

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:toonflix/models/webtoon_model.dart';

class WebtoonModel { //받은 string을 json으로 전환시킴
  final String title, thumb, id;

  WebtoonModel.fromJson(Map<string, dynamic=""> json)
      : title = json['title'],
        thumb = json['thumb'],
        id = json['id'];
}

class ApiService {
  final String baseUrl = "<https://webtoon-crawler.nomadcoders.workers.dev>";
  final String today = "today";
Future<list> getTodaysToons() async { //비동기 함수의 반환값은 future다.
    List webtoonInstances = [];
    final url = Uri.parse('$baseUrl/$today');
    final response = await http.get(url);
    if (response.statusCode == 200) {
	 final List webtoons = jsonDecode(response.body);//리스트에 추가
      for (var webtoon in webtoons) {
        webtoonInstances.add(WebtoonModel.fromJson(webtoon));//클래스 이용해 전환시킴
      }
      return webtoonInstances;
    }
    throw Error();
  }
</list</string,>
  • 요청받은 데이터를 문자열에서 json에 들어갈 수 있는 클래스로 변환해야 한다.
  • webtoonmodel은 named constructor 방식을 사용했다. 인수만 적고, : 로 있는 것이다.
  • json은 string ↔ map<string,dynamic> ↔ object 사이의 변환 과정이 필요하다.
  • json string을 map 형태로 변환시키는 데에는 jsonDecode가 쓰이며, 여기서 나온 것은 fromJson로 객체로 파싱 받는다.

 

 

 

waitForWebtoons

import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State createState() => _HomeScreenState();
}

class _HomeScreenState extends State {
  List webtoons = [];
  bool isLoading = true; //처음 loading true

  void waitForWebToons() async {
    webtoons = await ApiService.getTodaysToons();// 웹툰 api 받아올때까지 대기
    isLoading = false; // 로딩 끝났다는 뜻
    setState(() {});//업데이트
  }

  @override
  void initState() {
    super.initState();
    waitForWebToons();// build 메소드 호출 전 1번 호출
  }

  @override
  Widget build(BuildContext context) {
    print(webtoons);
    print(isLoading);
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        title: const Text(
          "어늘의 웹툰",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
    );
  }
}

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:toonflix/models/webtoon_model.dart';

class ApiService {
  static const String baseUrl =
      "<https://webtoon-crawler.nomadcoders.workers.dev>";
  static const String today = "today";

  static Future<list> getTodaysToons() async {
    List webtoonInstances = [];
    final url = Uri.parse('$baseUrl/$today');
    final response = await http.get(url);
    if (response.statusCode == 200) {
      final webtoons = jsonDecode(response.body);
      for (var webtoon in webtoons) {
        final instance = WebtoonModel.fromJson(webtoon);
        webtoonInstances.add(instance);
      }
      return webtoonInstances;
    }
    throw Error();
  }
}
</list
  • 처음에 api 받아오는 waitforwebtoons 함수 실행
  • 받아오는 거 기다리면서 빌드 진행됨
  • 데이터 도착해서 완료되면 isLoading은 false가 되고 setState로 rebuild
  • apiservice에 있는 값들에 static을 붙여 인스턴스 없이 실행할 수 있게 하였다. 이 부분은 나중에 더 연구해 봐야겠다.

 

 

FutureBuilder

import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';

class HomeScreen extends StatelessWidget {
  HomeScreen({super.key});

  Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        title: const Text(
          "어늘의 웹툰",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      body: FutureBuilder(
        future: webtoons,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return const Text("There is data!");
          }
          return const Text('Loading....');
        },
      ),
    );
  }
}
  • 위에서 api 업데이트를 기다리고 굳이 다시 setState로 rebuild 하고 했던 일련의 과정들은 이제 잊어라. 그딴 거 다 지워버리고 훨씬 쉬운 방법을 쓸 테니
  • 우선 future 붙어있는 것들은 컴파일 전에 값을 아는 const를 쓸 수 없다. 그러니 const들을 지워준다.
  • stateful widget에서 stateless widget으로 바꾸고도 FutureBuilder라는 것을 사용해 훨씬 간편하게 상태 변화를 적용할 수 있게 되었다.
  • 우선 Future <List <WebtoonModel>> webtoons = ApiService.getTodaysToons();로 webtoons를 future 형식으로 선언한다.
  • 그리고 FutureBuilder 안에서는 받아올 future 형태의 자료형을 future: 에 적고 builder의 snapshot을 통해 현재 받아오고 있는 상태를 알 수 있다.
  • snapshot은 네트워크 연결상태, 데이터 받아왔는지 에러가 나진 않았는지 알 수 있게 해 준다.
  • 여기서는 데이터를 받아왔는지에 따라 text를 출력해 주었다.

 

 

ListView

import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';
class HomeScreen extends StatelessWidget {
  HomeScreen({super.key});

  Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
  final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        title: const Text(
          "어늘의 웹툰",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      body: FutureBuilder(
        future: webtoons,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return const Text("There is data!");
            return ListView.separated(
              scrollDirection: Axis.horizontal, //가로 스크롤로 로딩
              itemCount: snapshot.data!.length,//총 로딩할 아이템 개수
              itemBuilder: (context, index) { //itembuilder을 통해 보여주는 만큼만 로딩
                var webtoon = snapshot.data![index];
                return Text(webtoon.title);
              },
              separatorBuilder: (context, index) => const SizedBox(width: 20),
//아이템 사이사이 빈 상자로 여백 만들기
            );
          }
          return const Text('Loading....');
          return const Center(
            child: CircularProgressIndicator(),//로딩 중일때 뜨는 원
          );
        },
      ),
    );
  }
}
  • futurebuilder의 snapshot을 다른 단어로 바꿔서 사용해도 된다. futureResult라던가
  • listview 사용 시 많은 양의 데이터들을 나열해서 보여주기 좋다. listview의 children으로 collection for 문, 즉, for in 문으로 모든 것을 한번에 띄울 수 있다.
  • 하지만 listview는 한번에 모든것을 다 로딩하기에 메모리가 많이 든다. 인스타그램같이 사용자 보는 부분만 로딩하는 게 더 메모리 사용에 좋다.
  • 그렇기 위해서는 Listview.builder을 쓴다. Listview.builder은 딱 보이는 만큼만 계속해서 새로 데이터를 받아 보여준다. 그리고 안의 설정으로 scroll 방향, itemcount로 총 item의 개수, itembuilder의 index를 통해 어떤 아이템이 로딩되는지 알 수 있다.
  • Listview.separate를 통해 아이템들이 로딩될 때 그 사이에 무언가를 설정할 수 있다. 여기서는 아이템마다 구분해 주는 공백상자를 넣었다. 맨 앞은 사이가 아니므로 그 상자가 들어가지 않는다.

 

 

Webtoon Card

import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';
class HomeScreen extends StatelessWidget {
  HomeScreen({super.key});
  final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        title: const Text(
          "어늘의 웹툰",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      body: FutureBuilder(
        future: webtoons,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return Column(
              children: [
                const SizedBox(
                  height: 50,//높이를 주기 위해서는 listview가 높이값을 알아야 한다. 그렇지 않으면 무한으로 여긴다.
                ),
                Expanded(child: makeList(snapshot))//화면 남는 공간 차지, 남는공간 차지해 listview 위에 높이 들어갈수 있게 함
              ],
            );
          }
          return const Center(
					child: CircularProgressIndicator(),
          );
        },
      ),
    );
  }

//listview 만드는 걸 extract 하였다. 자동으로 인자들이 지정되어 추출되었다.
ListView makeList(AsyncSnapshot<List<WebtoonModel>> snapshot) {
    return ListView.separated(
      scrollDirection: Axis.horizontal,
      itemCount: snapshot.data!.length,
      padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), // 처음 이미지 바로 왼쪽 화면에 붙어서 나오는 것 꼴보기 싫어서 써줌
      itemBuilder: (context, index) {
        var webtoon = snapshot.data![index];
        return Column(
          children: [
            Container( // 이미지 크기 조정하기 위한 상자 container 안에 이미지를 넣어버린다.
              width: 250,
              clipBehavior: Clip.hardEdge,// container 넘어가는 이미지 자르기 그래야 container 끝 둥글게 한것에 제대로 적용됨
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(15),// 끝 둥글게
                boxShadow: [
                  BoxShadow( //사각형에 대한 그림자
                    blurRadius: 15,//그림자 크기
                    offset: const Offset(10, 10),// (0,0)가 정면일때 광원위치 이동, 특정 방향만 그림자 적용 가능
                    color: Colors.black.withOpacity(0.3),//그림자 색깔
                  )
                ],
              ),
              child: Image.network(webtoon.thumb),//url을 기반으로 이미 불러옴
            ),
            const SizedBox(
              height: 10,
            ),
            Text(
              webtoon.title,
              style: const TextStyle(
                fontSize: 22,//밑의 제목 글씨
              ),
            ),
          ],
        );
      },
      separatorBuilder: (context, index) => const SizedBox(width: 40),
    );
  }
}

 

 

 

Detail Screen

import 'package:flutter/material.dart';
import 'package:toonflix/screens/detail_screen.dart';

class Webtoon extends StatelessWidget {
  final String title, thumb, id;

  const Webtoon({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
//home_screen에서 return된 데이터들 받아서 사용
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector( // 사용자 누르는 것 관련 상호작용
      onTap: () { //눌렀을때
        Navigator.push( //새로운 위젯 만들고 거기로 전환되는 것 처럼 보여줌
          context, //그 밑에 받은게 다 route로 아래를 스크린처럼 보여줌
          MaterialPageRoute( //statelesss 위젯을 다른 스크린처럼 보여줌
            builder: (context) => DetailScreen(
              title: title,
              thumb: thumb,
              id: id,
            ),
            fullscreenDialog: true, // 바닥에서 가져오는 것처럼 연출할 수 있음,
// 이거 안쓰면 옆에서 카드처럼 나오고, 쓰면 아래에서 새로운게 나오는 느낌 줌, 아이콘도 >에서  x로 바뀜 
          ),
        );
      },
      child: Column(
        children: [
          Container(
            width: 250,
            clipBehavior: Clip.hardEdge,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(15),
              boxShadow: [
                BoxShadow(
                  blurRadius: 15,
                  offset: const Offset(10, 10),
                  color: Colors.black.withOpacity(0.3),
                )
              ],
            ),
            child: Image.network(thumb),
          ),
          const SizedBox(
            height: 10,
          ),
          Text(
            title,
            style: const TextStyle(
              fontSize: 22,
            ),
          ),
        ],
      ),
    );
  }
}

  • 그 외에 detail_screen에서 원래 scaffold와 이미지 복붙해줘서 가져옴

 

 

 Hero

  • 기존 위젯의 일부를 다음으로 넘어갈 때도 똑같이 띄워야 한다면, 기존 위젯이 사라졌다가 새롭게 다시 생겨나는 것보다는 그 이미지가 이동하는 듯한 느낌을 주는 게 더 좋을 것이다.
  • 기존 위젯을 hero:id로 감싼다. 동일한 느낌을 줄 다른 코드도 hero:id 로 동일한 id로 넣는다.
  • 그러면 자연스럽게 넘어가는 연출이 된다.
  • navigator은 새로운 statefulwidget으로 홈 화면 위에 올려주고, 여기에 materialpageroute라는 애니메이션을 담당하는 기능을 사용할 수 있다. 그걸 통해 detailscreen 파일을 새롭게 띄우고, title, thumb, id를 전달한다.

 

apiService

class WebtoonDetailModel {
  final String title, about, genre, age;

  WebtoonDetailModel.fromJson(Map<String, dynamic> json)
      : title = json['title'],
        about = json['about'],
        genre = json['genre'],
        age = json['age'];
}

class WebtoonEpisodeModel {
  final String id, title, rating, date;

  WebtoonEpisodeModel.fromJson(Map<String, dynamic> json)
      : id = json['id'],
        title = json['title'],
        rating = json['rating'],
        date = json['date'];
}

static Future<WebtoonDetailModel> getToonById(String id) async {
    final url = Uri.parse("$baseUrl/$id");
    final response = await http.get(url);
    if (response.statusCode == 200) {
      final webtoon = jsonDecode(response.body);
      return WebtoonDetailModel.fromJson(webtoon);
    }
    throw Error();
  }

  static Future<List<WebtoonEpisodeModel>> getLatestEpisodesById(
      String id) async {
    List<WebtoonEpisodeModel> episodesInstances = [];
    final url = Uri.parse("$baseUrl/$id/episodes");
    final response = await http.get(url);
    if (response.statusCode == 200) {
      final episodes = jsonDecode(response.body);
      for (var episode in episodes) {
        episodesInstances.add(WebtoonEpisodeModel.fromJson(episode));
      }
      return episodesInstances;
    }
    throw Error();
  }
  • 위의 두 모델에서 각각 api를 받아 json 값을 추출한다.
  • 각각 추출하는 함수를 인자 리스트로 받는 static constructor들이다.

 

 

Futures

import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_detail_model.dart';
import 'package:toonflix/models/webtoon_episode_model.dart';
import 'package:toonflix/services/api_service.dart';

class DetailScreen extends StatefulWidget {
  final String title, thumb, id;

  const DetailScreen({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
  });

  @override
  State<DetailScreen> createState() => _DetailScreenState();
}

class _DetailScreenState extends State<DetailScreen> {
  late Future<WebtoonDetailModel> webtoon;
  late Future<List<WebtoonEpisodeModel>> episodes;

  @override
  void initState() {
    super.initState();
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
  }

  • 기존의 stateless로 되어있던 위젯을 stateful로 바꾸어 주었다. initstate() 메서드가 필요하거든 getToonById와 getLatestEpisodesById는 위젯 id가 필요하다. 즉 인자가 필요하다.
  • 원래 그냥 받아오던 title, thumb, id 값 앞에 widget. 을 붙여서 가져오게 되었다. 이렇게 widget을 붙여주는 이유는 부모 역할을 하는 상위 detailscreen stateful 위젯에서 받아오라는 뜻이다.
  • late로 webtoon과 episodes를 선언한 다음, initState 할 때 widget.id를 파라미터로 주고 값을 얻어온다.
  • 이렇게 하는 이유는 어떤 프로퍼티를 초기화할 때 다른 프로퍼티를 참조하는 것은 불가능하기 때문이다. 따라서 late로 일단 선언해 놓고, 나중에 받아오는 식으로 접근한다.

 

 

 detail info

const SizedBox(
            height: 25,
          ),
          FutureBuilder(
            future: webtoon,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                return Padding(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 50,
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start, // 왼쪽 끝
                    children: [
                      Text(
                        snapshot.data!.about,//불러오는 텍스트
                        style: const TextStyle(fontSize: 16),
                      ),
                      const SizedBox(
                        height: 15,
                      ),
                      Text(
                        '${snapshot.data!.genre} / ${snapshot.data!.age}',//불러오는 텍스트
                        style: const TextStyle(fontSize: 16),
                      ),
                    ],
                  ),
                );
              }
              return const Text("..."); //데이터 없을때 출력
            },
          )
  • 클릭했을 때 나오는 썸네일 밑의 상세정보란 텍스트들을 추가해 주었다.

 

 

Episodes

body: SingleChildScrollView( //스크롤
        child: Padding(
          padding: const EdgeInsets.all(50),//전체여백
          child: Column(children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Hero(
                    tag: widget.id,
                    child: Container(
                      width: 250,
                      clipBehavior: Clip.hardEdge,
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(15),
                        boxShadow: [
                          BoxShadow(
                            blurRadius: 15,
                            offset: const Offset(10, 10),
                            color: Colors.black.withOpacity(0.3),
                          )
                        ],
                      ),
                      child: Image.network(widget.thumb),
                    ),
                  ),
                ],
              ),
              const SizedBox(
                height: 25,
              ),
              FutureBuilder(
                future: webtoon,
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          snapshot.data!.about,
                          style: const TextStyle(fontSize: 16),
                        ),
                        const SizedBox(
                          height: 15,
                        ),
                        Text(
                          '${snapshot.data!.genre} / ${snapshot.data!.age}',
                          style: const TextStyle(fontSize: 16),
                        ),
                      ],
                    );
                  }
                  return const Text("...");
                },
              ),
              const SizedBox(
                height: 25,
              ),
              FutureBuilder(
                future: episodes,
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      children: [
                        for (var episode in snapshot.data!)//episode 들 collection for로 로딩
                          Container(
                            margin: const EdgeInsets.only(bottom: 10),//에피소드간 간격
                            decoration: BoxDecoration(
                              borderRadius: BorderRadius.circular(20),
                              color: Colors.green.shade400,
                              boxShadow: [
                                BoxShadow(
                                  blurRadius: 5,
                                  offset: const Offset(5, 5),
                                  color: Colors.black.withOpacity(0.1),
                                ),
                              ],
                            ),
                            child: Padding(
                              padding: const EdgeInsets.symmetric(
                                vertical: 10,
                                horizontal: 20,
                              ),
                              child: Row(
                                mainAxisAlignment:
                                    MainAxisAlignment.spaceBetween,//제목과 화살표 아이콘 수평 양끝으로
                                children: [
                                  Text(
                                    episode.title,
                                    style: const TextStyle(
                                      color: Colors.white,
                                      fontSize: 16,
                                    ),
                                  ),
                                  const Icon(
                                    Icons.chevron_right_rounded,//화살표 아이콘
                                    color: Colors.white,
                                  ),
                                ],
                              ),
                            ),
                          )
                      ],
                    );
                  }
                  return Container();
                },
              )
            ],
          ),
        ),
  • 썸네일 설명 밑의 최근 화들을 추가해 주었다.
  • 최근 화들을 추가할 때 개수가 얼마 되지 않아 간단한 column으로 추가했다. 최적화가 중요하고 복잡할 경우에는 listview를 사용하면 된다.

 

 

url Launcher

class Episode extends StatelessWidget {
  const Episode({
    Key? key,
    required this.episode,
    required this.webtoonId,
  }) : super(key: key);

  final String webtoonId;
  final WebtoonEpisodeModel episode;

  onButtonTap() async {
    await launchUrlString(
        "<https://comic.naver.com/webtoon/detail?titleId=$webtoonId&no=${episode.id}>");
  }

  • 별도의 설치과정이 있다. 영상 앞부분 참고
  • screen.dart에 episode를 받는 부분을 넣어주고 그 외 코드들은 새로 옮겼다.
  • 버튼을 눌렀을 때 받은 id와 episode를 바탕으로 url을 호출한다.

 

 

favorites

late SharedPreferences prefs; //저장소
  bool isLiked = false;

  Future initPrefs() async {
    prefs = await SharedPreferences.getInstance();//인스턴스 생성,rgkf
    final likedToons = prefs.getStringList('likedToons');//저장되어있는 것 가져오기
    if (likedToons != null) { //처음 실행할때
      if (likedToons.contains(widget.id) == true) {
        setState(() {
          isLiked = true;//ui 업데이트
        });
      }
    } else {
      await prefs.setStringList('likedToons', []);//데이터 쓰기
    }
  }

initPrefs(); //시작할때 실행함
  }

  onHeartTap() async { // 좋아요 눌렀을때
    final likedToons = prefs.getStringList('likedToons');//가져오기
    if (likedToons != null) {
      if (isLiked) {
        likedToons.remove(widget.id);
      } else {
        likedToons.add(widget.id);
      }
      await prefs.setStringList('likedToons', likedToons);
      setState(() {
        isLiked = !isLiked;
      });
    }

actions: [
          IconButton(
            onPressed: onHeartTap,
            icon: Icon(
              isLiked ? Icons.favorite : Icons.favorite_outline,
            ),
          )
        ],
  • 핸드폰 안에 저장소 만드는데 별도의 install이 필요하다.
  • likedtoons에 widget.id 리스트를 저장해 불러온다.
반응형