반응형
Nomad Coders 님의 플러터 강의를 정리한 것입니다.
해당 강의는 현재 무료로 누구나 쉽게 들을 수 있습니다.
https://nomadcoders.co/flutter-for-beginners
해당 내용에 대한 이전 게시글
2024.01.10 - [개발/클론코딩] - [Nomad Coders] Flutter로 Pomodoros 앱 만들기
Webtoon App
Appbar
import 'package:flutter/material.dart';
import 'package:toonflix/screens/home_screen.dart';
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});//키 반환
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: HomeScreen(),
);
}
}
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
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,
),
),
),
);
}
Expand Down
- 각 위젯에는 식별자 id 역할을 하는 key가 있다.
- AppBar로 제일 위 바의 음영, 글자색, 텍스트, 음영 등을 조정할 수 있다.
Data Fetching
import 'package:http/http.dart' as http;
class ApiService {
final String baseUrl = "<https://webtoon-crawler.nomadcoders.workers.dev>";
final String today = "today";
void getTodaysToons() async {
final url = Uri.parse('$baseUrl/$today');//uri에서 url만 빼서 파싱한다,
final response = await http.get(url); // 나중에 받을것이기에 awiat
if (response.statusCode == 200) {
print(response.body); // get으로 받아온 값 로그찍기
return;
}
throw Error(); //서버요청 200 즉 실패하면 에러 발생
}
}
- 여기서부터 api를 불러오기 위해서는 패키지를 설치해서 진행하여야 한다.
- import한 url을 http로 부르고 http.get()을 통해 해당 url에서 서버 응답을 받아올 수 있다.
- 여기서 get은 future타입을 반환한다.
- future은 미래에 받을 코드를 알려준다. 그리고 나중에 response를 반환한다. api 같은 경우 네트워크 문제로 인해 받아오는데 시간이 걸릴 수 있다. 그래서 바로 끝내는 게 아닌 api 요청을 처리할 때까지 응답을 기다려야 한다.
- 이를 비동기 프로그래밍이라 한다. async programming. 완료할때까지 기다리는 것이다.
- 이렇게 요청을 기다리게 하고 싶으면 async 함수 안에서 코드 앞에서 await을 써야 한다. 2가지 조건이 충족되어야 한다.
FromJson
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:toonflix/models/webtoon_model.dart';
class WebtoonModel { //받은 string을 json으로 전환시킴
final String title, thumb, id;
WebtoonModel.fromJson(Map<string, dynamic=""> json)
: title = json['title'],
thumb = json['thumb'],
id = json['id'];
}
class ApiService {
final String baseUrl = "<https://webtoon-crawler.nomadcoders.workers.dev>";
final String today = "today";
Future<list> getTodaysToons() async { //비동기 함수의 반환값은 future다.
List webtoonInstances = [];
final url = Uri.parse('$baseUrl/$today');
final response = await http.get(url);
if (response.statusCode == 200) {
final List webtoons = jsonDecode(response.body);//리스트에 추가
for (var webtoon in webtoons) {
webtoonInstances.add(WebtoonModel.fromJson(webtoon));//클래스 이용해 전환시킴
}
return webtoonInstances;
}
throw Error();
}
</list</string,>
- 요청받은 데이터를 문자열에서 json에 들어갈 수 있는 클래스로 변환해야 한다.
- webtoonmodel은 named constructor 방식을 사용했다. 인수만 적고, : 로 있는 것이다.
- json은 string ↔ map<string,dynamic> ↔ object 사이의 변환 과정이 필요하다.
- json string을 map 형태로 변환시키는 데에는 jsonDecode가 쓰이며, 여기서 나온 것은 fromJson로 객체로 파싱 받는다.
waitForWebtoons
import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State createState() => _HomeScreenState();
}
class _HomeScreenState extends State {
List webtoons = [];
bool isLoading = true; //처음 loading true
void waitForWebToons() async {
webtoons = await ApiService.getTodaysToons();// 웹툰 api 받아올때까지 대기
isLoading = false; // 로딩 끝났다는 뜻
setState(() {});//업데이트
}
@override
void initState() {
super.initState();
waitForWebToons();// build 메소드 호출 전 1번 호출
}
@override
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,
),
),
),
);
}
}
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:toonflix/models/webtoon_model.dart';
class ApiService {
static const String baseUrl =
"<https://webtoon-crawler.nomadcoders.workers.dev>";
static const String today = "today";
static Future<list> getTodaysToons() async {
List 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();
}
}
</list
- 처음에 api 받아오는 waitforwebtoons 함수 실행
- 받아오는 거 기다리면서 빌드 진행됨
- 데이터 도착해서 완료되면 isLoading은 false가 되고 setState로 rebuild
- apiservice에 있는 값들에 static을 붙여 인스턴스 없이 실행할 수 있게 하였다. 이 부분은 나중에 더 연구해 봐야겠다.
FutureBuilder
import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});
Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
@override
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....');
},
),
);
}
}
- 위에서 api 업데이트를 기다리고 굳이 다시 setState로 rebuild 하고 했던 일련의 과정들은 이제 잊어라. 그딴 거 다 지워버리고 훨씬 쉬운 방법을 쓸 테니
- 우선 future 붙어있는 것들은 컴파일 전에 값을 아는 const를 쓸 수 없다. 그러니 const들을 지워준다.
- stateful widget에서 stateless widget으로 바꾸고도 FutureBuilder라는 것을 사용해 훨씬 간편하게 상태 변화를 적용할 수 있게 되었다.
- 우선 Future <List <WebtoonModel>> webtoons = ApiService.getTodaysToons();로 webtoons를 future 형식으로 선언한다.
- 그리고 FutureBuilder 안에서는 받아올 future 형태의 자료형을 future: 에 적고 builder의 snapshot을 통해 현재 받아오고 있는 상태를 알 수 있다.
- snapshot은 네트워크 연결상태, 데이터 받아왔는지 에러가 나진 않았는지 알 수 있게 해 준다.
- 여기서는 데이터를 받아왔는지에 따라 text를 출력해 주었다.
ListView
import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';
class HomeScreen extends StatelessWidget {
HomeScreen({super.key});
Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();
@override
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 ListView.separated(
scrollDirection: Axis.horizontal, //가로 스크롤로 로딩
itemCount: snapshot.data!.length,//총 로딩할 아이템 개수
itemBuilder: (context, index) { //itembuilder을 통해 보여주는 만큼만 로딩
var webtoon = snapshot.data![index];
return Text(webtoon.title);
},
separatorBuilder: (context, index) => const SizedBox(width: 20),
//아이템 사이사이 빈 상자로 여백 만들기
);
}
return const Text('Loading....');
return const Center(
child: CircularProgressIndicator(),//로딩 중일때 뜨는 원
);
},
),
);
}
}
- futurebuilder의 snapshot을 다른 단어로 바꿔서 사용해도 된다. futureResult라던가
- listview 사용 시 많은 양의 데이터들을 나열해서 보여주기 좋다. listview의 children으로 collection for 문, 즉, for in 문으로 모든 것을 한번에 띄울 수 있다.
- 하지만 listview는 한번에 모든것을 다 로딩하기에 메모리가 많이 든다. 인스타그램같이 사용자 보는 부분만 로딩하는 게 더 메모리 사용에 좋다.
- 그렇기 위해서는 Listview.builder을 쓴다. Listview.builder은 딱 보이는 만큼만 계속해서 새로 데이터를 받아 보여준다. 그리고 안의 설정으로 scroll 방향, itemcount로 총 item의 개수, itembuilder의 index를 통해 어떤 아이템이 로딩되는지 알 수 있다.
- Listview.separate를 통해 아이템들이 로딩될 때 그 사이에 무언가를 설정할 수 있다. 여기서는 아이템마다 구분해 주는 공백상자를 넣었다. 맨 앞은 사이가 아니므로 그 상자가 들어가지 않는다.
Webtoon Card
import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_model.dart';
import 'package:toonflix/services/api_service.dart';
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(
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,//높이를 주기 위해서는 listview가 높이값을 알아야 한다. 그렇지 않으면 무한으로 여긴다.
),
Expanded(child: makeList(snapshot))//화면 남는 공간 차지, 남는공간 차지해 listview 위에 높이 들어갈수 있게 함
],
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
),
);
}
//listview 만드는 걸 extract 하였다. 자동으로 인자들이 지정되어 추출되었다.
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( // 이미지 크기 조정하기 위한 상자 container 안에 이미지를 넣어버린다.
width: 250,
clipBehavior: Clip.hardEdge,// container 넘어가는 이미지 자르기 그래야 container 끝 둥글게 한것에 제대로 적용됨
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),// 끝 둥글게
boxShadow: [
BoxShadow( //사각형에 대한 그림자
blurRadius: 15,//그림자 크기
offset: const Offset(10, 10),// (0,0)가 정면일때 광원위치 이동, 특정 방향만 그림자 적용 가능
color: Colors.black.withOpacity(0.3),//그림자 색깔
)
],
),
child: Image.network(webtoon.thumb),//url을 기반으로 이미 불러옴
),
const SizedBox(
height: 10,
),
Text(
webtoon.title,
style: const TextStyle(
fontSize: 22,//밑의 제목 글씨
),
),
],
);
},
separatorBuilder: (context, index) => const SizedBox(width: 40),
);
}
}
Detail Screen
import 'package:flutter/material.dart';
import 'package:toonflix/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,
//home_screen에서 return된 데이터들 받아서 사용
});
@override
Widget build(BuildContext context) {
return GestureDetector( // 사용자 누르는 것 관련 상호작용
onTap: () { //눌렀을때
Navigator.push( //새로운 위젯 만들고 거기로 전환되는 것 처럼 보여줌
context, //그 밑에 받은게 다 route로 아래를 스크린처럼 보여줌
MaterialPageRoute( //statelesss 위젯을 다른 스크린처럼 보여줌
builder: (context) => DetailScreen(
title: title,
thumb: thumb,
id: id,
),
fullscreenDialog: true, // 바닥에서 가져오는 것처럼 연출할 수 있음,
// 이거 안쓰면 옆에서 카드처럼 나오고, 쓰면 아래에서 새로운게 나오는 느낌 줌, 아이콘도 >에서 x로 바뀜
),
);
},
child: 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(thumb),
),
const SizedBox(
height: 10,
),
Text(
title,
style: const TextStyle(
fontSize: 22,
),
),
],
),
);
}
}
- 그 외에 detail_screen에서 원래 scaffold와 이미지 복붙해줘서 가져옴
Hero
- 기존 위젯의 일부를 다음으로 넘어갈 때도 똑같이 띄워야 한다면, 기존 위젯이 사라졌다가 새롭게 다시 생겨나는 것보다는 그 이미지가 이동하는 듯한 느낌을 주는 게 더 좋을 것이다.
- 기존 위젯을 hero:id로 감싼다. 동일한 느낌을 줄 다른 코드도 hero:id 로 동일한 id로 넣는다.
- 그러면 자연스럽게 넘어가는 연출이 된다.
- navigator은 새로운 statefulwidget으로 홈 화면 위에 올려주고, 여기에 materialpageroute라는 애니메이션을 담당하는 기능을 사용할 수 있다. 그걸 통해 detailscreen 파일을 새롭게 띄우고, title, thumb, id를 전달한다.
apiService
class WebtoonDetailModel {
final String title, about, genre, age;
WebtoonDetailModel.fromJson(Map<String, dynamic> json)
: title = json['title'],
about = json['about'],
genre = json['genre'],
age = json['age'];
}
class WebtoonEpisodeModel {
final String id, title, rating, date;
WebtoonEpisodeModel.fromJson(Map<String, dynamic> json)
: id = json['id'],
title = json['title'],
rating = json['rating'],
date = json['date'];
}
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");
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();
}
- 위의 두 모델에서 각각 api를 받아 json 값을 추출한다.
- 각각 추출하는 함수를 인자 리스트로 받는 static constructor들이다.
Futures
import 'package:flutter/material.dart';
import 'package:toonflix/models/webtoon_detail_model.dart';
import 'package:toonflix/models/webtoon_episode_model.dart';
import 'package:toonflix/services/api_service.dart';
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();
}
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);
}
- 기존의 stateless로 되어있던 위젯을 stateful로 바꾸어 주었다. initstate() 메서드가 필요하거든 getToonById와 getLatestEpisodesById는 위젯 id가 필요하다. 즉 인자가 필요하다.
- 원래 그냥 받아오던 title, thumb, id 값 앞에 widget. 을 붙여서 가져오게 되었다. 이렇게 widget을 붙여주는 이유는 부모 역할을 하는 상위 detailscreen stateful 위젯에서 받아오라는 뜻이다.
- late로 webtoon과 episodes를 선언한 다음, initState 할 때 widget.id를 파라미터로 주고 값을 얻어온다.
- 이렇게 하는 이유는 어떤 프로퍼티를 초기화할 때 다른 프로퍼티를 참조하는 것은 불가능하기 때문이다. 따라서 late로 일단 선언해 놓고, 나중에 받아오는 식으로 접근한다.
detail info
const SizedBox(
height: 25,
),
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("..."); //데이터 없을때 출력
},
)
- 클릭했을 때 나오는 썸네일 밑의 상세정보란 텍스트들을 추가해 주었다.
Episodes
body: SingleChildScrollView( //스크롤
child: Padding(
padding: const EdgeInsets.all(50),//전체여백
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: 15,
offset: const Offset(10, 10),
color: Colors.black.withOpacity(0.3),
)
],
),
child: Image.network(widget.thumb),
),
),
],
),
const SizedBox(
height: 25,
),
FutureBuilder(
future: webtoon,
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: 25,
),
FutureBuilder(
future: episodes,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Column(
children: [
for (var episode in snapshot.data!)//episode 들 collection for로 로딩
Container(
margin: const EdgeInsets.only(bottom: 10),//에피소드간 간격
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Colors.green.shade400,
boxShadow: [
BoxShadow(
blurRadius: 5,
offset: const Offset(5, 5),
color: Colors.black.withOpacity(0.1),
),
],
),
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();
},
)
],
),
),
- 썸네일 설명 밑의 최근 화들을 추가해 주었다.
- 최근 화들을 추가할 때 개수가 얼마 되지 않아 간단한 column으로 추가했다. 최적화가 중요하고 복잡할 경우에는 listview를 사용하면 된다.
url Launcher
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}>");
}
- 별도의 설치과정이 있다. 영상 앞부분 참고
- screen.dart에 episode를 받는 부분을 넣어주고 그 외 코드들은 새로 옮겼다.
- 버튼을 눌렀을 때 받은 id와 episode를 바탕으로 url을 호출한다.
favorites
late SharedPreferences prefs; //저장소
bool isLiked = false;
Future initPrefs() async {
prefs = await SharedPreferences.getInstance();//인스턴스 생성,rgkf
final likedToons = prefs.getStringList('likedToons');//저장되어있는 것 가져오기
if (likedToons != null) { //처음 실행할때
if (likedToons.contains(widget.id) == true) {
setState(() {
isLiked = true;//ui 업데이트
});
}
} else {
await prefs.setStringList('likedToons', []);//데이터 쓰기
}
}
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;
});
}
actions: [
IconButton(
onPressed: onHeartTap,
icon: Icon(
isLiked ? Icons.favorite : Icons.favorite_outline,
),
)
],
- 핸드폰 안에 저장소 만드는데 별도의 install이 필요하다.
- likedtoons에 widget.id 리스트를 저장해 불러온다.
반응형
'개발 > 클론코딩' 카테고리의 다른 글
[Nomad Coders] Flutter로 Pomodoros 앱 만들기 (1) | 2024.01.10 |
---|---|
[Nomad Coders] Flutter로 UI 만들기 (2) | 2024.01.10 |
[Nomad Coders] Flutter 를 위한 DART 문법 요약 (1) | 2024.01.09 |
JavaScript로 테트리스 만들기, 코린이가 코린이를 위한 A-Z 설명 4 (完) (0) | 2023.05.08 |
JavaScript로 테트리스 만들기, 코린이가 코린이를 위한 A-Z 설명 3 (0) | 2023.04.29 |