๋ฐ˜์‘ํ˜•

๐Ÿ’ก ์•„๋ž˜๋Š” Part 1์—์„œ ์ด์–ด์ง€๋Š” ๋‚ด์šฉ

 

Data Fetching


๊ฐ์ฒด์ง€ํ–ฅ ์–ธ์–ด์ธ Dart๋Š” ๊ฑฐ์˜ ๋Œ€๋ถ€๋ถ„์ด ํด๋ž˜์Šค ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋œ๋‹ค. API ํ˜ธ์ถœ, ์‘๋‹ต ๋ฐ์ดํ„ฐ ํƒ€์ž… ์ •์˜, ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ์—ญ์‹œ ๋ชจ๋‘ ํด๋ž˜์Šค ๋‚ด๋ถ€์—์„œ ์ด๋ฃจ์–ด์ง„๋‹ค.

 

๋จผ์ € ์‘๋‹ต ๋ฐ์ดํ„ฐ๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ๋ชจ๋ธ ํด๋ž˜์Šค๋ฅผ ์ •์˜ํ•œ๋‹ค. ์ผ๋ฐ˜์ ์œผ๋กœ fromJson ์ด๋ผ๋Š” ์ด๋ฆ„ ์žˆ๋Š” ์ƒ์„ฑ์ž๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ JSON ๋ฐ์ดํ„ฐ๋ฅผ ์ธ์Šคํ„ด์Šคํ™” ์‹œํ‚จ๋‹ค. ์•„๋ž˜ ์˜ˆ์‹œ์—์„  JSON ๊ฐ์ฒด๋ฅผ ์ธ์ž๋กœ ๋ฐ›๊ธฐ ๋•Œ๋ฌธ์— ์ดˆ๊ธฐํ™” ๋ฆฌ์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํด๋ž˜์Šค ํ•„๋“œ์— ํ• ๋‹นํ•˜๊ณ  ์žˆ๋‹ค.

// lib/models/webtoon_model.dart

class WebtoonModel {
  final String id, title, thumb;

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

 

Dart์˜ ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ์€ ์ฃผ๋กœ http ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. ๋ธŒ๋ผ์šฐ์ €, Node.js์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” fetch ํ•จ์ˆ˜์™€ ๋น„์Šทํ•˜๋‹ค๊ณ  ๋ณด๋ฉด ๋œ๋‹ค. pubspec.yaml ํŒŒ์ผ dependencies ๋ชฉ๋ก์— http: ๋ฒ„์ „ ์„ ์ถ”๊ฐ€ํ•˜๋ฉด, IDE๊ฐ€ ์ž๋™์œผ๋กœ ์ธ์‹ํ•˜์—ฌ ์˜์กด์„ฑ ์ถ”๊ฐ€ ์—ฌ๋ถ€ ํŒ์—…์ด ํ‘œ์‹œ๋œ๋‹ค. ํ˜น์€ Pub get ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ๋ฐ”๋กœ ์ถ”๊ฐ€ํ•  ์ˆ˜๋„ ์žˆ๋‹ค. ํŒจํ‚ค์ง€๋ฅผ ์ œ๊ฑฐํ•  ๋•Œ๋„ dependencies ๋ชฉ๋ก์—์„œ ์ง€์šด ํ›„ Pub get ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ๋œ๋‹ค.

 

 

  • pub get: ํŒจํ‚ค์ง€ ๋‹ค์šด๋กœ๋“œ/์„ค์น˜ (์ตœ์ดˆ ํ”„๋กœ์ ํŠธ ์„ค์ • ํ˜น์€ ํŒจํ‚ค์ง€ ์ถ”๊ฐ€ ํ›„ ์‚ฌ์šฉ)
  • pub upgrade: ์‚ฌ์šฉ ์ค‘์ธ ํŒจํ‚ค์ง€๋“ค์„ ๊ฐ€๋Šฅํ•œ ์ตœ์‹  ๋ฒ„์ „์œผ๋กœ ์—…๊ทธ๋ ˆ์ด๋“œ
  • pub outdated: ์‚ฌ์šฉ ์ค‘์ธ ํŒจํ‚ค์ง€๋“ค์˜ ํ˜„์žฌ ๋ฒ„์ „๊ณผ ์—…๊ทธ๋ ˆ์ด๋“œ ๊ฐ€๋Šฅํ•œ ๋ฒ„์ „์„ ๋น„๊ตํ•ด์„œ ํ‘œ์‹œ

 

๊ทธ ํ›„ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ณ , ๋ถˆ๋Ÿฌ์˜จ ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” API ์„œ๋น„์Šค ํด๋ž˜์Šค๋ฅผ ์ •์˜ํ•œ๋‹ค. BaseUrl๊ณผ ๊ฐ™์€ ๊ฐ’์€ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— const ํ‚ค์›Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ปดํŒŒ์ผ ์ƒ์ˆ˜๋กœ ์„ ์–ธํ•œ๋‹ค. ์ด๋Ÿฌํ•œ ๊ฐ’์€ ์—ฌ๋Ÿฌ ์ธ์Šคํ„ด์Šค์—์„œ ๋™์ผํ•˜๊ฒŒ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ํด๋ž˜์Šค์˜ ์ •์ (static) ํ•„๋“œ๋กœ ์ •์˜ํ•˜๋Š”๊ฒŒ ์ ํ•ฉํ•˜๋‹ค. ๋ฐ์ดํ„ฐ ํŒจ์นญ ๋กœ์ง์€ ๋ณ„๋„์˜ ๋ฉ”์„œ๋“œ์— ์ž‘์„ฑํ•œ๋‹ค.

// services/api_service.dart

import 'dart:convert';

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

import '../models/webtoon_model.dart';

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

