Flutter Basic Chapter 5 - 웹툰 API 연동과 상세 화면

AppBar와 기본 화면 정비

🎯 이번 단계에서 배울 것

  • MaterialApprunAppconst를 적용해 불필요한 리빌드 최소화
  • 홈 화면을 HomeScreen StatelessWidget으로 구성하고 한국어 AppBar 추가
  • AppBar의 backgroundColor, foregroundColor, elevation 속성으로 스타일 제어

📂 파일 구조

1
2
3
4
수정되는 파일
- lib/main.dart
- lib/screens/home_screen.dart
- test/widget_test.dart (MyApp → App 이름 변경)

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// lib/main.dart
void main() {
runApp(const App());
}

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

@override
Widget build(BuildContext context) {
return const MaterialApp(
home: HomeScreen(),
);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// lib/screens/home_screen.dart
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
elevation: 2,
backgroundColor: Colors.white,
foregroundColor: Colors.green,
title: const Text(
'어늘의 웹툰',
style: TextStyle(fontSize: 24),
),
),
);

🔍 상세 설명

  • const MaterialApp 사용으로 위젯 트리를 고정화하고 Hot Reload 성능을 개선했습니다.
  • AppBar의 그림자 높이를 elevation: 2로 줄여 얇은 실선 느낌을 주고, 글자색은 foregroundColor로 초록색으로 통일했습니다.
  • 테스트 파일에서 MyApp 대신 App을 pump하도록 수정해 기본 테스트가 통과합니다.

🎨 화면 미리보기

1
2
3
┌────────────────────────────┐
│ 어늘의 웹툰 │ ← 흰색 배경 + 초록 텍스트 AppBar
└────────────────────────────┘

✅ 체크리스트

  • runApp(const App())const가 적용됐는가?
  • AppBar 배경이 흰색, 텍스트는 초록색으로 표시되는가?
  • 기본 테스트(test/widget_test.dart)가 App 기준으로 동작하는가?

HTTP 요청과 모델 정의 (Data Fetching & fromJson)

🎯 이번 단계에서 배울 것

  • http 패키지를 의존성으로 추가하고 REST API 호출하기
  • 비동기 함수에 Future<List<WebtoonModel>> 타입 선언하기
  • jsonDecode 결과를 WebtoonModel.fromJson으로 변환해 강타입 컬렉션 만들기

📂 파일 구조

1
2
3
4
5
수정되는/생성되는 파일
- pubspec.yaml (http 패키지 추가)
- lib/services/api_service.dart (신규)
- lib/models/webtoon_model.dart (신규)
- lib/main.dart (앱 시작 시 API 호출 시험)

📝 핵심 코드

1
2
// pubspec.yaml (dependencies)
http: ^0.13.5
1
2
3
4
5
6
7
8
9
// lib/models/webtoon_model.dart
class WebtoonModel {
final String title, thumb, id;

WebtoonModel.fromJson(Map<String, dynamic> json)
: title = json['title'],
thumb = json['thumb'],
id = json['id'];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// lib/services/api_service.dart
class ApiService {
static const String baseUrl =
'https://webtoon-crawler.nomadcoders.workers.dev';
static const String today = 'today';

static Future<List<WebtoonModel>> getTodaysToons() async {
List<WebtoonModel> 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();
}
}
1
2
3
4
5
// lib/main.dart (임시 호출)
void main() {
ApiService().getTodaysToons(); // 이후 5.4에서 static 호출로 변경
runApp(const App());
}

🔍 상세 설명

  • .fromJson 생성자를 통해 맵 구조 데이터를 안전하게 모델로 변환했습니다.
  • 5.3 Recap 커밋에서 final instance = ... 변수로 나누어 가독성을 높였습니다.
  • ApiService를 static 메서드로 재구성(5.4)해 인스턴스를 생성하지 않고도 재사용할 수 있도록 했습니다.

✅ 체크리스트

  • http 패키지가 의존성으로 추가되고 flutter pub get이 실행되었는가?
  • getTodaysToons()Future<List<WebtoonModel>>를 반환하는가?
  • HTTP 200 이외에는 throw Error()로 예외를 던지는가?

초기 데이터 로딩과 상태 관리 (waitForWebToons)

🎯 이번 단계에서 배울 것

  • StatefulWidget에서 initState()를 활용해 비동기 초기화 실행하기
  • 로딩 상태(isLoading)와 결과 리스트(webtoons)를 상태로 저장하기
  • API 호출을 기다린 후 setState()로 UI 갱신하기

📂 파일 구조

1
2
수정되는 파일
- lib/screens/home_screen.dart (StatefulWidget으로 전환)

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class _HomeScreenState extends State<HomeScreen> {
List<WebtoonModel> webtoons = [];
bool isLoading = true;

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

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

@override
Widget build(BuildContext context) {
print(webtoons);
print(isLoading);
...
}
}

🔍 상세 설명

