와챠의 우당탕탕 코딩 일기장

[Flutter] Flutter 로 웹툰 앱 만들기: Data fetch/fromJson/async await/ListView/Hero/Futures/Uri Luncher/Pub.dev 본문

코딩 일기장/Flutter

[Flutter] Flutter 로 웹툰 앱 만들기: Data fetch/fromJson/async await/ListView/Hero/Futures/Uri Luncher/Pub.dev

minWachya 2023. 1. 29. 14:54
반응형

목차

  1. 결과물 설명
    1. 화면 및 기능
    2. api
    3. 패키지 구조
  2. 홈 화면
  3. 상세 화면

1. 결과물 설명

만들 웹툰 앱은 다음과 같다.

보는 웹툰은 아니지만 최근에 농놀 다녀와서 이거로 고름ㅋ

1-1. 화면 및 기능

화면은 홈 화면과 상세 화면으로 총 2개고, 각 화면별 기능은 다음과 같다.

  • 홈 화면
    • 오늘의 웹툰 리스트 확인 가능
    • 리스트 아이템 클릭 시 해당 웹툰의 상세 페이지로 이동
  • 상세 화면
    • 웹툰 설명 / 장르 / 연령가 출력
    • 최신 10화 목록 출력
    • 목록 아이템 클릭 시 해당 회차의 네이버 웹툰 url로 이동
    • 하트 기능: 좋아요 표시 가능

1-2. api

여기서 사용할 api는 다음과 같다.

base Url = https://webtoon-crawler.nomadcoders.workers.dev

  • 오늘의 웹툰 목록 받아오기: baseUrl + "/today"
  • 웹툰 상세 정보 받아오기: baseUrl + "/$id"
  • 웹툰 최신 10회차 정보 받아오기: baseUrl + "/$id" + "/episodes"

1-3. 패키지 구조

패키지 구조는 다음과 같다.

  • models
    • webtoon_detail_model.dart: 웹툰 상세 정보(제목, 설명, 장르, 연령가)
    • webtoon_episode_model.dart: 웹툰 최신 화차 정보(회차 제목)
    • webtoon_model.dart: 오늘의 웹툰 목록(제목, 썸네일, id)
  • screens
    • detail_srceen.dart: 상세 화면
    • home_screen.dart: 홈 화면
  • services
    • api_service.dart: api fetch하는 부분
  • widgets
    • episode_widget.dart: 상세화면의 웹툰 에피소드 목록 아이템(초록색)
    • webtoon_widget.dart: 홈 화면의 오늘의 웹툰 목록 아이템

2. 홈 화면

api를 사용해 오늘의 웹툰 목록을 불러올 것이다.

 

이를 위해 pub.dev에서 http 패키지를 다운받아야한다!

다운 받는 법은 간단한데, pub.dev에서 http를 검색한 뒤 install을 눌러 다운 방법을 확인한다.

https://pub.dev/packages/http/install

 

터미널에서 명령어로 입력하는 방법도 있지만 나는 더 간단해보이는 아래 방법을 선택했다.

pubspec.yaml 파일에서 dependencies에 아래 코드를 추가해주고 오른쪽 위의 다운 이모티콘을 눌러주면 된다.

dependencies:
  http: ^0.13.5

 

그리고 services 폴더를 만들고 api_service.dart 파일을 만든다.

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

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

	// 비동기적으로 동작하기 때문에 async await 사용
  static Future<List<WebtoonModel>> getTodaysToons() async {
    List<WebtoonModel> webtoonInstances = [];
    final url = Uri.parse('$baseUrl/$today');

    final response = await http.get(url);	// 이부분 값 받을 때까지 기다렸다가 아래 실행
    
    // 값 잘 받아왔으면 json을 WebtoonModel로 파싱 후 배열에 추가한 뒤 리턴
    if (response.statusCode == 200) {
      final List<dynamic> webtoons = jsonDecode(response.body);
      for (var webtoon in webtoons) {
        final instance = WebtoonModel.fromJson(webtoon);
        webtoonInstances.add(instance);
      }
      return webtoonInstances;
    }
    throw Error();
  }
}

 