  // ์˜ค๋Š˜์˜ ์›นํˆฐ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฉ”์„œ๋“œ. async ํ‚ค์›Œ๋“œ๋ฅผ ๋ถ™์˜€๊ธฐ ๋•Œ๋ฌธ์— Future๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. 
  static Future<List<WebtoonModel>> getTodayToons() async {
    try {
      // baseUrl๊ณผ today๋ฅผ ์‚ฌ์šฉํ•ด HTTPS URI ์ƒ์„ฑ
      // Uri.parse(...)๋กœ๋„ ๊ฐ€๋Šฅ(์ด๋• ์ฃผ์†Œ์— ํ”„๋กœํ† ์ฝœ๊นŒ์ง€ ๋ชจ๋‘ ๋ช…์‹œ)
      final uri = Uri.https(baseUrl, '/$today');
      // ์ƒ์„ฑํ•œ URI๋กœ GET ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ  ์‘๋‹ต ๋Œ€๊ธฐ
      final response = await http.get(uri);

      if (response.statusCode == 200) {
        // String์œผ๋กœ ๋ฐ›์€ ์‘๋‹ต ๋ฐ์ดํ„ฐ๋ฅผ JSON์œผ๋กœ ๋””์ฝ”๋”ฉ. json.decode(...)๋กœ๋„ ๊ฐ€๋Šฅ
        final List<dynamic> webtoons = jsonDecode(response.body);
        // ๋””์ฝ”๋”ฉ๋œ ๊ฐ JSON ๋ฐ์ดํ„ฐ๋ฅผ WebtoonModel ์ธ์Šคํ„ด์Šค๋กœ ๋งคํ•‘ํ•˜์—ฌ ๋ฆฌ์ŠคํŠธ๋กœ ๋ฐ˜ํ™˜
        return webtoons.map((data) => WebtoonModel.fromJson(data)).toList();
      } else {
        // ์ƒํƒœ ์ฝ”๋“œ๊ฐ€ 200์ด ์•„๋‹ ๊ฒฝ์šฐ, ์˜ˆ์™ธ ๋ฐœ์ƒ
        throw Exception('Failed to load webtoons: ${response.statusCode}');
      }
    } on http.ClientException catch (e) {
      // HTTP ํด๋ผ์ด์–ธํŠธ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ
      throw Exception('Failed to fetch webtoons: $e');
    } on Exception catch (e) {
      // ๊ธฐํƒ€ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ
      throw Exception('An unknown error occurred: $e');
    }
  }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ. ์ฝ˜์†” ์ถœ๋ ฅ: [Instance of 'WebtoonModel', ...]
ApiService.getTodayToons().then(print);

 

Uri.https ์ƒ์„ฑ์ž๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํ”„๋กœํ† ์ฝœ์ด ํฌํ•จ๋œ URI ์ฃผ์†Œ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. ์ฒซ ๋ฒˆ์งธ ์ธ์ž๋Š” ๋ณดํ†ต ๋„๋ฉ”์ธ ์ฃผ์†Œ๋ฅผ, ๋‘๋ฒˆ์งธ ์ธ์ž๋Š” ๋ฆฌ์†Œ์Šค์— ๋Œ€ํ•œ ๊ฒฝ๋กœ(path)๋ฅผ ์ง€์ •ํ•œ๋‹ค. ์ด๋•Œ ๊ฒฝ๋กœ ๊ฐ€์žฅ ์•ž์—๋Š” / ์Šฌ๋ž˜์‹œ๋ฅผ ๋ถ™์—ฌ์•ผ ํ•˜๊ณ , ๋‚ด๋ถ€์ ์œผ๋กœ ์ž๋™ ์ธ์ฝ”๋”ฉ ๋˜๊ธฐ ๋•Œ๋ฌธ์—, ์ธ์ฝ”๋”ฉ๋˜์ง€ ์•Š์€ ์ฃผ์†Œ๋ฅผ ๋„˜๊ฒจ์•ผ ํ•œ๋‹ค.

 

on ์˜ˆ์™ธ์œ ํ˜• ๊ตฌ๋ฌธ์€ ํŠน์ • ์˜ˆ์™ธ ์œ ํ˜•์„ ์ง€์ •ํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค. ์˜ˆ์™ธ ๊ฐ์ฒด๊ฐ€ ํ•„์š”ํ•  ๋• on ์˜ˆ์™ธ์œ ํ˜• catch ๊ตฌ๋ฌธ์„ ์‚ฌ์šฉํ•œ๋‹ค. ์ผ์น˜ํ•˜๋Š” ์˜ˆ์™ธ ์œ ํ˜•์ด ์—†์œผ๋ฉด ๋‹ค์Œ ๋ธ”๋ก์œผ๋กœ ๋„˜์–ด๊ฐ€๊ณ , ์˜ˆ์™ธ๋ฅผ ์ฒ˜๋ฆฌํ•  ๋ธ”๋ก์ด ์—†์„ ๊ฒฝ์šฐ์—” ํ”„๋กœ๊ทธ๋žจ์ด ์ข…๋ฃŒ๋˜๊ธฐ ๋•Œ๋ฌธ์— ์ผ๋ฐ˜ catch ๋ธ”๋ก๋„ ํ•จ๊ป˜ ์ง€์ •ํ•ด์ฃผ๋Š” ๊ฒƒ์ด ์ข‹๋‹ค.

 

๋ฐ์ดํ„ฐ ํŒจ์นญ/์ฒ˜๋ฆฌ ๊ณผ์ •์„ ์ •๋ฆฌํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™๋‹ค.

  1. URI ์ƒ์„ฑ : Uri.https()
  2. ๋„คํŠธ์›Œํฌ ์š”์ฒญ : http.get()
  3. ๋ฌธ์ž์—ด์„ JSON์œผ๋กœ ๋ณ€ํ™˜ : jsonDecode()
  4. JSON ๊ฐ ํ•ญ๋ชฉ์„ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ๋กœ ๋งตํ•‘ ํ›„ ๋ฆฌ์ŠคํŠธ๋กœ ๋ฐ˜ํ™˜

 

 

FutureBuilder


FutureBuilder๋Š” ๋น„๋™๊ธฐ ์ž‘์—…์˜ ์ƒํƒœ๋ฅผ ์ถ”์ ํ•˜๊ณ , ๊ทธ ๊ฒฐ๊ณผ์— ๋”ฐ๋ผ UI๋ฅผ ์ž๋™์œผ๋กœ ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ๋Š” ์œ„์ ฏ์ด๋‹ค. FutureBuilder๋Š” Future ๊ฐ์ฒด๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„ ํ•ด๋‹น ์ž‘์—…์ด ์™„๋ฃŒ๋˜๋ฉด builder ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด UI๋ฅผ ๋‹ค์‹œ ๋ Œ๋”๋งํ•œ๋‹ค. then, await ๋“ฑ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ ๋„ ๋น„๋™๊ธฐ ์ž‘์—… ์ƒํƒœ์— ๋”ฐ๋ผ UI๋ฅผ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ์žฅ์ ์ด ์žˆ๋‹ค. React 18๋ฒ„์ „๋ถ€ํ„ฐ ์ง€์›ํ•˜๋Š” Suspense์™€ ์œ ์‚ฌํ•˜๋‹ค๊ณ  ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

 

FutureBuilder๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฉด isLoading ๋‚ด๋ถ€ ์ƒํƒœ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , initState ๊ฐ™์€ ๋ผ์ดํ”„์‚ฌ์ดํด ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด StatefulWidget์œผ๋กœ ์ž‘์„ฑํ•ด์•ผํ•œ๋‹ค. ์ด๋กœ์ธํ•ด ๊ฑฐ์ถ”์žฅ์Šค๋Ÿฌ์šด ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ๊ฐ€ ๋งŽ์•„์ง„๋‹ค .

// FutureBuilder๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š์•˜์„ ๋•Œ

class HomeScreen extends StatefulWidget {
  // ...
}

class _HomeScreenState extends State<HomeScreen> {
  List<WebtoonModel> webtoons = [];
  bool isLoading = true;

  void waitForWebToons() async {
    webtoons = await ApiService.getTodayToons();
    isLoading = false;
    setState(() => {});
  }

  @override
  void initState() {
    super.initState();
    waitForWebToons();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(/* ... */),
    );
  }
}

 

FutureBuilder๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด StatefulWidget์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ ๋„ ์„ ์–ธ์ ์ธ ๋ฐฉ์‹์œผ๋กœ ๋น„๋™๊ธฐ ์ž‘์—…์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค. FutureBuilder์˜ future ์†์„ฑ์— ๋น„๋™๊ธฐ ์ž‘์—…์„ ๋ฐ˜ํ™˜ํ•˜๋Š” Future ๊ฐ์ฒด๋ฅผ ๋„ฃ์–ด์ฃผ๋ฉด, ๋น„๋™๊ธฐ ์ž‘์—…์˜ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค builder ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋ผ์„œ UI๋ฅผ ์—…๋ฐ์ดํŠธํ•œ๋‹ค.

// FutureBuilder๋ฅผ ์‚ฌ์šฉํ–ˆ์„ ๋•Œ

// ...
import 'package:practice_flutter/services/api_service.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(/* ... */),
      body: FutureBuilder(
        future: webtoons,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            // ๋กœ๋”ฉ ์ค‘์ผ ๋•Œ
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            // ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ
            return Center(child: Text('Error: ${snapshot.error}'));
          } else if (snapshot.hasData) {
            // ๋ฐ์ดํ„ฐ ์žˆ์„ ๋•Œ (์ƒ๋žต)
          }
          // ๋ฐ์ดํ„ฐ ์—†์„ ๋•Œ
          return const Center(child: Text('No data available'));
        },
      ),
    );
  }
}

 