  • API 응답을 기다린 뒤 setState()를 호출해 화면을 다시 그리고, 개발 중에는 print로 값 확인을 했습니다.
  • 이후 5.5에서 FutureBuilder로 리팩터링하므로 이 임시 상태 로직은 제거됩니다.

✅ 체크리스트

  • waitForWebToons()initState에서 한 번만 호출되는가?
  • API 응답이 완료되면 isLoading이 false로 바뀌는가?

FutureBuilder로 상태 단순화

🎯 이번 단계에서 배울 것

  • FutureBuilder를 사용해 비동기 처리 결과를 위젯 트리 안에서 직접 다루기
  • snapshot.hasData를 기준으로 로딩/성공 상태 분기하기
  • Scaffoldbody에 FutureBuilder를 배치해 전체 화면 컨텐츠 제어하기

📂 파일 구조

1
2
3
수정되는 파일
- lib/screens/home_screen.dart (StatelessWidget + FutureBuilder)
- lib/main.dart (불필요한 ApiService 인스턴스 제거)

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});

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

@override
Widget build(BuildContext context) {
return Scaffold(
...
body: FutureBuilder(
future: webtoons,
builder: (context, snapshot) {
if (snapshot.hasData) {
return const Text('There is data!'); // 이후 단계에서 교체
}
return const Text('Loading....');
},
),
);
}
}

🔍 상세 설명

  • FutureBuilder 덕분에 StatefulWidget이 다시 StatelessWidget으로 간결해졌습니다.
  • 이후 섹션에서 snapshot.data!를 활용해 리스트를 렌더링합니다.

✅ 체크리스트

  • FutureBuilder가 null safety에 맞게 snapshot.hasData 체크 후 데이터를 사용하는가?
  • 로딩 중에는 텍스트 또는 스피너가 표시되는가?

가로 스크롤 리스트뷰 구성

🎯 이번 단계에서 배울 것

  • ListView.separated를 이용해 카드 간 간격을 쉽게 조절하기
  • scrollDirection: Axis.horizontal로 가로 캐러셀 형태 만들기
  • 로딩 상태일 때 CircularProgressIndicator를 중앙에 배치하기

📂 파일 구조

1
2
수정되는 파일
- lib/screens/home_screen.dart

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
return FutureBuilder(
future: webtoons,
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
var webtoon = snapshot.data![index];
return Text(webtoon.title);
},
separatorBuilder: (context, index) => const SizedBox(width: 20),
);
}
return const Center(child: CircularProgressIndicator());
},
);

🔍 상세 설명

  • separatorBuilder로 일정한 공백(SizedBox(width: 20))을 자동 삽입했습니다.
  • CircularProgressIndicator를 사용해 데이터 로딩 중임을 명확히 전달합니다.

✅ 체크리스트

  • ListView.separated가 가로 방향으로 동작하는가?
  • 로딩 시 스피너가 중앙에 표시되는가?

Webtoon 카드 디자인

🎯 이번 단계에서 배울 것

  • 썸네일 이미지에 그림자(BoxShadow)와 둥근 모서리(borderRadius) 적용
  • ColumnExpanded로 리스트 상단 여백과 공간 비율 조절
  • 별도 메서드 makeList로 빌더 코드를 분리해 가독성 향상

📂 파일 구조

1
2
수정되는 파일
- lib/screens/home_screen.dart (카드 UI 추가)

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ListView makeList(AsyncSnapshot<List<WebtoonModel>> snapshot) {
return ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
itemBuilder: (context, index) {
var webtoon = snapshot.data![index];
return 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(webtoon.thumb),
),
const SizedBox(height: 10),
Text(webtoon.title, style: const TextStyle(fontSize: 22)),
],
);
},
separatorBuilder: (context, index) => const SizedBox(width: 40),
);
}

✅ 체크리스트

  • 썸네일이 둥근 모서리와 그림자를 적용한 컨테이너 안에 들어가는가?
  • 타이틀 텍스트가 이미지 아래에 표시되는가?

상세 화면과 분리된 위젯 구조

🎯 이번 단계에서 배울 것

  • Navigator.pushMaterialPageRoute로 상세 화면으로 이동하기
  • 썸네일/타이틀 UI를 Webtoon 위젯으로 분리해 재사용성 확보
  • 상세 화면(DetailScreen)에서 동일한 카드 레이아웃 재활용

📂 파일 구조

1
2
3
4
5
6
새로 생성되는 파일
- lib/screens/detail_screen.dart
- lib/widgets/webtoon_widget.dart

