25일차에서는 데이터 Serialization을 연습하기 위해 두 가지 앱을 만들 것이다. 두 가지 앱을 각각 다른 포스팅으로 작성해서 part1과 part2로 나누었다. part1에서는 블로그 앱을 만들었다.
학습한 내용
- part1: 블로그 앱 만들기
- part2: Todo 앱 만들기
추상 클래스는 추상 메서드를 가질 수 있는 클래스이다. 추상 메서드는 미완성된 메서드라고 생각하면 된다. 즉, 메서드의 선언은 되어 있지만, 메서드의 바디가 작성되지 않은 형태이다.
추상 클래스는 abstract
키워드를 사용한다. 아래는 간단한 추상 클래스와 메서드의 예시이다.
abstract class Perst {
eat();
}
이러한 추상 클래스는 객체를 생성할 수 없다. 하지만 참조형 변수의 타입으로는 사용이 가능하다. 추상 클래스를 사용하기 위해서는 implements
키워드를 사용한 뒤 반드시 추상 메서드를 Override 해야한다.
abstract class Perst {
eat();
}
class Developer implements Person {
eat() {
print('Developer eat a meal');
}
}
main() {
Person person = Developer();
person.eat();
}
위의 예시에서 main
함수를 보면 person
의 객체 타입으로 추상 클래스인 Person
을 사용했고, 객체는 Developer()
로 생성했다. Person()
으로 객체를 생성하면 에러가 발생한다.
또한 추상 클래스에서는 추상 메서드만 존재해야 하는 것은 아니다. 일반 메서드도 정의할 수 있다. 하지만 일반 메서드도 implement
된 클래스에서 반드시 재정의해야 한다.
(추상 메서드와 일반 메서드를 재정의할 때 @override
어노테이션은 생략 가능하다.)
extends
키워드를 사용하면 하나의 클래스만 상속이 가능하지만, 추상 클래스는 여러 개를 implement
할 수 있다.
abstract class Perst {
eat();
sleep() {
print('Person must sleep');
}
}
abstract class Junior {
work() {
print('work');
}
}
class Developer implements Person, Junior {
eat() {
print('Developer eat a meal');
}
sleep() {
print('Developer must sleep');
}
work() {
print('Junior developer works hard');
}
}
main() {
Developer person = Developer();
person.eat();
person.sleep();
person.work();
}
person
의 참조형 변수 타입을 Person
으로 하면 Junior
의 메서드인 work()
를 참조할 수 없기 때문에 해당 메소드를 사용하려면 Developer
타입으로 선언해야 한다.
리스트는 List<E>.from()
과 같이 이름있는 생성자를 사용해 리스트를 만들 수 있다. 해당 생성자는 아래와 같은 형태를 가진다.
List<E>.from(
Iterable elements,
{bool growable = true}
)
element
를 모두 포함하는 리스트를 생성하며, 모든 element
는 E
타입의 인스턴스여야 한다. growable
은 리스트의 가변 여부를 나타낸다. 아래는 간단한 사용 예시이다.
final numbers = <num>[1, 2, 3];
final listFrom = List<int>.from(numbers);
print(listFrom); // [1, 2, 3]
또한 이 리스트 생성자는 아래의 예시처럼 요소들을 다운 캐스팅하는데 사용할 수 있다.
const jsonArray = '''
[{"text": "foo", "value": 1, "status": true},
{"text": "bar", "value": 2, "status": false}]
''';
final List<dynamic> dynamicList = jsonDecode(jsonArray);
final List<Map<String, dynamic>> fooData =
List.from(dynamicList.where((x) => x is Map && x['text'] == 'foo'));
print(fooData); // [{text: foo, value: 1, status: true}]
아래는 List.from
생성자의 공식문서 내용이다.
List.from constructor - List - dart:core library - Dart API
- 블로그 앱 만들기
- 추가 사항
공개된 API를 분석하고, 클래스를 활용하여 적용 후 블로그를 보여주는 앱을 만들고자 한다.
- 아래의 공개된 API에서 데이터를 받아온다.
- 반드시 Post 클래스를 만들고 Serialization을 진행할 수 있도록 한다.
- ListView.separated 위젯을 활용하여 Post가 5개씩 구분되어 보여질 수 있도록 한다.
- 이 때, 범위의 번호가 함게 보여진다.
- Post 하나를 클릭하면 아래에서 상세 내용이 나타나도록 만든다.
- 이 때, showModalBottomSheet를 활용한다.
import 'package:flutter/material.dart';
import 'package:my_app/page/main_page.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// root Widget
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(),
home: MainPage(), // 메인 페이지 호출
);
}
}
main.dart
에서는 MainPage
를 호출한다.
class Post {
int userId; //유저 아이디
int id; //포스트 아이디
String title; //제목
String body; //내용
//생성자
Post({
required this.userId,
required this.id,
required this.title,
required this.body,
});
//json을 받아 Post 객체를 생성
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body']);
}
}
Post
클래스는 userId
, id
, title
, body
를 멤버 변수로 가진다. 기본 생성자를 작성하고, json
데이터를 맵 형태로 받아 Serialzation 하는 팩토리 생성자를 작성했다.
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:my_app/model/post.dart';
import 'package:my_app/widget/PostBottomSheet.dart';
import 'package:my_app/widget/PostTile.dart';
class MainPage extends StatelessWidget {
const MainPage({super.key});
//데이터 가져오기
Future<List<Post>> getData() async {
Dio dio = Dio(); //dio 객체
String url = 'https://jsonplaceholder.typicode.com/posts'; //데이터 요청 url
var response = await dio.get(url); //데이터 요청
//데이터를 성공적으로 받아온 경우
if (response.statusCode == 200) {
var data = List<Map<String, dynamic>>.from(response.data); //결과 형변환
return data.map((e) => Post.fromJson(e)).toList(); //데이터 직렬화 후 반환
}
return []; //빈 리스트 반환
}
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder(
future: getData(), //데이터 요청
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.separated(
itemCount: snapshot.data!.length, //리스트뷰 길이
itemBuilder: (context, index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//리스트뷰의 맨 위에 1번부터 5번까지의 포스트 표시
if (index == 0)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
style: TextStyle(
fontSize: 40,
),
'Post 1 ~ 5',
),
),
//포스트 출력
PostTile(
post: snapshot.data![index],
//타일을 눌렀을 때 bottom sheet 생성
onTap: () => showModalBottomSheet(
context: context,
builder: (context) {
return PostBottomSheet(post: snapshot.data![index]);
},
),
)
],
);
},
separatorBuilder: (context, index) {
// 포스트를 5개 단위로 구분
if ((index + 1) % 5 == 0) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
style: const TextStyle(
fontSize: 40,
),
'Post ${index + 2} ~ ${index + 6}',
),
);
}
return const SizedBox();
},
);
}
return const Center(child: CircularProgressIndicator()); //로딩 중
},
),
);
}
}
MainPage
에서는 데이터를 가져오는 getData()
를 작성했는데 데이터를 받아온 경우 Post
에 데이터를 Serialization 해서 리턴했다.
본문에서는 FutureBuilder
로 데이터를 가져오고, 내용은 ListView.separated
를 사용해 만들었다. itemBuilder
에서 리스트 뷰의 맨 위에는 "Post 1 ~ 5"를 출력했고, 각 요소들은 커스텀 위젯인 PostTile
로 만들었다. PostTile
을 눌렀을 때에는 showModalBottomSheet
으로 하단 시트를 띄웠고, 내부 내용은 PostBottomSheet
위젯을 따로 생성했다.
포스트를 5개마다 구분하기 위해 separatorBuilder
에서 5개마다 Text
위젯을 출력했다.
import 'package:flutter/material.dart';
import 'package:my_app/model/post.dart';
class PostTile extends StatelessWidget {
const PostTile({super.key, required this.post, required this.onTap});
final Post post; //포스트
final VoidCallback onTap; //onTap 이벤트 핸들러
Widget build(BuildContext context) {
return InkWell(
onTap: onTap, //이벤트 핸들러 연결
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//포스트 번호
Padding(
padding: const EdgeInsets.all(16.0),
child: CircleAvatar(
child: Text(post.id.toString()),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//포스트 제목
Text(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
post.title,
),
//포스트 내용
Text(
post.body,
),
],
),
)
],
),
),
);
}
}
PostTile
은 포스트를 매개변수로 전달받아 포스트 번호와 포스트 제목, 포스트 내용을 출력한다.
타일을 눌렀을 때 하단 시트가 뜨도록 onTap
핸들러도 전달받아 적용한다.
import 'package:flutter/material.dart';
import 'package:my_app/model/post.dart';
class PostBottomSheet extends StatelessWidget {
const PostBottomSheet({super.key, required this.post});
final Post post; //포스트
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//포스트 제목
Text(
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
post.title,
),
const SizedBox(height: 24),
//포스트 내용
Text(
post.body,
),
],
),
);
}
}
PostBottomSheet
는 포스트를 전달받아 포스트 제목과 내용을 출력한다. 이 내용은 Bottom Sheet로 출력된다.
과제를 수행하고 난 뒤 강의를 듣고 아쉬웠던 점을 정리했다.
크게 문제가 되지는 않지만 직접 작성한 코드에서 PostTile
은 Column
과 Row
를 사용하여 만들었는데 ListTile
을 사용했으면 더 간단한 코드가 되었을 것 같다.
LisTile(
title: Text(post.title),
subtitle: Text(post.body),
leading: CircleAvatar(
child: Text(post.id.toString()),
),
)
결과물이 dark 테마라 몰랐는데 하단 시트에 곡선이 적용되어 있었다...ㅠ
Bottom Sheet의 테두리 곡선은 showModalBottomSheet
에서 shape
속성을 사용하면 된다.
showModalBottomSheet(
context: context,
builder: (context) {
return PostBottomSheet(post: snapshot.data![index]);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
part1은 크게 어려운 점이 없어서 빨리 끝났다.ㅎㅎ 추가 내용 정리는 새로운 내용이 크게 없어서 추상 클래스에 대해 정리해 보고, 사용한 List.from 생성자에 대해 작성했다. (추상 클래스에서 시리님이 testMethod 라는 것이 있다고 하셨는데 찾아보니 Unit Test an Abstract Class?? 와 관련된 내용들이 많이 나온다. 근데 이게 맞는지 모르겠다.ㅠㅠ 뭔가 어마어마한 내용들이 있는데 정리는 아직 엄두가 안나서 나중에....😂) 후기는 part2에 이어서....