builder ํ•จ์ˆ˜์˜ ๋‘ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ snapshot์€ Future์˜ ์ƒํƒœ๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ๊ฐ์ฒด๋กœ ๋‹ค์–‘ํ•œ ์†์„ฑ์„ ์ œ๊ณตํ•œ๋‹ค. ์ด๋ฅผ ์ด์šฉํ•ด ํ˜„์žฌ ์ƒํƒœ์— ๋งž๋Š” UI๋ฅผ ์ ์ ˆํ•˜๊ฒŒ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋‹ค.

  • connectionState: ๋น„๋™๊ธฐ ์ž‘์—…์˜ ํ˜„์žฌ ์ƒํƒœ
    • none: Future๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š์Œ
    • waiting: Future๊ฐ€ ์‹คํ–‰ ์ค‘์ด๋ฉฐ, ๊ฒฐ๊ณผ ๋Œ€๊ธฐ ์ค‘
    • active: Future ์ง„ํ–‰ ์ค‘ (์ฃผ๋กœ StreamBuilder์—์„œ ์‚ฌ์šฉ)
    • done: Future ์™„๋ฃŒ
  • hasData: ์„ฑ๊ณต์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์œผ๋ฉด true ๋ฐ˜ํ™˜
  • hasError: ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ true ๋ฐ˜ํ™˜
  • data: ์™„๋ฃŒ ํ›„ ๋ฐ˜ํ™˜๋œ ๋ฐ์ดํ„ฐ
  • error: ๋ฐœ์ƒํ•œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋˜๋Š” ๊ฐ์ฒด

 

 

ListView


์Šคํฌ๋กค ํ•  ์ˆ˜ ์žˆ๋Š” ๋ชฉ๋ก์„ ํ‘œ์‹œํ•  ๋•Œ ListView ์œ„์ ฏ์„ ์‚ฌ์šฉํ•œ๋‹ค. ListView๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์Šคํฌ๋กค ๊ธฐ๋Šฅ์„ ์ง€์›ํ•˜์—ฌ ๋ฐ์ดํ„ฐ๊ฐ€ ํ™”๋ฉด์„ ์ดˆ๊ณผํ•  ๊ฒฝ์šฐ ์Šคํฌ๋กคํ•  ์ˆ˜ ์žˆ๋‹ค. ๋ฐ์ดํ„ฐ ์–‘์ด ๋งŽ๋‹ค๋ฉด ListView.builder ์ƒ์„ฑ์ž๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์ง€์—ฐ ๋กœ๋”ฉ(Lazy Loading)์„ ์ ์šฉํ•  ์ˆ˜๋„ ์žˆ๋‹ค.

// ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

ListView(
  children: <Widget>[
    ListTile(
      leading: Icon(Icons.map), // ์นด๋“œ ๊ฐ€์žฅ ์™ผ์ชฝ์— ๋‚˜ํƒ€๋‚˜๋Š” ์š”์†Œ
      title: Text('Map'), // ์นด๋“œ ์šฐ์ธก ์ƒ๋‹จ ์ œ๋ชฉ
    ),
    ListTile(
      leading: Icon(Icons.photo_album),
      title: Text('Album'),
    ),
    ListTile(
      leading: Icon(Icons.phone),
      title: Text('Phone'),
    ),
  ],
)

 

ListView.builder ์ƒ์„ฑ์ž๋Š” ํ™”๋ฉด์— ๋…ธ์ถœ๋œ ํ•ญ๋ชฉ๋“ค๋งŒ ๋นŒ๋“œํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์„ฑ๋Šฅ ์ตœ์ ํ™”๊ฐ€ ํ•„์š”ํ•œ ์ƒํ™ฉ์ด๋‚˜ ๋ฌดํ•œ ์Šคํฌ๋กค ๋“ฑ์„ ๊ตฌํ˜„ํ•  ๋•Œ ์œ ์šฉํ•˜๊ฒŒ ์“ฐ์ธ๋‹ค. ํŠนํžˆ, ๋ฐ์ดํ„ฐ๊ฐ€ ๋งŽ๊ฑฐ๋‚˜ ๋™์ ์œผ๋กœ ์ถ”๊ฐ€๋  ๋•Œ ์ด์ „์— ๋…ธ์ถœ๋œ ํ•ญ๋ชฉ์„ ์žฌ์‚ฌ์šฉํ•˜์—ฌ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ ˆ์•ฝํ•  ์ˆ˜ ์žˆ๋‹ค.

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(/* ... */),
      body: FutureBuilder(
        future: webtoons,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return ListView.builder(
              // ์Šคํฌ๋กค ๋ฐฉํ–ฅ ์ง€์ •
              scrollDirection: Axis.horizontal,
              // ์•„์ดํ…œ ์ด ๊ฐœ์ˆ˜ ์ง€์ •
              itemCount: snapshot.data!.length,
              // ํ™”๋ฉด์— ๋…ธ์ถœ๋œ ์•„์ดํ…œ์„ ์ƒ์„ฑํ•  ๋•Œ ํ˜ธ์ถœ๋˜๋Š” ํ•จ์ˆ˜
              itemBuilder: (BuildContext context, int index) {
                var webtoon = snapshot.data![index];
                return Text(webtoon.title);
              },
            );
          }
          return const Center(
            child: CircularProgressIndicator(),
          );
        },
      ),
    );
  }
}

 

itemCount ์†์„ฑ์œผ๋กœ ์•„์ดํ…œ์˜ ์ด ๊ฐœ์ˆ˜๋ฅผ ์ง€์ •ํ•˜๋ฉด ๋นŒ๋”๊ฐ€ ํ˜ธ์ถœ๋  ํšŸ์ˆ˜๋ฅผ ๋ฏธ๋ฆฌ ๊ณ„์‚ฐํ•ด์„œ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋” ํšจ์œจ์ ์œผ๋กœ ์ƒ์„ฑํ•œ๋‹ค. itemBuilder๋Š” ๊ฐ ์•„์ดํ…œ์„ ์ƒ์„ฑํ•  ๋•Œ ํ˜ธ์ถœ๋˜๋Š” ํ•จ์ˆ˜๋กœ ์•„์ดํ…œ์ด ํ™”๋ฉด์— ๋…ธ์ถœ๋  ๋•Œ๋งˆ๋‹ค ํ•ด๋‹น ์•„์ดํ…œ์˜ ์ธ๋ฑ์Šค ์ •๋ณด๊ฐ€ itemBuilder ๋‘ ๋ฒˆ์งธ ์ธ์ž๋กœ ์ „๋‹ฌ๋œ๋‹ค.

 

์ถœ๋ ฅ ํ™”๋ฉด

๋ฆฌ์ŠคํŠธ ํ•ญ๋ชฉ ์‚ฌ์ด์— ๊ตฌ๋ถ„์„ ์„ ๋„ฃ์„ ์ˆ˜ ์žˆ๋Š” ListView.separated ์ƒ์„ฑ์ž๋„ ์žˆ๋‹ค. ๊ฐ ์•„์ดํ…œ ์‚ฌ์ด์— ๊ฐ„๊ฒฉ์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ํŠน์ • ์œ„์ ฏ์„ ํ‘œ์‹œํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค. separatorBuilder๋Š” ๋ฆฌ์ŠคํŠธ ํ•ญ๋ชฉ ์‚ฌ์ด์— ๋“ค์–ด๊ฐˆ ๊ตฌ๋ถ„์„ ์„ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜๋กœ, ๊ตฌ๋ถ„์„ ์ด ํ™”๋ฉด์— ๋…ธ์ถœ๋  ๋•Œ๋งˆ๋‹ค ํ˜ธ์ถœ๋˜๊ณ , ๋‘ ๋ฒˆ์งธ ์ธ์ž๋กœ ๋…ธ์ถœ ์ค‘์ธ ํ•ญ๋ชฉ์˜ ์ธ๋ฑ์Šค๊ฐ€ ์ „๋‹ฌ๋œ๋‹ค.

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.white,
        appBar: AppBar(/* ... */),
        body: FutureBuilder(
          future: webtoons,
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              return ListView.separated(
                scrollDirection: Axis.horizontal,
                itemCount: snapshot.data!.length,
                itemBuilder: (BuildContext context, int index) {
                  var webtoon = snapshot.data![index];
                  return Text(webtoon.title);
                },
                // ํ™”๋ฉด์— ๋…ธ์ถœ๋œ ์•„์ดํ…œ ์‚ฌ์ด์— ๊ตฌ๋ถ„์„ ์„ ์ƒ์„ฑํ•  ๋•Œ ํ˜ธ์ถœ
                separatorBuilder: (BuildContext context, int index) {
                  return const SizedBox(width: 10);
                },
              );
            }
            return const Center(child: CircularProgressIndicator());
          },
        ));
  }
}

 