수정되는 파일
- lib/screens/home_screen.dart (Webtoon 위젯 사용)

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// lib/widgets/webtoon_widget.dart
class Webtoon extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(
title: title,
thumb: thumb,
id: id,
),
fullscreenDialog: true,
),
);
},
child: Column(
children: [...],
),
);
}
}
1
2
3
4
5
// lib/screens/detail_screen.dart (초기 버전)
class DetailScreen extends StatelessWidget {
final String title, thumb, id;
...
}

🔍 상세 설명

  • fullscreenDialog: true로 iOS 스타일의 모달 전환 효과를 적용했습니다.
  • DetailScreen은 아직 정적 정보만 보여주며, 이후 섹션에서 API 연동으로 확장합니다.

✅ 체크리스트

  • 썸네일을 탭하면 DetailScreen으로 이동하는가?
  • 홈 화면과 상세 화면 모두 동일한 카드 스타일을 유지하는가?

Hero 애니메이션 연결

🎯 이번 단계에서 배울 것

  • Hero 위젯을 이용해 썸네일 전환 애니메이션 구현하기
  • tag로 고유 ID(webtoon.id)를 사용해 출발/도착 위젯을 연결하기

📂 파일 구조

1
2
3
수정되는 파일
- lib/widgets/webtoon_widget.dart
- lib/screens/detail_screen.dart

📝 핵심 코드

1
2
3
4
5
6
7
8
9
Hero(
tag: id,
child: Container(
width: 250,
clipBehavior: Clip.hardEdge,
decoration: ...,
child: Image.network(thumb),
),
)

🔍 상세 설명

  • 동일한 tag 값을 가진 Hero 위젯이 화면 전환 시 자연스러운 확대/축소 이동을 제공합니다.
  • Hero는 반드시 전환 대상 두 화면에 모두 존재해야 하므로 홈/상세 화면 모두 수정을 진행했습니다.

✅ 체크리스트

  • 카드 썸네일이 상세 화면으로 전환될 때 부드러운 애니메이션이 재생되는가?

상세 정보 API 확장

🎯 이번 단계에서 배울 것

  • 세부 정보용 모델(WebtoonDetailModel, WebtoonEpisodeModel)을 추가로 정의하기
  • ApiService에 웹툰 상세 정보/에피소드 목록 API 메서드 구현하기

📂 파일 구조

1
2
3
4
5
6
새로 생성되는 파일
- lib/models/webtoon_detail_model.dart
- lib/models/webtoon_episode_model.dart

수정되는 파일
- lib/services/api_service.dart

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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');
...
}

✅ 체크리스트

  • 상세 API가 JSON을 모델 객체로 변환해 반환하는가?
  • 에피소드 리스트가 List<WebtoonEpisodeModel> 형태로 준비되는가?

DetailScreen에서 Future 준비

🎯 이번 단계에서 배울 것

  • DetailScreen을 StatefulWidget으로 전환하고 initState()에서 Future 초기화
  • widget.id를 사용해 라우트 인자로 받은 값을 접근하기

📂 파일 구조

1
2
수정되는 파일
- lib/screens/detail_screen.dart (StatefulWidget화)

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class DetailScreen extends StatefulWidget {
...
}

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);
}
}

✅ 체크리스트

  • webtoonepisodes Futures가 late로 선언되고 initState에서 초기화되는가?

상세 정보 UI 표시

🎯 이번 단계에서 배울 것

  • FutureBuilder로 상세 정보를 받아와 소개글/장르/연령을 표시하기
  • PaddingcrossAxisAlignment로 텍스트 정렬 다듬기

📂 파일 구조

1
2
수정되는 파일
- lib/screens/detail_screen.dart

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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('...');
},
)

✅ 체크리스트

  • 상세 소개글과 장르/연령 정보가 로딩 완료 후 표시되는가?
  • 로딩 중에는 placeholder(...)가 나타나는가?

에피소드 목록과 스크롤 레이아웃

🎯 이번 단계에서 배울 것

  • SingleChildScrollViewPadding으로 상세 화면 전체를 스크롤 가능하게 만들기
  • FutureBuilder + for 루프 조합으로 에피소드 목록을 위젯 리스트로 변환하기
  • 카드 형태의 에피소드 요소에 그림자와 간격 적용하기

📂 파일 구조

1
2
수정되는 파일
- lib/screens/detail_screen.dart

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
FutureBuilder(
future: episodes,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Column(
children: [
for (var episode in snapshot.data!)
Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Colors.green.shade400,
boxShadow: [...],
),
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();
},
)

✅ 체크리스트

  • 상세 화면이 세로로 스크롤되며 이미지/설명/에피소드가 이어지는가?
  • 각각의 에피소드 카드가 일정한 간격과 그림자를 갖는가?

URL Launcher로 네이버 웹툰 열기