참고로 WebtoonModel은 다음과 같이 json을 Model로 변환시켜준다.

class WebtoonModel {
  final String title, thumb, id;

	// json: key가 String이고 value가 dynamic인 Map
  WebtoonModel.fromJson(Map<String, dynamic> json)
      : title = json['title'],
        thumb = json['thumb'],
        id = json['id'];
}

 

이제 home_screen.dart에서 위 api를 사용해 오늘의 웹툰 정보를 불러오고, 이를 예쁘게 출력해주면 된다!!

근데 그 전에!

오늘의 웹툰 목록의 리스트 아이템을 위젯으로 만들어보자.

아래 아이템과 같음!

이때 썸네일의 url을 사용해 이미지를 불러올 건데, 이는 아래 의존성이 또 필요하다~.~

dependencies:
  url_launcher: ^6.1.8

 

widgets > webtoon_widget.dart

// ...
class Webtoon extends StatelessWidget {
	// 오늘의 웹툰 목록 아이템에 들어갈 파라미터들
    // 제목, 썸네일, id
  final String title, thumb, id;

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(	// GestureDetector: 사용자 이벤트 받는 위젯
      onTap: () {		// 클릭 시 아래 함수 실행: 상세 페이지로 이동
        Navigator.push(
          context,
          // MaterialPageRoute: DetailScreen 위젯을 페이지처럼 보여줌
          MaterialPageRoute(
            builder: (context) => DetailScreen(
              title: title,
              thumb: thumb,
              id: id,
            ),
            // 위에서 아래로 뿅 하고 나타나는 애니메이션 true
            fullscreenDialog: true,
          ),
        );
      },
      
      
      child: Column(
        children: [
          Hero( // Hero: 홈 > 상세 화면으로 이동 시, 썸네일 이미지는 동일하기 때문에 이를 부드럽게 연결해줌.
            tag: id,
            child: Container(
              width: 250,
              clipBehavior: Clip.hardEdge,
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(15),
                  boxShadow: [
                    BoxShadow(
                      blurRadius: 5,
                      offset: const Offset(3, 0),
                      color: Colors.black.withOpacity(0.5),
                    ),
                  ]),
                  // 썸네일의 url을 사용해 이미지 출력
              child: Image.network(thumb),
            ),
          ),
          const SizedBox(
            height: 10,
          ),
          Text(
            title,
            style: const TextStyle(
              fontSize: 22,
            ),
          ),
        ],
      ),
    );
  }
}

 

이제 이 위젯을 사용해 오늘의 웹툰 리스트를 받아 출력해보자~

// ...
// 상태를 사용하지 않으므로 StatelessWidget!!!
// api를 사용할 때 파라미터가 따로 필요 없으므로 가능한 일.
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(
        title: const Text(
          "오늘의 웹툰",
          style: TextStyle(
            fontSize: 24,
            fontWeight: FontWeight.w400,
          ),
        ),
        foregroundColor: Colors.green,
        backgroundColor: Colors.white,
        elevation: 2,
      ),
      body: FutureBuilder(	// FutureBuilder:p future값이 올 때까지 기다려줌
        future: webtoons,	// 웹툰 목록 받아올 때까지 기다린다~~ 아이고 착해라
        builder: (context, snapshot) {	// snapshot은 data의 상태이다.
        
        // data가 잘 받아졌으면 실행
          if (snapshot.hasData) {
            return Column(
              children: [
                const SizedBox(
                  height: 50,
                ),
                // 오늘의 웹툰 목록 출력!!!
                Expanded(
                  child: makeList(snapshot),
                ),
              ],
            );
          }
          
          // data가 잘 안받아와졌으면 로딩 화면 보여주기
          return const Center(
            child: CircularProgressIndicator(),
          );
          
        },
      ),
    );
  }
}

 

오늘의 웹툰 목록을 출력하는 함수는 아래와 같다.

그냥 ListView가 아닌, ListView.separated를 사용해 메모리 사용을 줄이면서 로드했다.