์ถœ๋ ฅ ํ™”๋ฉด

 

Image.network


๋„คํŠธ์›Œํฌ ์ƒ์˜ ์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ Image.network ์ƒ์„ฑ์ž๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. Image.network๋Š” ๋กœ๋”ฉ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ loadingBuilder, ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ errorBuilder ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ณตํ•˜๋ฉฐ, ์ด๋ฏธ ๋‹ค์šด๋กœ๋“œํ•œ ์ด๋ฏธ์ง€๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๊ธฐ๊ธฐ์— ์ €์žฅ๋œ ์บ์‹œ๋ฅผ ์žฌ์‚ฌ์šฉํ•œ๋‹ค.

Image.network(
  // ๋ถˆ๋Ÿฌ์˜ฌ ์ด๋ฏธ์ง€ URL
  'https://example.com/image.png',
  // ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•˜๋Š” ๋™์•ˆ ํ‘œ์‹œํ•  ์œ„์ ฏ ์ •์˜
  loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
    if (loadingProgress == null) {
      return child; // ๋กœ๋“œ ์™„๋ฃŒ ์‹œ ์ด๋ฏธ์ง€(child) ๋ฐ˜ํ™˜
    } else {
      return Center(
        // ์ง„ํ–‰๋ฅ ์„ ํ‘œ์‹œํ•˜๋Š” ์›ํ˜• ์ธ๋””์ผ€์ดํ„ฐ. value๋Š” 0.0~1.0 ์‚ฌ์ด์˜ ๊ฐ’. null์ด๋ฉด ๋ฌดํ•œ ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ๋กœ ๋™์ž‘
        child: CircularProgressIndicator(
          value: loadingProgress.expectedTotalBytes != null
              ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
              : null, // ์ด๋ฏธ์ง€ ํฌ๊ธฐ๋ฅผ ์•Œ ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ null ๋ฐ˜ํ™˜
        ),
      );
    }
  },
  // ์ด๋ฏธ์ง€ ๋กœ๋“œ์— ์‹คํŒจํ–ˆ์„ ๋•Œ ํ‘œ์‹œํ•  ์œ„์ ฏ ์ •์˜
  errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) {
    return Text('์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ');
  },
  // ๋„คํŠธ์›Œํฌ ์š”์ฒญ์— ํ•„์š”ํ•œ HTTP ํ—ค๋” ์ง€์ •
  headers: const {
    'Referer': 'https://example.com',
  },
  // ์ด ์™ธ์—๋„ width, height, fit ๋“ฑ์˜ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ํ†ตํ•ด ์ด๋ฏธ์ง€ ํ‘œ์‹œ ๋ฐฉ์‹์„ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ๋‹ค
)

 

loadingBuilder ์ฝœ๋ฐฑ์€ ์•„๋ž˜ 3๊ฐœ์˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ›๋Š”๋‹ค.

  1. context: ํ˜„์žฌ ์œ„์ ฏ ํŠธ๋ฆฌ์—์„œ Image.network๊ฐ€ ์œ„์น˜ํ•œ ๋นŒ๋“œ ์ปจํ…์ŠคํŠธ.
  2. child: ์ด๋ฏธ์ง€ ๋กœ๋“œ๋ฅผ ์™„๋ฃŒํ–ˆ์„ ๋•Œ ํ‘œ์‹œํ•  ์‹ค์ œ ์ด๋ฏธ์ง€.
  3. loadingProgress: ์ด๋ฏธ์ง€ ๋กœ๋“œ ์ƒํƒœ๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ImageChunkEvent ๊ฐ์ฒด. ๋กœ๋“œ ์™„๋ฃŒ ์‹œ null ๋ฐ˜ํ™˜. ์ด ๊ฐ์ฒด๋Š” ์•„๋ž˜ ๋‘ ์†์„ฑ์„ ๊ฐ€์ง„๋‹ค.
    • cumulativeBytesLoaded: ํ˜„์žฌ๊นŒ์ง€ ๋‹ค์šด๋กœ๋“œํ•œ ๋ฐ”์ดํŠธ ์ˆ˜ (int).
    • expectedTotalBytes: ์ด๋ฏธ์ง€ ํŒŒ์ผ์˜ ์˜ˆ์ƒ ์ด ๋ฐ”์ดํŠธ ์ˆ˜ (int?). ์ด๋ฏธ์ง€ ํฌ๊ธฐ๋ฅผ ์•Œ ์ˆ˜ ์—†์œผ๋ฉด null ๋ฐ˜ํ™˜.

 

 

GestureDetector


์‚ฌ์šฉ์ž์˜ ํ„ฐ์น˜ ๋™์ž‘์„ ๊ฐ์ง€ํ•˜๊ณ  ์ฒ˜๋ฆฌํ•  ๋•Œ GestureDetector ์œ„์ ฏ์„ ์‚ฌ์šฉํ•œ๋‹ค. ํƒญ, ๋”๋ธ”ํƒญ, ๋“œ๋ž˜๊ทธ, ์Šค์™€์ดํ”„ ๋“ฑ ๋‹ค์–‘ํ•œ ์ œ์Šค์ฒ˜๋ฅผ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค. ์›น๊ฐœ๋ฐœ์—์„œ ์ž์ฃผ ์‚ฌ์šฉํ•˜๋Š” onClick, onChange ์ด๋ฒคํŠธ์™€ ๋น„์Šทํ•˜๊ฒŒ ์ž‘๋™ํ•œ๋‹ค๊ณ  ๋ณด๋ฉด ๋œ๋‹ค.

 

GestureDetector ํด๋ž˜์Šค์˜ child ์†์„ฑ์— ํ„ฐ์น˜ ์ด๋ฒคํŠธ๋ฅผ ๊ฐ์ง€ํ•  ์œ„์ ฏ์„ ์ถ”๊ฐ€ํ•˜๊ณ , ์›ํ•˜๋Š” ์ œ์Šค์ฒ˜์— ๋Œ€ํ•œ ์ฝœ๋ฐฑ์„ ์ •์˜ํ•˜๋ฉด ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•  ๋•Œ๋งˆ๋‹ค ๋Œ€์‘ํ•˜๋Š” ์ฝœ๋ฐฑ์ด ์‹คํ–‰๋œ๋‹ค.

GestureDetector(
  onTap: () {
    print('ํƒญ ๊ฐ์ง€');
  },
  onDoubleTap: () {
    print('๋”๋ธ”ํƒญ ๊ฐ์ง€');
  },
  onLongPress: () {
    print('๋กฑํ”„๋ ˆ์Šค ๊ฐ์ง€');
  },
  child: Container(
    width: 100,
    height: 100,
    color: Colors.blue,
    child: Center(
      child: Text('ํ„ฐ์น˜'),
    ),
  ),
);

 

 

Page Transition


Navigator.push์™€ MaterialPageRoute๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๋ฅผ ์Šคํƒ(stack)์— ์ถ”๊ฐ€ํ•˜์—ฌ ๋‹ค๋ฅธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•  ์ˆ˜ ์žˆ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์˜ ํŽ˜์ด์ง€ ๋‚ด๋น„๊ฒŒ์ด์…˜์ฒ˜๋Ÿผ(์•ž์œผ๋กœ/๋’ค๋กœ๊ฐ€๊ธฐ) ํ”Œ๋Ÿฌํ„ฐ๋„ ํ™”๋ฉด ์ „ํ™˜์„ ์Šคํƒ ๊ตฌ์กฐ๋กœ ๊ด€๋ฆฌํ•œ๋‹ค. ์ƒˆ๋กœ์šด ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•  ๋• ํŽ˜์ด์ง€๊ฐ€ ์Šคํƒ์— ์Œ“์ด๊ณ , ๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ์—” ์Šคํƒ์—์„œ ์ œ๊ฑฐ๋œ๋‹ค.

 