🎯 이번 단계에서 배울 것

  • url_launcher 패키지 추가 및 플랫폼 설정(iOS/Android/macOS 등) 적용
  • 에피소드 전용 위젯(Episode) 분리 및 탭 제스처 처리
  • launchUrlString으로 외부 브라우저를 통해 실제 웹툰 페이지 열기

📂 파일 구조

1
2
3
4
5
6
7
새로 생성되는 파일
- lib/widgets/episode_widget.dart

수정되는 파일
- pubspec.yaml (url_launcher 의존성)
- lib/screens/detail_screen.dart (Episode 위젯 사용)
- iOS/macOS/Linux/Windows 설정 파일들 (플러그인 등록)

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// lib/widgets/episode_widget.dart
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}',
);
}

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onButtonTap,
child: Container(...),
);
}
}
1
2
3
4
5
6
// lib/screens/detail_screen.dart (에피소드 출력 부분)
for (var episode in snapshot.data!)
Episode(
episode: episode,
webtoonId: widget.id,
)

🔍 플랫폼 설정 요약

  • iOS/ macOS Podfile, xcconfig 파일에 use_frameworks! 등 url_launcher 구성이 추가되었습니다.
  • Flutter가 자동 생성하는 GeneratedPluginRegistrant 파일들도 url_launcher 등록 코드가 포함되도록 업데이트되었습니다.

✅ 체크리스트

  • 에피소드를 탭하면 기본 브라우저에서 네이버 웹툰 상세 페이지가 열리는가?
  • url_launcher 의존성이 pubspec에 추가되어 있는가?

즐겨찾기(좋아요) 상태 저장

🎯 이번 단계에서 배울 것

  • shared_preferences 패키지로 간단한 로컬 스토리지 구현
  • initPrefs()에서 초기화 후 likedToons 리스트를 읽어 상태 반영하기
  • AppBar 아이콘을 Icons.favoriteIcons.favorite_outline으로 토글하기

📂 파일 구조

1
2
3
4
수정되는 파일
- pubspec.yaml (shared_preferences 의존성)
- lib/screens/detail_screen.dart (즐겨찾기 로직 추가)
- pubspec.lock, 플랫폼 플러그인 등록 파일들

📝 핵심 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class _DetailScreenState extends State<DetailScreen> {
late SharedPreferences prefs;
bool isLiked = false;

Future initPrefs() async {
prefs = await SharedPreferences.getInstance();
final likedToons = prefs.getStringList('likedToons');
if (likedToons != null) {
if (likedToons.contains(widget.id) == true) {
setState(() {
isLiked = true;
});
}
} else {
await prefs.setStringList('likedToons', []);
}
}

@override
void initState() {
super.initState();
webtoon = ApiService.getToonById(widget.id);
episodes = ApiService.getLatestEpisodesById(widget.id);
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;
});
}
}
1
2
3
4
5
6
7
8
9
appBar: AppBar(
...
actions: [
IconButton(
onPressed: onHeartTap,
icon: Icon(isLiked ? Icons.favorite : Icons.favorite_outline),
),
],
)

🔍 상세 설명

  • 앱 최초 실행 시 likedToons 키가 없으면 빈 리스트를 만들어 저장합니다.
  • 즐겨찾기 토글 시 setStringList로 업데이트하고 isLiked를 반전합니다.
  • Heart 아이콘이 즉시 변경되어 UX가 즉각적입니다.

✅ 체크리스트

  • 앱을 재실행해도 즐겨찾기한 웹툰은 하트가 채워진 상태로 표시되는가?
  • shared_preferences 의존성이 pubspec에 추가되었는가?

📚 전체 흐름 정리

  • AppBar가 있는 기본 UI를 구성했고, HTTP 요청과 JSON → 모델 변환을 준비했습니다.
  • FutureBuilder와 ListView로 오늘의 웹툰 목록을 비동기/가로 스크롤 형태로 렌더링했습니다.
  • 카드 UI를 개선하고 상세 화면 이동, Hero 애니메이션을 추가했습니다.
  • 상세 정보와 최신 에피소드 목록을 API로 받아와 표시했습니다.
  • url_launcher로 네이버 웹툰 페이지를 직접 열 수 있게 했고, shared_preferences로 즐겨찾기 상태를 저장했습니다.

✅ 최종 점검 리스트

  • http, url_launcher, shared_preferences 패키지가 pubspec에 포함돼 있는가?
  • 홈 화면이 가로 캐러셀로 오늘의 웹툰을 보여주고, 탭하면 상세 화면으로 이동하는가?
  • 상세 화면에서 소개/장르/연령/최근 에피소드가 제대로 표시되는가?
  • 에피소드를 탭하면 외부 브라우저에서 열리는가?
  • 하트 아이콘으로 즐겨찾기를 토글할 수 있고 앱을 다시 켜도 상태가 유지되는가?

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