// 우리가 받고자하는 List<WebtoonModel>는 AsyncSnapshot의 자식이어야하기 때문에 이를 씌워준다.
ListView makeList(AsyncSnapshot<List<WebtoonModel>> snapshot) {
	// ListView.separated를 사용해 아이템 사이에 간격을 주었다.
    return ListView.separated(
        scrollDirection: Axis.horizontal,	// 스크롤 방향
        itemCount: snapshot.data!.length,	// 아이템 갯수
        // 아이템 패딩
        padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
        // 아이템 출력하는 부분!!
        itemBuilder: (context, index) {
          final webtoon = snapshot.data![index];
          return Webtoon(
              title: webtoon.title, thumb: webtoon.thumb, id: webtoon.id);
        },
        // 간격 주는 부분
        separatorBuilder: (context, index) => const SizedBox(
              width: 40,
            ));
  }

3. 상세 화면

상세화면에서는

웹툰 상세 정보와 최신 10회차 목록을 받아오기 때문에,

api_service.dart에 아래의 함수를 추가해준다.

이때 id를 사용하는 것에 주의..!! < 얘 때문에 StatelessWidget 못 씀

// 웹툰 상세 정보 받아오기
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();
  }

// 웹툰 최신 10회차 목록 가져오기
  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();
  }

 

+ 좋아요 누른 웹툰 정보를 앱 내 저장소에 저장할 것이기 때문에 shared preference 의존성도 다운 받아준다.ㅎㅎ

dependencies:
  shared_preferences: ^2.0.17

 

detail_screen.dart

// ...

// id를 받은 다음에 api를 불러야하므로 StatefulWidget~~
class DetailScreen extends StatefulWidget {
// 상세 화면에 필요한 파라미터들
// 제목, 썸네일, id
  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> webtoonDetail;	// 웹툰 상세
  late Future<List<WebtoonEpisodeModel>> episodes;	// 웹툰 에피소드 목록
  late SharedPreferences prefs;		// 내부 저장소 접근을 위한 변수
  bool isLiked = false;			// 좋아요 눌렀는지 아닌지

	// 내부 저장소 접근: 비동기
  Future initPrefs() async {
    prefs = await SharedPreferences.getInstance();
    // 좋아요 누른 목록 불러오기
    final likeToons = prefs.getStringList('likedToons');
    
    // 좋아요 목록이 존재하면
    // 해당 id가 목록에 있는지 없는지에 따라 isLiked 상태 변경
    if (likeToons != null) {
      if (likeToons.contains(widget.id) == true) {
        setState(() {
          isLiked = true;
        });
      }
    } 
    // 좋아요 목록 없으면 만들기
    else {
      await prefs.setStringList('likedToons', []);
    }
  }

// api 불러오기 + 내부저장소 접근 변수 초기화
  @override
  void initState() {
    super.initState();
    webtoonDetail = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
    initPrefs();
  }

// 좋아요 버튼 눌렀을 때
  onHeartTap() async {
  // 좋아요 목록에 좋아요 누른 id 추가 or 삭제
    final likeToons = prefs.getStringList('likedToons');
    if (likeToons != null) {
      if (isLiked) {
        likeToons.remove(widget.id);
      } else {
        likeToons.add(widget.id);
      }
      // 좋아요 목록 다시 저장
      await prefs.setStringList('likedToons', likeToons);
      // isLiked 상태 변경
      setState(() {
        isLiked = !isLiked;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        title: Text(
          widget.title,
          style: const TextStyle(
            fontSize: 24,
            fontWeight: FontWeight.w400,
          ),
        ),
        // 앱 바에 좋아요 버튼 달고 동작 리스너 달기
        actions: [
          IconButton(
            onPressed: onHeartTap,
            icon: Icon(
            // isLiked 상태에 따라 채워진 하트, 빈하트 보이기
              isLiked ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
            ),
          ),
        ],
        foregroundColor: Colors.green,
        backgroundColor: Colors.white,
        elevation: 2,
      ),
      
      
      body: SingleChildScrollView( // SingleChildScrollView: 스크롤
        child: Padding(
          padding: const EdgeInsets.all(30),
          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: 5,
                              offset: const Offset(3, 0), // center
                              color: Colors.black.withOpacity(0.5),
                            ),
                          ]),
                      child: Image.network(widget.thumb),
                    ),
                  ),
                ],
              ),
              const SizedBox(
                height: 20,
              ),
              FutureBuilder(
                future: webtoonDetail,	// 웹툰 상세 정보가 도착할 때까지 기다리기,,,
                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: 50,
              ),
              
              FutureBuilder(
                future: episodes,	// 에피소드 목록 받을 때까지 기다리기
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      children: [ // 출력
                        for (var episode in snapshot.data!)
                          Episode(episode: episode, webtoonId: widget.id),
                      ],
                    );
                  }
                  return Container();
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

 

 