Navigator.push๋Š” ์ƒˆ๋กœ์šด ํ™”๋ฉด(ํŽ˜์ด์ง€)์„ ์Šคํƒ์— ์ถ”๊ฐ€ํ•˜๋Š” ์—ญํ• ์„ ํ•˜๋ฉฐ, MaterialPageRoute๋Š” ์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ณ  Material Design ๊ฐ€์ด๋“œ๋ฅผ ๋”ฐ๋ฅธ ํ™”๋ฉด ์ „ํ™˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ œ๊ณตํ•œ๋‹ค.

Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => SecondPage(),
    fullscreenDialog: true,
  ),
  // ...
);

 

๊ธฐ๋ณธ์ ์œผ๋กœ ํ™”๋ฉด ์ „ํ™˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์€ ์˜ค๋ฅธ์ชฝ์—์„œ ์™ผ์ชฝ์œผ๋กœ ์Šฌ๋ผ์ด๋“œ ๋˜๋ฉด์„œ ์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๊ฐ€ ๋‚˜ํƒ€๋‚˜๊ณ , ์ƒ๋‹จ์— ๋’ค๋กœ ๊ฐ€๊ธฐ ๋ฒ„ํŠผ์ด ํ‘œ์‹œ๋œ๋‹ค. fullscreenDialog ์†์„ฑ์„ true๋กœ ์„ค์ •ํ•˜๋ฉด, ์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๊ฐ€ ์•„๋ž˜์—์„œ ์œ„๋กœ ์Šฌ๋ผ์ด๋“œ ๋˜๋ฉด์„œ ์ „์ฒด ํ™”๋ฉด ๋ชจ๋“œ๋กœ ์—ด๋ฆฌ๋ฉฐ, ๋’ค๋กœ ๊ฐ€๊ธฐ ๋ฒ„ํŠผ ๋Œ€์‹  ๋‹ซ๊ธฐ ๋ฒ„ํŠผ์ด ํ‘œ์‹œ๋œ๋‹ค. ์ด ๋ฐฉ์‹์€ ์ฃผ๋กœ ์„ค์ • ํŽ˜์ด์ง€๋‚˜ ๋ณ„๋„์˜ ํ™•์ธ ์ฐฝ์„ ์—ด ๋•Œ ์œ ์šฉํ•˜๋‹ค. ์ฐธ๊ณ ๋กœ fullscreenDialog์˜ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋™์ž‘์€ ํ”Œ๋žซํผ๋งˆ๋‹ค ๋‹ค๋ฅผ ์ˆ˜ ์žˆ๋‹ค.

 

(์ขŒ)fullscreenDialog: false(๊ธฐ๋ณธ๊ฐ’), (์šฐ)fullscreenDialog: true

 

์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๋Š” ์™„์ „ํžˆ ๋…๋ฆฝ์ ์ธ ํ™”๋ฉด์ด๊ธฐ ๋•Œ๋ฌธ์— Scaffold ์œ„์ ฏ์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ธฐ๋ณธ ๊ตฌ์กฐ๋ฅผ ์ •์˜ํ•ด์•ผ ํ•œ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์•ฑ๋ฐ”(appBar), ๋ณธ๋ฌธ(body), ํ”Œ๋กœํŒ… ์•ก์…˜ ๋ฒ„ํŠผ๊ณผ ๊ฐ™์€ ๋ ˆ์ด์•„์›ƒ ์š”์†Œ๋ฅผ ์ƒˆ๋กญ๊ฒŒ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Second Page'),
      ),
      body: Center(
        child: Text('Welcome to Second Page'),
      ),
    );
  }
}

 

 

Hero


Hero ์œ„์ ฏ์€ ๋‘ ํ™”๋ฉด(ํŽ˜์ด์ง€) ๊ฐ„์˜ ์ž์—ฐ์Šค๋Ÿฌ์šด ์ „ํ™˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ œ๊ณตํ•˜์—ฌ, ํŠน์ • UI ์š”์†Œ๊ฐ€ ๋งˆ์น˜ ํ™”๋ฉด์„ ๊ฐ€๋กœ์งˆ๋Ÿฌ ์ด๋™ํ•˜๋Š” ๋“ฏํ•œ ์‹œ๊ฐ์  ํšจ๊ณผ๋ฅผ ๋งŒ๋“ ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด Navigator๋ฅผ ํ†ตํ•ด A ํ™”๋ฉด์—์„œ B ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•  ๋•Œ A์™€ B ํ™”๋ฉด์—์„œ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ์š”์†Œ๋ฅผ Hero ์œ„์ ฏ์œผ๋กœ ๊ฐ์‹ธ๋ฉด, ํ•ด๋‹น ์š”์†Œ๊ฐ€ ๋‘ ํ™”๋ฉด ์‚ฌ์ด๋ฅผ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ด๋™ํ•˜๋Š” ๋“ฏํ•œ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ ์šฉ๋œ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž๋Š” ํ™”๋ฉด ์ „ํ™˜ ์‹œ ์ผ๊ด€์„ฑ ์žˆ๊ณ  ๋ถ€๋“œ๋Ÿฌ์šด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์–ป์„ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์•ฑ์˜ ํ๋ฆ„์„ ๋” ์‰ฝ๊ฒŒ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋‹ค.

 

์ฝ”๋“œ ์ถœ์ฒ˜ - Flutter ๊ณต์‹ ๋ฌธ์„œ

Hero ์œ„์ ฏ์€ ์‚ฌ์šฉ ๋ฐฉ๋ฒ•๋„ ๊ฐ„๋‹จํ•˜๋‹ค. Hero ์ „ํ™˜ ํšจ๊ณผ๋ฅผ ์ฃผ๊ณ  ์‹ถ์€ A, B ํ™”๋ฉด์˜ ์š”์†Œ๋ฅผ ๊ฐ๊ฐ Hero ์œ„์ ฏ์œผ๋กœ ๊ฐ์‹ผ ํ›„ ๊ณ ์œ ํ•œ tag ๊ฐ’์„ ์ง€์ •ํ•˜๋ฉด ๋œ๋‹ค. Hero ์œ„์ ฏ์€ ์ด๋ฏธ์ง€ ๊ฐค๋Ÿฌ๋ฆฌ, ์นด๋“œ ๋ฆฌ์ŠคํŠธ ๋˜๋Š” ํ™”๋ฉด ์ „ํ™˜ ์‹œ ์‹œ๊ฐ์  ์—ฐ๊ฒฐ๊ฐ์„ ๊ฐ•์กฐํ•˜๊ณ ์ž ํ•  ๋•Œ ์œ ์šฉํ•˜๋‹ค.

// ...

class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('First Screen'),
      ),
      body: GestureDetector(
        onTap: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => SecondScreen(),
            ),
          );
        },
        child: Hero(
          // ๋‘ ํ™”๋ฉด์˜ Hero ์œ„์ ฏ tag ๊ฐ’์„ ๋™์ผํ•˜๊ฒŒ ์ง€์ •ํ•œ๋‹ค
          tag: 'hero-tag',
          child: Image.asset(
            'assets/my_image.png',
            width: 100,
            height: 100,
          ),
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Second Screen'),
      ),
      body: Center(
        child: Hero(
          // ๋‘ ํ™”๋ฉด์˜ Hero ์œ„์ ฏ tag ๊ฐ’์„ ๋™์ผํ•˜๊ฒŒ ์ง€์ •ํ•œ๋‹ค
          tag: 'hero-tag',
          child: Image.asset(
            'assets/my_image.png',
            width: 300,
            height: 300,
          ),
        ),
      ),
    );
  }
}

 

 

Refer to Parent Widget


Stateful ์œ„์ ฏ์—์„œ ๋ถ€๋ชจ ์œ„์ ฏ์˜ ๋ฐ์ดํ„ฐ๋‚˜ ์„ค์ •์„ ์ฐธ์กฐํ•  ๋• widget์„ ์‚ฌ์šฉํ•œ๋‹ค.

 

