이 글은 노마드 코더 - Flutter 로 웹툰 앱 만들기를 참고하여 작성하였습니다.
작성자 : 조미서
개발 환경 : Mac OS, Android Studio, Visual Studio Code
이번 챕터에서는
1. 데이터 불러오기
2. 인터넷에서 패키지 다운 받아 사용하기
3. 스마트폰의 API 사용하는 법
4. 폰에 데이터를 저장하는 법
등에 대해서 다룰 것이다
니콜라스가 만든 비공식 웹툰 API를 사용할 것임
/today
: 오늘의 웹툰에 대한 정보를 받을 수 있음 (요일마다 달라짐) 성인 웹툰은 포함 X -> 전체이용가
웹툰의 제목, 썸네일, ID를 받을 수 있음
/:id
: 웹툰에 대한 정보를 알 수 있음
/:id/episodes
: 제목, 평점, 썸네일 등 최근 에피소드에 대한 정보를 얻을 수 있음
/today
를 눌러
웹툰의 ID를 알면
이를 주소 끝 부분에
이렇게 붙여 넣어
이렇게 해당 웹툰에 대한 정보를 얻을 수 있다.
또한 주소 끝 부분 (id로 끝나는)에 /episode를 하게 되면
위와 같이 제목, 평점, 썸네일 등의 최근 에피소드들에 대한 정보가 나온다.
우리가 만들 앱은 웹툰을 직접적으로 보여주는 것이 아니고 사용자를 네이버 웹툰 사이트로 보내어 웹툰을 직접 읽도록 해주는 앱을 제작할 것임. (그러하여 웹사이트에서 이미 공개한 정보만 보여줌.)
처음코드
import 'package:flutter/material.dart';
void main() {
runApp(App());
}
class App extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
home: Container(),
);
}
}
에서
이러한 경고를 발견할 수 있는 데 이는 code action -> Quick Fix에서 Add 'key' to constructors
를 누르면 const App({super.key});
코드를 생성하여 위젯의 key를 stateless widget이라는 슈퍼클래스에 보낸다. (위젯은 ID같은 식별자 역할을 하는 key
를 가진다는 점이 중요 -> flutter가 위젯을 빠르게 찾을 수 있음)
home_screen.dart중 scaffold는 screen을 위한 기본적인 레이아웃과 설정 제공
main 코드
import 'package:flutter/material.dart';
import 'package:webtoon/screens/home_screen.dart';
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: HomeScreen(),
);
}
}
home_screen 코드
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
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,
),
),
),
);
}
}
최종 실행화면
api_service 코드
import 'package:http/http.dart' as http;
class ApiService {
final String baseUrl =
"https://webtoon-crawler.nomadcoders.workers.dev";
// api의 기본 url
final String today = "today";
void getTodaysToons() async {
final url = Uri.parse('$baseUrl/$today');
final response = await http.get(url);
if (response.statusCode == 200) {
print(response.body);
return;
}
throw Error();
}
}
먼저 Flutter 위젯이나 클래스가 아닌 평범한 dart 클래스(ApiService)를 만들어주고,
그 안에 baseUrl, today라는 멤버변수를 만든다.
메소드(getTodaysToons())를 만들어서 baseUrl에 today를 추가한 URL에서 데이터를 가져오고 JSON 데이터로 웹툰의 리스트를 받아올 것임
현재 이 method가 어떤 타입을 반환할지 모르기 때문에 -> Future라는 것을 배울 것임!
일단 우리 api에 요청하여 api가 반환한 JSON을 내 콘솔에 프린트 하도록 할 것이다.
URL에 요청을 보내기 위해서 먼저 http라는 패키지 설치
를 해야한다.
Flutter나 Dart 패키지를 찾고 싶으면
https://pub.dev/ - Dart, Flutter 공식 패키지 보관소
위 링크에 들어가서 찾을 수 있다. (Node.js의 npm이나 Python의 PyPI와 비슷한 개념)
그렇게 들어간 pub.dev에서 http 패키지를 찾아 설치 방법을 보면,
이렇게 세 가지의 방법을 볼 수 있다.
1. 터미널에서 dart pub add http
실행
2. 터미널에서 flutter pub add http
실행
3. pubspec.yaml
파일에 dependencies:
밑에 http: 모듈버전
입력 및 설치
여기에서 pubspec.yaml
에 대해서 더 알아보자면, 이 파일은 프로젝트에 대한 정보 및 설정을 가지고 있다. (앱이 material-design을 사용하는지 안하는지 설정할 수 있고, 이미지를 포함할 때 설정도 할 수 있으며, 프로젝트에 폰트를 설치하고 싶을 때도 이를 설정할 수 있다.
dependencies:
밑에 http: 모듈버전
입력을 하게 되면
이 버튼을 눌러 pubspec.yaml에 작성한 모든 패키지를 설치해준다.
이제 get
함수를 사용하여 http 패키지로부터 불러 올 것이다.
get(url)
그런데 여기에서
import 'package:http/http.dart' as http
라고 지정하여 http 패키지를 불러오되 그 불러오는 것을 http라고 하는 것이다.
그럼 단순히 get(url)
이라고 함수를 쓰는 대신에 http.get(url)
이라고 쓸 수 있다.
get은 uri 타입을 매개변수로 전달해줘야 하므로
final url = Uri.parse('$baseUrl/$today');
api에 요청을 보내면 그 요청을 처리하는데 오랜 시간이 걸릴 수 있다. -> 요청이 처리될 때까지 기다려야함
getTodaysToons() 함수를 부르면 Dart가 바로 코드를 처리하는 걸 원하지 않는다. Dart가 http.get(url)
부분을 제대로 완료될 때까지 기다리길 원한다.
api 요청이 처리돼서 응답을 반환할 때(데이터가 올 때)까지 기다리는 것 -> async(비동기) programming
=> 서버가 응답할때까지 프로그램을 기다리게 하는 것
서버가 응답하지 않고 넘어가버리면 요청의 결과값을 얻을 수 없으므로 기다리라고 해야함
결과값을 기다리기 위해서는 await
키워드를 이용하여 그 부분이 처리될 때까지 기다리라고 할 수 있음
주의 await
는 asynchronous function(비동기 함수)내에서만 사용될 수 있으므로 함수명()async
와 같이 꼭 작성해 주어야 한다.
get의 반환 타입은 Future
이고 안에 <Response
>라는 타입이 있다.
Future는 미래에 받을 결과 값의 타입을 알려준다.
그러하여 완료가 되었을 때 Response
라는 타입을 반환
결국 Response는 서버의 응답에 대한 정보 type이 담겨 있음
그리고 response라는 변수에 await http.get(url);
을 넣어 서버에서 요청을 처리하고 응답을 주는 것을 기다리도록 하고, 그 상태 코드가 200(200은 요청이 성공했다는 뜻)인지 체크하여 body를 콘솔 창에 출력하도록 한다.(response의 body에는 서버가 보낸 데이터가 있다)
main에서의 확인을 위하여
import 'package:flutter/material.dart';
import 'package:webtoon/screens/home_screen.dart';
import 'package:webtoon/services/api_service.dart';
void main() {
ApiService().getTodaysToons();
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: HomeScreen(),
);
}
}
위와 같이 작성을 해주고, 코드를 실행해 디버그 콘솔을 보게 되면,
위와 같이 서버 응답을 잘 출력하고 있는 것을 볼 수 있다.
이제 서버로부터 받는 JSON 형식의 데이터를 Dart와 Flutter에서 쓸 수 있는 데이터 형식인 클래스로 만들어 볼 것이다.
콘솔 창에 출력되었던 웹툰 하나 하나를 클래스로 만들어서 전체를 여러 클래스들로 이루어진 리스트로 변환해줄 것임
api_service 코드
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
final String baseUrl =
"https://webtoon-crawler.nomadcoders.workers.dev"; // api의 기본 url
final String today = "today";
void getTodaysToons() async {
final url = Uri.parse('$baseUrl/$today');
final response = await http.get(url);
if (response.statusCode == 200) {
final List<dynamic> webtoons = jsonDecode(response.body);
// body를 JSON으로 decode (본래 포맷이 string이 아니라 JSON이므로)
for (var webtoon in webtoons) {
print(webtoon);
}
return;
}
throw Error();
}
}
webtoon_model 코드
class WebtoonModel { // WebtoonModel 클래스 작성
final String title, thumb, id; // 멤버변수 정의 (api 응답에 따른)
WebtoonModel({required this.title, required this.thumb, required this.id}); // Constructor를 만들고 title, thumb, id를 필수 매개변수로 설정
}
response.body는 본래 포맷이 string이 아니라 JSON이므로, 이를 바꾸기 위해 jsonDecode
라는 함수를 사용하여 JSON으로 변환한다.
jsonDecode의 반환값 타입은 dynamic이다. (dynamic 타입은 어떤 타입이든 수용이 가능하다 그러하여 우리가 타입을 지정해줘야 함)
어떤 타입을 지정해주어야 할까? -> 응답 텍스트를 보면 현재 이 JSON은 여러 object로 이루어진 리스트라는 것을 알 수 있음 -> List
이제 잘 변경이 되었는지 확인하기 위하여 for loop를 사용해서 webtoons에 있는 webtoon을 하나씩 출력하게 하면
위와 같이 웹툰을 하나씩 출력하는 것을 볼 수 있다. 매번 flutter가 출력할 때마다 flutter: 라고 적혀있는 것을 볼 수 있다.
이제 dynamic 타입인 webtoon으로 WebtoonModel 클래스를 초기화하자
api_service 코드
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:webtoon/models/webtoon_model.dart';
class ApiService {
final String baseUrl =
"https://webtoon-crawler.nomadcoders.workers.dev"; // api의 기본 url
final String today = "today";
void getTodaysToons() async {
final url = Uri.parse('$baseUrl/$today');
final response = await http.get(url);
if (response.statusCode == 200) {
final List<dynamic> webtoons = jsonDecode(response.body);
for (var webtoon in webtoons) {
final toon = WebtoonModel.fromJson(webtoon); // toon 변수에 webtoon을 넘겨받아 WebtoonModel을 생성
print(toon.title);
}
return;
}
throw Error();
}
}
webtoon_model 코드
class WebtoonModel {
final String title, thumb, id;
WebtoonModel.fromJson(Map<String, dynamic> json)
// String과 dynamic 타입으로 이루어진 Map을 받는다
: title = json['title'],
thumb = json['thumb'],
id = json['id'];
} // named constructor를 만들고, JSON의 제목, 썸네일, 아이디 값을 토대로 초기화 해준다
이렇게 코드를 작성하고 실행하여 디버깅 콘솔을 보면
이렇게 46개의 인스턴스가 있다는 것을 확인할 수 있고,
print(toon);부분을 -> print(toon.title);로 바꾸어 실행하여 디버깅 콘솔을 보면
이제 데이터가 Dart에서 사용할 수 있는 형식으로 되어있는 것을 볼 수 있었다.
마지막으로 여러 WebtoonModel로 구성된 list를 만들어보자.
webtoon_model 코드는 그 전과 동일하고,
api_service 코드
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:webtoon/models/webtoon_model.dart';
class ApiService {
final String baseUrl =
"https://webtoon-crawler.nomadcoders.workers.dev"; // api의 기본 url
final String today = "today";
Future<List<WebtoonModel>> getTodaysToons() async { // webtoonInstances(List<WebtoonModel>타입)를 반환해 줄 것임으로 메소드 타입을 바꿔주고 async에 의해 Future도 써야한다.
List<WebtoonModel> webtoonInstances = [];
final url = Uri.parse('$baseUrl/$today');
final response = await http.get(url);
if (response.statusCode == 200) {
final List<dynamic> webtoons = jsonDecode(response.body);
for (var webtoon in webtoons) {
webtoonInstances.add(WebtoonModel.fromJson(webtoon));
// JSON으로 웹툰을 만들 때마다 webtoonInstances 리스트에 추가
}
return webtoonInstances;
}
throw Error();
}
}
복습
API에 요청을 보낼 ApiService라는 클래스 생성
pub.dev에서 http 패키지 다운 및 적용
API에 HTTP 요청 보내기
Future 타입은 당장 완료될 수 있는 작업이 아닌 작업이 마무리 될 때 까지 기다려 Response라는 타입을 반환 (Future가 마무리되기를 기다렸다가 Response를 저장)
Response가 성공했는지 확인 ( response.statusCode== 200)
response의 body(string)을 JSON 형식으로 변환하기 위해 JsonDecode
리스트에 있는 웹툰 하나 당 fromJson이라는 named constructor를 사용하여 WebtoonModel 인스턴스를 만들어줌
WebtoonModel을 webtoonInstances 리스트에 넣어준다
리스트를 반환한다
map은 object처럼 Dart가 지원하는 key-value 데이터 구조(WebtoonModel의 경우 key는 JSON의 key이고 value는 JSON의 body이다)
api_service 코드
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:webtoon/models/webtoon_model.dart';
class ApiService {
static const String baseUrl =
"https://webtoon-crawler.nomadcoders.workers.dev"; // api의 기본 url
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 List<dynamic> webtoons = jsonDecode(response.body);
for (var webtoon in webtoons) {
webtoonInstances.add(WebtoonModel.fromJson(webtoon));
}
return webtoonInstances;
}
throw Error();
}
}
home_screen 코드
import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_model.dart';
import 'package:webtoon/services/api_service.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
List<WebtoonModel> webtoons = [];
bool isLoading = true;
void waitForWebToons() async {
webtoons = await ApiService.getTodaysToons();
isLoading = false;
setState(() {});
}
void initState() {
super.initState();
waitForWebToons();
}
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,
),
),
),
);
}
}
api_service 에서 클래스의 모든 method와 property를 static으로 바꾼다 -> 현재 클래스에는 state가 없으므로
Future 데이터를 불러와서 보여주는 방법은 크게 두 가지가 있음
먼저 기초적인 방법으로 해보자
우선 HomeScreen을 StatefulWidget으로 바꾼다. -> State(ui와 변할 수 있는 데이터를 가짐)가 생김
State가 가지는 데이터를 작성(데이터 타입은 List<WebtoonModel>) -> 초기값은 빈 배열
또한 isLoading state를 하나 더 생성한다. (초기값은 true)
initState()메서드 생성 super.initState 먼저 호출하고, waitForWebtoons라는 함수 호출
waitForWebToons() 함수작성 -> 비동기 함수이다 / HTTP 요청을 기다리는 함수(getTodaysToons())를 기다려야 한다. / State의 webtoons 배열에 데이터를 넣는다. / isLoading을 false로 바꾼다. / setState 호출을 해서 StatefulWidget의 UI가 새로고침 되도록 해준다.
print(webtoons);
print(isLoading);을 하여 콘솔 출력창에 뭐가 나오는지 확인하면,
처음엔 webtoons가 빈 배열이고, isLoading은 true이다. 그 다음에 webtoon에 Instance of WebToonModel이 많이 들어온 것을 볼 수 있고, isLoading이 false로 바뀐 것을 확인할 수 있다.
다시 정리해보자면
webtoons와 isLoading이라는 배열과 state를 생성해야 하고, 수동으로 await를 해줘야 하고, isLoading을 false 상태로 바꿔줘야 하고, setState를 쓰고 그걸 initState 내부에 작성해줘야 함. 또한 함수(waitForWebToons)도 만들어야 하고, async도 해줘야 함
이러한 방식(데이터를 fetch해 State에 넣는 방법)은 실수를 할 수 있고, 반복이 많아질 수도 있다.
왜냐하면 데이터를 fetch하는 방식은 일단 데이터가 없는 상태에서 화면이 뜨면 로딩화면을 보여주고, 데이터를 받은 후에 데이터를 보여준다. -> API에서 데이터를 fetch할 때 항상 일어나는 상황이다.
그렇다면 Future를 기다리는 좀 더 나은 방법이 있지 않을까? (나중에..)
데이터를 fetch해 State에 넣는 방법은 추천하지 않음 -> State는 최대한 사용하지 않는 것이 좋다.
그러하여 다른 방법을 사용하여 데이터를 fetch하여 보자!
StatelessWidget인 상태에서 fetch할 수 있다.
home_screen을 이렇게 바꾼 상태에서
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
// code action으로 StatelessWidget으로 변경
const HomeScreen({super.key});
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,
),
),
),
);
}
}
api_service에서 만들어 두었던 Future를 클래스의 가장 처음에 넣어준다. 이때 클래스를 const로 설정 되어 있던 부분에서 오류가 생긴다. -> 당연히 Future를 사용하게 되면 컴파일 전에 값을 알 수 있지 않는다.
Future의 로딩 상태를 build 메소드에 전달하기 위해서 우리는 FutureBuilder라는 위젯을 사용할 것이다.
먼저 Scaffold의 body에 FutureBuilder를 넣는다.
FutureBuilder
는 builder
라는 매개변수가 required이다.
builder는 UI를 그려주는 함수이다. 또한 initialData
와 future
를 전달할 수 있다.
FutureBuilder는 await를 쓸 필요가 없다. (자동적으로 해 줌)
FutureBuilder가 builder에 전달하는 매개변수 : context
, snapshot
(builder의 매개변수 snapshot 이름을 원하는 이름으로 바꿔서도 무관하다.)
먼저 context는 그냥 BuildContext이고 snapshot은 이를 이용해서 Future의 상태를 알 수 있다.
snapshot 옵션이 Future가 데이터를 받았는지, 아니면 오류가 났는지 알 수 있다. (connectionState도 알 수 있음)
그러하여 snapshot.hasData -> Future가 완료되서 데이터가 존재하면 "There is data!" 라는 Text를 반환하도록 하고, 데이터가 없으면, "Loading"을 반환하도록 하였다.
home_screen 코드
import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_model.dart';
import 'package:webtoon/services/api_service.dart';
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});
final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
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....");
},
),
);
}
}
실행화면
이렇게 Loading...이 나타난 뒤에 There is data!가 들어오는 것을 확인할 수 있었다.
많은 양의 데이터를 연속적으로 보여주고 싶을 때 -> ListView
ListView
는 여러 항목을 나열하는데 최적화된 위젯이다. (overflow 에러가 없이 자동으로 scroll view를 가진다)
home_screen 코드
import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_model.dart';
import 'package:webtoon/services/api_service.dart';
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});
final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
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 ListView(
children: [
for (var webtoon in snapshot.data!) Text(webtoon.title)
// snapshot.data를 하게 되면 오류가 생기는 데 null일 수 있어서 오류가 생김
//-> 하지만 현재 코드 블록이 snapshot.hasData가 참일때만 실행되므로 null이 아님을 !로 표시해줘야 한다
],
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
),
);
}
}
실행화면
이렇게 웹툰 제목들이 자동으로 scroll view를 가진 리스트로 나타나진다.
하지만 위의 방법은 ListView가 최적화되지 않았다는 문제를 가진다 -> 한 번에 모든 아이템을 로딩하므로 (메모리가 낭비됨) 사용자가 보고있는 사진이나 섹션만 로딩하도록 해주어야 한다
그러하여 ListView.builder를 사용하여 사용자가 보고 있는 아이템만 build 하도록 한다.
ListView.builder
는 다양한 옵션을 가지는 데, 그 중 scrollDirection
을 통해 스크롤 방향을 바꿀 수 있고, itemCount
를 통해 list 아이템의 개수를 설정할 수 있다.(ListView를 최적화 해주는 좋은 기능)
또한 ListView.builder
는 필수인자로 itemBuilder
를 가지고 이 itemBuilder는 위젯을 반환하는 함수이다. (FutureBuilder의 builder와 아주 아주 비슷하다)
다른 점이라면 인덱스에만 접근이 가능하다는 것이다. 이 인덱스는 어떤 아이템이 build 되는지 알 수 있도록 한다.
사용자가 어떤 아이템을 안 보고 있다면, ListView.builder는 해당 아이템을 메모리에서 삭제한다.
home_screen 코드
import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_model.dart';
import 'package:webtoon/services/api_service.dart';
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});
final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
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 ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
print(index);
var webtoon = snapshot.data![index];
return Text(webtoon.title);
},
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
),
);
}
}
실행화면
이렇게 ListView가 수평으로 스크롤 되면서 디버그 콘솔을 보게 되면 한 번에 아이템을 모두 로딩하지 않고, 필요할 때만 아이템을 만들어 build하는 것을 볼 수 있다. -> ListView.builder 함수의 힘
ListView.builder부분을 ListView.separated로 바꾸고 separatorBuilder(필수인자, 위젯(리스트 아이템 사이에 렌더 될 것)을 리턴해야 하는 함수)를 추가하면
home_screen 코드(ListView.separated 부분)
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
print(index);
var webtoon = snapshot.data![index];
return Text(webtoon.title);
},
separatorBuilder: (context, index) => const SizedBox(width: 20),
);
실행화면
이렇게 ListView.separated로 separatorBuilder에 SizedBox를 사용해서 아이템들을 구분하기 위해서 리스트 아이템 사이에 간격을 띄워주게 되었다. (여기에서 SizedBox는 구분자라고 생각하면 된다)
하지만 재밌는 것은 리스트 아이템의 맨 앞에는 구분자가 없다는 것이다. (알아서 구분자가 없도록 해줌)
전 강의에서 만들었던 ListView.separated 부분을 method로 추출(makeList)
이제 SizedBox를 이용해서 리스트와 위쪽 사이에 공간을 넣고, 웹툰의 표지를 보여주는 이미지를 만들 것임 (Column으로 SizedBox와 makeList를 감싸주어 return)
그렇게 하게 되면 오류가 생기는데, 이 오류는 현재 ListView에 높이 값이 없어 나타나는 오류이다. 그러하여 Column이 ListView가 얼마나 큰지 알 수 없다. -> ListView에 제한된 높이를 준다 -> makeList를 Expanded로 감싼다
Expanded
는 Row나 Column의 child를 확장해서 그 child가 남는 공간을 채우게 한다
이제 ListView에서 Text가 아닌 Column을 return 해서 이미지와 Text 모두 return 하자
이 때, Image.network(src)
를 통하여 이미지를 불러오자.
Image.network(webtoon.thumb)
이렇게 하면 이미지가 잘 출력되어야 하는데, 이미지가 나오지 않는 오류가 생겼다.
오류 해결방법
Image.network(
webtoon.thumb,
headers: const {
'Referer': 'https://comic.naver.com',
},
),
위와 같이 referer 헤더를 추가해주어 해결하였다.
이제 이미지의 크기를 조절해주기 위해서 이미지 부분을 Container로 감싸주고 너비를 설정해 주었다.
그림과 텍스트 사이에 간격을 주기 위해서 SizedBox
를 사용해주고, BoxDecoration
과 BorderRadius
, BoxShadow
를 사용하여 디자인 부분도 좀 더 수정해준다.
여기에서 주의할 점은 BorderRadius가 적용이 안되는 경우에 clipBehavior
를 Clip.hardEdge
로 설정해 줌으로써 이를 해결할 수 있다.
clipBehavior
는 자식의 부모 영역 침범을 제어하는 방법이다.
마지막으로 이미지가 모서리에 붙어서 시작되지 않도록 하기 위해서 ListView에 padding을 준다.
home_screen 코드
import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_model.dart';
import 'package:webtoon/services/api_service.dart';
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});
final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
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,
),
Expanded(child: makeList(snapshot)),
],
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
),
);
}
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(
width: 250,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
blurRadius: 15,
offset: const Offset(10, 10),
color: Colors.black.withOpacity(1),
),
]),
child: Image.network(
webtoon.thumb,
headers: const {
'Referer': 'https://comic.naver.com',
},
),
),
const SizedBox(
height: 10,
),
Text(webtoon.title,
style: const TextStyle(
fontSize: 22,
)),
],
);
},
separatorBuilder: (context, index) => const SizedBox(
width: 40,
),
);
}
}
실행화면
GestureDetector
위젯은 대부분의 동작을 감지한다. (onTapCancel, onTop, onTopUp, onTopDown, onLongPress, onTertiaryLongPress, onVerticalDrag, onHorizontalDrag, onScale, onPan 등)
그 중 onTap
은 버튼을 탭했을 때 발생하는 이벤트 (onTapDown과 onTapUp의 조합)
onTap
함수에 Navigator.push(context, route)
를 통해 화면 이동을 할 건데
route 부분에 MaterialPageRoute
라는 또 다른 클래스를 넣는다. 이 클래스는 StatelessWidget을 route로 감싸서 다른 스크린처럼 보이게 만들어 준다.
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
DetailScreen(title: title, thumb: thumb, id: id),
fullscreenDialog: true,
),
);
},
~~)
그러하여 이렇게 새 route로 push할 수 있다.
fullscreenDiaglog
는 Navigator.push와 MaterialPageRoute를 사용할 때 옵션으로 바닥부터 이미지가 나오도록 애니메이션이 구동되는 것을 볼 수 있다.
home_screen 코드
import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_model.dart';
import 'package:webtoon/services/api_service.dart';
import 'package:webtoon/widgets/webtoon_widget.dart';
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});
final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
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,
),
Expanded(child: makeList(snapshot)),
],
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
),
);
}
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 Webtoon(
title: webtoon.title, thumb: webtoon.thumb, id: webtoon.id);
},
separatorBuilder: (context, index) => const SizedBox(
width: 40,
),
);
}
}
webtoon_widget 코드
import 'package:flutter/material.dart';
import 'package:webtoon/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,
});
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: [
Container(
width: 250,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
blurRadius: 15,
offset: const Offset(10, 10),
color: Colors.black.withOpacity(1),
),
]),
child: Image.network(
thumb,
headers: const {
'Referer': 'https://comic.naver.com',
},
),
),
const SizedBox(
height: 10,
),
Text(
title,
style: const TextStyle(
fontSize: 22,
),
),
],
),
);
}
}
detail_screen 코드
import 'package:flutter/material.dart';
class DetailScreen extends StatelessWidget {
final String title, thumb, id;
const DetailScreen({
super.key,
required this.title,
required this.thumb,
required this.id,
});
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
elevation: 2,
backgroundColor: Colors.white,
foregroundColor: Colors.green,
title: Text(
title,
style: const TextStyle(
fontSize: 24,
),
),
),
body: Column(
children: [
const SizedBox(
height: 50,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 250,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
blurRadius: 15,
offset: const Offset(10, 10),
color: Colors.black.withOpacity(1),
),
]),
child: Image.network(
thumb,
headers: const {
'Referer': 'https://comic.naver.com',
},
),
),
],
),
],
));
}
}
실행화면
현재까지는 썸네일을 누르면 새로운 화면으로 덮이게 되는데 이때 새 이미지를 사용하게 된다.
Hero
위젯을 사용하게 되면 원래의 이미지를 덮어버리지 않고 포스터를 움직여서 마치 같은 포스터인 것처럼 보이게 한다
사용법은 Hero 위젯을 두 개의 화면에 각각 사용하고 각각의 위젯에 같은 태그를 주기만 하면 된다.
현재의 경우 썸네일 카드 Container를 Hero
위젯으로 감싸고 그 안에 tag:id
를 넣어주면 끝이다.
webtoon_widget 코드
import 'package:flutter/material.dart';
import 'package:webtoon/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,
});
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: [
Hero( // Hero, tag 사용
tag: id,
child: Container(
width: 250,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
blurRadius: 15,
offset: const Offset(10, 10),
color: Colors.black.withOpacity(1),
),
]),
child: Image.network(
thumb,
headers: const {
'Referer': 'https://comic.naver.com',
},
),
),
),
const SizedBox(
height: 10,
),
Text(
title,
style: const TextStyle(
fontSize: 22,
),
),
],
),
);
}
}
detail_screen 코드
import 'package:flutter/material.dart';
class DetailScreen extends StatelessWidget {
final String title, thumb, id;
const DetailScreen({
super.key,
required this.title,
required this.thumb,
required this.id,
});
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
elevation: 2,
backgroundColor: Colors.white,
foregroundColor: Colors.green,
title: Text(
title,
style: const TextStyle(
fontSize: 24,
),
),
),
body: Column(
children: [
const SizedBox(
height: 50,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Hero( // Hero, tag 사용
tag: id,
child: Container(
width: 250,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
blurRadius: 15,
offset: const Offset(10, 10),
color: Colors.black.withOpacity(1),
),
]),
child: Image.network(
thumb,
headers: const {
'Referer': 'https://comic.naver.com',
},
),
),
),
],
),
],
));
}
}
실행화면