+ 참고로 episode_widget.dart에서는 아이템 클릭 시 네이버 웹툰으로 이동하는 코드가 있는데 이는 다음과 같다.

// 클릭 시 실행할 함수: 해당 회차의 네이버 웹툰 페이지로 이동
onButtonTop() async {
    await launchUrlString(
        "https://comic.naver.com/webtoon/detail?titleId=$webtoonId&no=${episode.id}");
  }

// 위 함수를  GestureDetector의 onTap에 달아주면 된다.

주석으로 중요 부분 설명은 달아놓았고,,,

생략할 수 있는 코드는 최대한 생략했다.

이정도면 나중에 잘 확인할 수 있을 거라 생각함ㅎ

 

느낀 점:

Hero 이거 뭐냐.. 이거 이렇게 아름다워도 되는 거냐

이거 안드에서 디자이너 or 기획자가 만들어달라고하면

네...?...제가요....? 이거를요....? 하면서 라이브러리 졸라 뒤져볼텐데

플러터에서 이거 해달라고 요구하면

아~ㅋㅋ 오키오키 ㅋㅋ 하고 말 일이네..

대박이다

플러터도 너무 아름답다

이렇게 아름다운 애니메이션을 쉽게 제공하는 플러터에 감동받았다.

...

 

그리고 비동기 함수 실행도 너무너무 간단하네...

안드에서는,,, 인터페이스 만들고 그를 상속받는 클래스와 또 인터페이스, 클래스,,의 연속이었는데

이렇게 간단하면 안드는 뭐가되냐!?!?!

 

내가 젤 맘에 들었던 부분은,,,

if else 문으로 보여지는 화면 전체를 통제할 수 있다는 점이다.

안드에서는 화면단위로 코딩하기 때문에

데이터가 로딩 중일 땐 같은 화면의 ui를 다 invisible하고 로딩ui만 visible하는 식으로 visiblilty를 신경쓰느라 아주아주 귀찮았는데,

플러터는 위젯 단위로 코딩하기 때문인지...

그냥 보이는 화면을 if else로 출력만 하면 되네...

너무하다 너무해

안드는 뭐가 되냐고2....

 

근데 한가지 궁금한 점

안드는 ! 사용보단 ? 사용을 더 권장하는데

플러터가 널세이프 언어라고 해도 !를 이렇게 막 사용해도 되는 건가?

음,,,,물론 해당 값이 널이 아니란 걸 잘 알고는 있지만... 그래도 !를 보는 것이 좀 불편하다.

플러터에서는 이게 기본인지, 아니면 잘 안쓰는지도 알아보고 코딩하면 좋을 거 같다.

 

아무튼 완강을 했습니다~

강의 듣다보면 아래 궁금증이 들 텐데 일단 답변 달아놓음,,,

 

Q. 다트 언어 들어야하나?

A. 비전공자, 개발을 처음하는 사람이 아닌 이상 안 들어도 무관. 특히 코틀린 잘 아는 사람이면 안 들어도 넘넘 무관

 

Q. 플러터 안 깔아도 할 수 있다던데 플레이그라운드로 강의 듣기 충분한가?

A. ㄴㄴ 불편함. VSCode랑 플러터 꼭 깔아야 함... 코드 자동정리, 자동 최적화, 미리보기 등 이거 이용 못하면 너무 불편할듯,,,

반응형
Comments