Stateful ์œ„์ ฏ์„ ์ •์˜ํ•  ๋•Œ, _MyWidget extends State<MyWidget> ํ˜•์‹์œผ๋กœ ์ด ์œ„์ ฏ์˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๋ณ„๋„์˜ State ํด๋ž˜์Šค๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. State ํด๋ž˜์Šค ๋‚ด๋ถ€์—์„  widget ์ ‘๊ทผ์ž ์†์„ฑ์„ ์ œ๊ณตํ•˜๋Š”๋ฐ, ์ด ์†์„ฑ์„ ํ†ตํ•ด ๋ถ€๋ชจ ํ”„๋กœํผํ‹ฐ๋‚˜ ๋ฐ์ดํ„ฐ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋‹ค.

// Stateful ์œ„์ ฏ
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();
}

// State ํด๋ž˜์Šค
class _DetailScreenState extends State<DetailScreen> {
  late Future<WebtoonDetailModel> webtoon;
  late Future<List<WebtoonEpisodeModel>> episodes;

  @override
  void initState() {
    super.initState();
    // widget ์ ‘๊ทผ์ž ์†์„ฑ์„ ํ†ตํ•ด ๋ถ€๋ชจ ์œ„์ ฏ์˜ ํ”„๋กœํผํ‹ฐ ์ ‘๊ทผ
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(/* ... */);
  }
}

 

์œ„ ์ฝ”๋“œ์—์„œ getToonById, getLatestEpisodesById๋Š” id๋ฅผ ๋ฐ›์•„์•ผ ํ•˜๋Š” ํ•จ์ˆ˜๋“ค์ด๋‹ค. ์ด id๋Š” ๋ถ€๋ชจ ์œ„์ ฏ์ธ DetailScreen์ด ์ƒ์„ฑ๋  ๋•Œ ์ „๋‹ฌ๋˜์ง€๋งŒ, State ํด๋ž˜์Šค๊ฐ€ ์ดˆ๊ธฐํ™”๋˜๊ธฐ ์ „๊นŒ์ง„ ๋ถ€๋ชจ ์œ„์ ฏ์˜ ํ”„๋กœํผํ‹ฐ์— ์•ˆ์ „ํ•˜๊ฒŒ ์ ‘๊ทผํ•  ์ˆ˜ ์—†๋‹ค.

 

๋•Œ๋ฌธ์— ๋ถ€๋ชจ ์œ„์ ฏ์˜ ์ •๋ณด๋ฅผ ํ•„์š”๋กœ ํ•˜๋Š” ํ”„๋กœํผํ‹ฐ๋“ค์€ late ํ‚ค์›Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์„ ์–ธํ•˜๊ณ (null ์•ˆ์ „์„ฑ ๋ณด์žฅ), ์‹ค์ œ ๋ฐ์ดํ„ฐ ํ• ๋‹น์€ initState() ์‹คํ–‰ ์‹œ์ ์œผ๋กœ ์ง€์—ฐ์‹œ์ผœ์•ผ ํ•œ๋‹ค. initState()๋Š” ์ƒํƒœ ํด๋ž˜์Šค์˜ ์ดˆ๊ธฐํ™”๊ฐ€ ์™„๋ฃŒ๋œ ํ›„ ํ˜ธ์ถœ๋˜๋ฏ€๋กœ, ์ด ์‹œ์ ์— widget ํ”„๋กœํผํ‹ฐ์— ์ ‘๊ทผํ•˜๋Š” ๊ฒƒ์€ ์•ˆ์ „ํ•˜๋‹ค.

 

 

SingleChildScrollView


ํ™”๋ฉด์„ ๋ฒ—์–ด๋‚œ ์ฝ˜ํ…์ธ ๋ฅผ ์Šคํฌ๋กค ๊ฐ€๋Šฅํ•˜๋„๋ก ๋งŒ๋“ค ๋•Œ SingleChildScrollView ํด๋ž˜์Šค(์œ„์ ฏ)๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด ํด๋ž˜์Šค๋Š” ํ•˜๋‚˜์˜ ์ž์‹ ์œ„์ ฏ๋งŒ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, scrollDirection ์†์„ฑ์„ ํ†ตํ•ด ์Šคํฌ๋กค ๋ฐฉํ–ฅ(๊ฐ€๋กœ/์„ธ๋กœ)์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์€ ์„ธ๋กœ ์Šคํฌ๋กค์ด๋‹ค.

SingleChildScrollView(
  scrollDirection: Axis.horizontal, // ๊ฐ€๋กœ ์Šคํฌ๋กค ์„ค์ •
  child: Row(
    children: [
      // ...
    ],
  ),
)

 

ListView ๊ฐ™์€ ๋‹ค๋ฅธ ์Šคํฌ๋กค ๊ฐ€๋Šฅํ•œ ์œ„์ ฏ๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ์ถฉ๋Œ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ฃผ์˜ํ•˜์ž. SingleChildScrollView๋Š” ์ฃผ๋กœ ์ฝ˜ํ…์ธ  ์–‘์ด ์ ์„ ๋•Œ ์‚ฌ์šฉํ•˜๊ธฐ ์ข‹๋‹ค. ๋ฐ์ดํ„ฐ์–‘์ด ๋งŽ๋‹ค๋ฉด ์ง€์—ฐ ๋กœ๋”ฉ์„ ์ง€์›ํ•˜๋Š” ListView๋ฅผ ์‚ฌ์šฉํ•˜๋Š”๊ฒŒ ๋” ์ ์ ˆํ•  ์ˆ˜ ์žˆ๋‹ค.

 

 

URL Launcher


URL Launcher๋Š” ์•ฑ์—์„œ ์™ธ๋ถ€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ํŒจํ‚ค์ง€๋‹ค. ์›น ํŽ˜์ด์ง€๋ฅผ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด๊ฑฐ๋‚˜, ์ „ํ™” ๊ฑธ๊ธฐ, ์ด๋ฉ”์ผ/๋ฉ”์‹œ์ง€ ์ „์†ก ๋“ฑ์˜ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค. ์˜ˆ๋ฅผ๋“ค์–ด ์‚ฌ์šฉ์ž๊ฐ€ ์•ฑ ๋‚ด์—์„œ ๋งํฌ๋ฅผ ํด๋ฆญํ•˜๋ฉด ์‚ฌํŒŒ๋ฆฌ ๊ฐ™์€ ์™ธ๋ถ€ ์•ฑ์„ ํ†ตํ•ด ํ•ด๋‹น ํŽ˜์ด์ง€๋ฅผ ์—ด๋„๋ก ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

๊ตฌ๋ถ„ ์Šคํ‚ค๋งˆ
์›น ํŽ˜์ด์ง€ ์—ด๊ธฐ https:<URL>
์ด๋ฉ”์ผ ๋ณด๋‚ด๊ธฐ mailto:<email address>?subject=<subject>&body=<body>
์ „ํ™” ๊ฑธ๊ธฐ tel:<phone number>
๋ฌธ์ž ๋ณด๋‚ด๊ธฐ sms:<phone number>
ํŒŒ์ผ ์—ด๊ธฐ file:<path>

 

URL ์Šคํ‚ค๋งˆ๋Š” ํ•ด๋‹น ์Šคํ‚ค๋งˆ๋ฅผ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ์•ฑ์ด ๊ธฐ๊ธฐ์— ์„ค์น˜๋˜์–ด ์žˆ์„ ๋•Œ๋งŒ ์ž‘๋™ํ•œ๋‹ค. ์˜ˆ๋ฅผ๋“ค์–ด iOS ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋ฉ”์ผ, ์ „ํ™” ์•ฑ์ด ์„ค์น˜๋˜์–ด ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— tel:, mailto: ์Šคํ‚ค๋งˆ๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์—†๋‹ค.

 

URL ์Šคํ‚ค๋งˆ๋ฅผ ์‹คํ–‰ํ•˜๋ ค๋ฉด ํ—ˆ์šฉํ•  ์Šคํ‚ค๋งˆ ๋ชฉ๋ก์„ ๋ฏธ๋ฆฌ ๋ช…์‹œํ•ด์•ผ ํ•œ๋‹ค. iOS๋Š” Info.plist ํŒŒ์ผ์„ ์ˆ˜์ •ํ•ด์•ผํ•˜๊ณ , ์•ˆ๋“œ๋กœ์ด๋“œ๋Š” AndroidManifest.xml ํŒŒ์ผ์„ ์ˆ˜์ •ํ•ด์•ผ ํ•œ๋‹ค. ์•„๋ž˜๋Š” URL์„ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด๊ธฐ ์œ„ํ•ด https ์Šคํ‚ค๋งˆ๋ฅผ ํ—ˆ์šฉํ•˜๋„๋ก ์„ค์ •ํ•œ ์˜ˆ์‹œ.

 

