와챠의 우당탕탕 코딩 일기장
[Flutter] Flutter 로 웹툰 앱 만들기: Data fetch/fromJson/async await/ListView/Hero/Futures/Uri Luncher/Pub.dev 본문
[Flutter] Flutter 로 웹툰 앱 만들기: Data fetch/fromJson/async await/ListView/Hero/Futures/Uri Luncher/Pub.dev
minWachya 2023. 1. 29. 14:54목차
- 결과물 설명
- 화면 및 기능
- api
- 패키지 구조
- 홈 화면
- 상세 화면
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랑 플러터 꼭 깔아야 함... 코드 자동정리, 자동 최적화, 미리보기 등 이거 이용 못하면 너무 불편할듯,,,
'코딩 일기장 > Flutter' 카테고리의 다른 글
[Flutter] Camera에서 실시간으로 이미지 받아오기 (0) | 2023.05.28 |
---|---|
[Flutter] MediaPipe로 스켈레톤 추출하기 (1) | 2023.05.15 |
[Flutter] Flutter 로 웹툰 앱 만들기: Pomodoro 앱 만들기/Timer/Flexible (1) | 2023.01.27 |
[Flutter] Flutter 로 웹툰 앱 만들기: State/buildContext/Widget Life Cycle (0) | 2023.01.27 |
[Flutter] Flutter 로 웹툰 앱 만들기: 헤더/버튼/카드/컴포넌트 (0) | 2023.01.25 |