ios/Runner/Info.plist โ–ผ

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <!-- ์•ฑ์—์„œ ํŠน์ • URL ์Šคํ‚ด์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ—ˆ์šฉ -->
  <key>LSApplicationQueriesSchemes</key>
  <array>
    <string>https</string>
  </array>
</dict>
</plist>

 

๐Ÿ’ก Info.plist, AndroidManifest.xml ์ฒ˜๋Ÿผ ๋„ค์ดํ‹ฐ๋ธŒ ๊ตฌ์„ฑ ์š”์†Œ๋ฅผ ์ •์˜ํ•˜๋Š” ํŒŒ์ผ์€ Hot Reload, Hot Restart๋งŒ์œผ๋กœ๋Š” ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ๋ฐ˜์˜๋˜์ง€ ์•Š๊ณ , ์•ฑ์„ ์™„์ „ํžˆ ์ข…๋ฃŒํ•œ ํ›„ ๋‹ค์‹œ ๋นŒ๋“œ ๋ฐ ์‹คํ–‰ ํ•ด์•ผ ํ•œ๋‹ค.

  • Hot Reload : ์ƒํƒœ๋Š” ์œ ์ง€ํ•˜๋ฉด์„œ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‚ฌํ•ญ ๋ฐ˜์˜. main(), initState() ๋‹ค์‹œ ์‹คํ–‰ ์•ˆํ•จ.
  • Hot Restart : ๋ชจ๋“  ์ƒํƒœ๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜์—ฌ ์•ฑ ์žฌ์‹œ์ž‘. main() ๋‹ค์‹œ ์‹คํ–‰.

 

URL Launcher ํŒจํ‚ค์ง€๋Š” ํŠน์ • URL์„ ์—ด ๋•Œ ์‚ฌ์šฉํ•˜๋Š” launchUrl(Uri), ๋ฌธ์ž์—ด URL์„ ์ง์ ‘ ์ „๋‹ฌํ•˜์—ฌ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” launchUrlString(String), ํŠน์ • URL์˜ ์‹คํ–‰ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” canLaunchUrl(Uri) ํ•จ์ˆ˜๋ฅผ ์ œ๊ณตํ•œ๋‹ค.

import 'package:url_launcher/url_launcher.dart';

class Episode extends StatelessWidget {
  // ...

  static const String baseUrl = 'https://comic.naver.com/webtoon/detail';

  // ...

  onEpisodeTab() async {
    final uri = Uri.parse(baseUrl).replace(queryParameters: {
      'titleId': webtoonId,
      'no': episode.id,
    });
    // Uri.https ์ƒ์„ฑ์ž๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜๋„ ์žˆ์Œ
    // final uri = Uri.https('comic.naver.com', 'webtoon/detail', {
    //   'titleId': webtoonId,
    //   'no': episode.id,
    // });

    // launchUrl์€ ๋น„๋™๊ธฐ ํ•จ์ˆ˜๋กœ Future๋ฅผ ๋ฐ˜ํ™˜ํ•จ.
    // ์—ํ”ผ์†Œ๋“œ๋ฅผ ํƒญํ–ˆ์„ ๋•Œ, ์ง€์ •๋œ ์ฃผ์†Œ๋กœ ์ด๋™.
    // ์˜ˆ: https://comic.naver.com/webtoon/detail?titleId=...&no=...
    await launchUrl(uri);
  }

  // ...
}

 

๋ฌธ์ž์—ด ๋ณด๊ฐ„๋ฒ•(${...} ์œผ๋กœ ๋ฌธ์ž์—ด์— ๋ณ€์ˆ˜ ์‚ฝ์ž…)์œผ๋กœ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ง์ ‘ ์ž…๋ ฅํ•˜๋ฉด ์˜คํƒ€๋ฅผ ๋‚ด๊ธฐ ์‰ฌ์šฐ๋ฏ€๋กœ ์œ„์ฒ˜๋Ÿผ replace() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ Uri() ์ƒ์„ฑ์ž๋ฅผ ์ด์šฉํ•˜๋Š” ๋ฐฉ์‹์ด ๋” ๊ตฌ์กฐ์ ์ด๊ณ  ์•ˆ์ „ํ•˜๋‹ค.

 

์—ํ”ผ์†Œ๋“œ๋ฅผ ํ„ฐ์น˜ํ•˜๋ฉด launchUrl ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ์ธ์•ฑ ๋ธŒ๋ผ์šฐ์ €์—์„œ ๋งํฌ๊ฐ€ ์—ด๋ฆฌ๋Š” ๋ชจ์Šต(GIF)

 

SharedPreferences


SharedPreferences๋Š” ํ‚ค-๊ฐ’ ์Œ์œผ๋กœ ์ด๋ฃจ์–ด์ง„ ๋ฐ์ดํ„ฐ๋ฅผ ์˜๊ตฌ์ ์œผ๋กœ ์ €์žฅํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ์ด๋‹ค(ํ”Œ๋Ÿฌํ„ฐ ํŒ€์—์„œ ๊ฐœ๋ฐœ). ์ด ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์‚ฌ์šฉํ•˜๋ฉด ์‚ฌ์šฉ์ž ์žฅ์น˜์˜ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์— ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์•ฑ์„ ์ข…๋ฃŒํ•˜๊ณ  ๋‹ค์‹œ ์‹คํ–‰ํ•ด๋„ ๋ฐ์ดํ„ฐ๋Š” ์œ ์ง€๋œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์˜ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์™€ ๋น„์Šทํ•˜๋‹ค๊ณ  ๋ณด๋ฉด ๋œ๋‹ค.

 

๋ฐ์ดํ„ฐ๋Š” ๋””์Šคํฌ์— ๋น„๋™๊ธฐ์ ์œผ๋กœ ์ €์žฅ๋˜๋ฉฐ, ์“ฐ๊ธฐ ์ž‘์—…์„ ํ˜ธ์ถœํ•œ ํ›„ ๋ฐ์ดํ„ฐ๊ฐ€ ์ฆ‰์‹œ ์ €์žฅ๋œ๋‹ค๊ณ  ๋ณด์žฅํ•  ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์—(๋ฐ์ดํ„ฐ์˜ ์ผ๊ด€์„ฑ ๋ณด์žฅ ์•ˆํ•จ) ์ค‘์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๋Š” ๊ฒƒ์€ ๊ถŒ์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค. ์ฃผ๋กœ ์•ฑ ์„ค์ •, ์‚ฌ์šฉ์ž ์„ ํ˜ธ๋„, ๊ฐ„๋‹จํ•œ ์ƒํƒœ ์ •๋ณด ๊ฐ™์€ ์ž‘์€ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•  ๋•Œ ์ ํ•ฉํ•˜๋‹ค.

 

SharedPreferences๋Š” int, bool, double, String, List<String> ์ด 5๊ฐ€์ง€ ์ž๋ฃŒํ˜•์œผ๋กœ ์ €์žฅํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ž๋ฃŒํ˜•์— ๋งž๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค.

 

์“ฐ๊ธฐ ๋ฉ”์„œ๋“œ โ–ผ

// SharedPreferences ์ธ์Šคํ„ด์Šค๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ํ›„ prefs ๋ณ€์ˆ˜์— ํ• ๋‹น
final SharedPreferences prefs = await SharedPreferences.getInstance();

// 'counter' ํ‚ค์— ์ •์ˆ˜ ๊ฐ’ ์ €์žฅ
await prefs.setInt('counter', 10);
// 'repeat' ํ‚ค์— ๋ถˆ๋ฆฌ์–ธ ๊ฐ’ ์ €์žฅ
await prefs.setBool('repeat', true);
// 'decimal' ํ‚ค์— ์‹ค์ˆ˜ ๊ฐ’ ์ €์žฅ
await prefs.setDouble('decimal', 1.5);
// 'action' ํ‚ค์— ๋ฌธ์ž์—ด ๊ฐ’ ์ €์žฅ
await prefs.setString('action', 'Start');
// 'items' ํ‚ค์— ๋ฌธ์ž์—ด ๋ฆฌ์ŠคํŠธ ์ €์žฅ
await prefs.setStringList('items', <String>['Earth', 'Moon', 'Sun']);

 

์ฝ๊ธฐ ๋ฉ”์„œ๋“œ โ–ผ

// 'counter' ํ‚ค ๋ฐ์ดํ„ฐ ์กฐํšŒ. ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด null ๋ฐ˜ํ™˜
final int? counter = prefs.getInt('counter');
// 'repeat' ํ‚ค ๋ฐ์ดํ„ฐ ์กฐํšŒ. ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด null ๋ฐ˜ํ™˜
final bool? repeat = prefs.getBool('repeat');
// 'decimal' ํ‚ค ๋ฐ์ดํ„ฐ ์กฐํšŒ. ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด null ๋ฐ˜ํ™˜
final double? decimal = prefs.getDouble('decimal');
// 'action' ํ‚ค ๋ฐ์ดํ„ฐ ์กฐํšŒ. ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด null ๋ฐ˜ํ™˜
final String? action = prefs.getString('action');
// 'items' ํ‚ค ๋ฐ์ดํ„ฐ ์กฐํšŒ. ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด null ๋ฐ˜ํ™˜
final List<String>? items = prefs.getStringList('items');

 

์ œ๊ฑฐ ๋ฉ”์„œ๋“œ โ–ผ

// 'counter' ํ‚ค์˜ ๋ฐ์ดํ„ฐ ์‚ญ์ œ
await prefs.remove('counter');

 

์•„๋ž˜๋Š” ์›นํˆฐ์˜ ์ข‹์•„์š” ์ƒํƒœ๋ฅผ ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅํ•˜๊ณ  ๋ถˆ๋Ÿฌ์˜ค๋Š” ์˜ˆ์‹œ. ์œ„์ ฏ์ด ์ดˆ๊ธฐํ™”๋˜๋ฉด initPrefs ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ SharedPreferences ์ธ์Šคํ„ด์Šค๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ณ , ์ €์žฅ๋œ ์ข‹์•„์š” ๋ชฉ๋ก์„ ๊ธฐ๋ฐ˜์œผ๋กœ isLiked์˜ ์ดˆ๊ธฐ ์ƒํƒœ๋ฅผ ์„ค์ •ํ•œ๋‹ค.

class _DetailScreenState extends State<DetailScreen> {
  // SharedPreferences ์ธ์Šคํ„ด์Šค๋ฅผ ์ €์žฅํ•  ๋ฉค๋ฒ„ ๋ณ€์ˆ˜
  late SharedPreferences prefs;

  // ์ข‹์•„์š”ํ•œ ์›นํˆฐ ID ๋ชฉ๋ก์„ ์ €์žฅํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋Š” SharedPreferences ํ‚ค
  static const likedToonsPrefsKey = 'likedToons';

  // ํ˜„์žฌ ์›นํˆฐ์˜ ์ข‹์•„์š” ์—ฌ๋ถ€๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ์ƒํƒœ ๋ณ€์ˆ˜
  bool isLiked = false;

  // SharedPreferences ์ดˆ๊ธฐํ™” ๋ฐ isLiked ์ดˆ๊ธฐ ์ƒํƒœ ์„ค์ •
  Future<void> initPrefs() async {
    // SharedPreferences ์ธ์Šคํ„ด์Šค๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ํ›„ prefs ๋ณ€์ˆ˜์— ํ• ๋‹น
    prefs = await SharedPreferences.getInstance();

    // ์ข‹์•„์š”ํ•œ ์›นํˆฐ ID ๋ชฉ๋ก ๋ถˆ๋Ÿฌ์˜ค๊ธฐ
    final likedToons = prefs.getStringList(likedToonsPrefsKey);

    // ์•ฑ์„ ์ฒ˜์Œ ์‹คํ–‰ํ•ด์„œ ์ข‹์•„์š” ๋ชฉ๋ก์ด ์—†๋Š” ๊ฒฝ์šฐ ๋นˆ ๋ฆฌ์ŠคํŠธ๋กœ ์ดˆ๊ธฐํ™”
    if (likedToons == null) {
      prefs.setStringList(likedToonsPrefsKey, []);
    } else {
      // ์ข‹์•„์š” ๋ชฉ๋ก์— ํ˜„์žฌ ์›นํˆฐ ID๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธ ํ›„ isLiked ์ƒํƒœ ์„ค์ •
      setState(() {
        isLiked = likedToons.contains(widget.id);
      });
    }
  }

  @override
  void initState() {
    super.initState();
    initPrefs();
  }

  // ์ข‹์•„์š” ์ƒํƒœ ๋ณ€๊ฒฝ ๋ฉ”์„œ๋“œ (AppBar ์šฐ์ธก ์ข‹์•„์š” ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ํ˜ธ์ถœ)
  void onHeartTab() async {
    final likedToons = prefs.getStringList(likedToonsPrefsKey);
    if (likedToons == null) return;

    // ํ˜„์žฌ isLiked ์ƒํƒœ์— ๋”ฐ๋ผ ์›นํˆฐ ID๋ฅผ ๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ œ๊ฑฐ
    if (isLiked) {
      likedToons.remove(widget.id);
    } else {
      likedToons.add(widget.id);
    }

    // ์—…๋ฐ์ดํŠธํ•œ ์ข‹์•„์š” ๋ชฉ๋ก์„ SharedPreferences์— ์ €์žฅ
    prefs.setStringList(likedToonsPrefsKey, likedToons);

    // isLiked ์ƒํƒœ ํ† ๊ธ€ ๋ฐ UI ์—…๋ฐ์ดํŠธ
    setState(() {
      isLiked = !isLiked;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        // AppBar ์šฐ์ธก์— ํ‘œ์‹œ๋  ์•ก์…˜ ์œ„์ ฏ ๋ชฉ๋ก (์•„์ด์ฝ˜ ๋ฒ„ํŠผ ๋“ฑ)
        actions: [
          IconButton(
            onPressed: onHeartTab,
            icon: Icon(isLiked ? Icons.favorite : Icons.favorite_outline),
          ),
        ],
        // ...
      ),
      body: SingleChildScrollView(
        // ...
      ),
    );
  }
}

 

๊ทธ ํ›„ AppBar์˜ ์ข‹์•„์š” ๋ฒ„ํŠผ์„ ํด๋ฆญ ํ•  ๋•Œ๋งˆ๋‹ค onHeartTab ๋ฉ”์„œ๋“œ๊ฐ€ ํ˜ธ์ถœ๋ผ์„œ โ‘ ํ˜„์žฌ isLiked ์ƒํƒœ์— ๋”ฐ๋ผ ์ข‹์•„์š” ๋ชฉ๋ก์„ ์—…๋ฐ์ดํŠธํ•˜๊ณ , โ‘ก์—…๋ฐ์ดํŠธํ•œ ์ข‹์•„์š” ๋ชฉ๋ก์„ SharedPreferences์— ์ €์žฅํ•œ ํ›„, โ‘ขisLiked ์ƒํƒœ ํ† ๊ธ€ ๋ฐ setState๋ฅผ ํ˜ธ์ถœํ•ด์„œ UI๋ฅผ ์—…๋ฐ์ดํŠธํ•œ๋‹ค.

 

AppBar ์šฐ์ธก ์ข‹์•„์š” ๋ฒ„ํŠผ. isLiked ์ƒํƒœ๊ฐ€ true์ด๋ฉด filled ์•„์ด์ฝ˜์„, false์ด๋ฉด outlined ์•„์ด์ฝ˜์„ ํ‘œ์‹œํ•œ๋‹ค.

 


๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”
 
๋ฐ˜์‘ํ˜•