27일차에서는 Map 데이터 안에 또 Map이 있는 구조의 데이터를 Serialization 하는 방법에 대해 학습했다.
(26일차는 3.1절로 포스팅이 없습니다.)
학습한 내용
- 프로필 앱 만들기
일반적으로 다른 언어들에서는 인터페이스를 정의하기 위해 interface
와 같은 키워드를 사용하지만 Dart는 따로 키워드 없이 클래스로 만든다.
이를 구현하기 위해서는 implements
키워드를 사용한다. 아래는 인터페이스의 예시 코드이다.
class Animal {
String? name;
Animal(this.name);
void sayName() {
print('My name is ${this.name}');
}
}
class Cat implements Animal {
// Animal 클래스에 정의되어있기 때문에 반드시 구현!
String? name;
// 상속이 아니기 때문에 super를 사용하지 않음
Cat(this.name);
// 상속과 달리 구현하지 않으면 에러 발생
void sayName() {
print('고양이');
}
}
void main() {
var cat = Cat('고양이');
cat.sayName();
}
인터페이스는 추상 클래스와 비슷해 보이지만 약간의 차이가 있다.
추상 클래스에 대한 내용은 [Flutter] 스나이퍼팩토리 25일차 part1에서 정리했다.
추상 클래스는 상속을 통해서 추상 메서드를 정의하도록 유도하는 미완성 설계도이고, 인터페이스는 기본 설계도이다.
간단히 말하면 인터페이스는 모든 메소드가 추상 메서드로 구현되지 않은 상태이고, 추상 클래스는 일반 메서드도 추상 메서드와 함께 사용될 수 있다.
물론 추상 클래스에서 모든 메서드를 추상 메서드로 구현할 수도 있지만 인터페이스와 사용 용도에 맞게 사용하면 더 좋을 것 같다.
추상 클래스와 인터페이스의 목적
추상 클래스: 상속을 받아서 기능을 확장 시키는 것
인터페이스: 구현하는 모든 클래스에 대해 특정한 메서드가 반드시 존재하도록 강제하는 역할
- 프로필 앱 만들기
- 추가 사항
공개된 API를 분석하고, 클래스를 활용하여 적용 후 유저들의 프로필을 보여주는 앱을 만들고자 한다.
- 아래의 공개된 API에서 데이터를 받아온다.
- 각 사람별 이미지를 CircleAvatar를 통해 보여주도록 한다.
- 이 때, 해당 API에는 프로필 이미지가 없으므로 다음의 이미지 URL을 활용한다.
- https://xsgames.co/randomusers/assets/avatars/male/{번호}.jpg
- {번호}에는 유저의 id를 넣어 사용한다.
- 애니메이션 효과를 적절히 사용해 최대한 결과물 예시와 비슷하게 만든다.
- 네트워크에 통신하여 데이터를 가져오는 것은 첫 페이지(리스트를 보여주는 페이지)에서만 할 수 있도록 한다.
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
dio: ^5.0.0
cached_network_image: ^3.2.3
animate_do: ^3.0.2
pubspec.yaml
에 사용할 패키지들을 설치했다.
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(
home: MainPage(), // 메인 페이지 호출
);
}
}
main.dart
에서는 MainPage
를 호출한다.
import 'package:my_app/model/address.dart';
import 'package:my_app/model/company.dart';
class User {
int id; //아이디
String name; //이름
String email; //이메일
Address address; //주소
String phone; //전화번호
String website; //웹사이트
Company company; //회사
//생성자
User({
required this.id,
required this.name,
required this.email,
required this.address,
required this.phone,
required this.website,
required this.company,
});
//데이터를 직렬화하여 User 객체를 생성
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'],
name: map['name'],
email: map['email'],
address: Address.fromMap(map['address']),
phone: map['phone'],
website: map['website'],
company: Company.fromMap(map['company']),
);
}
}
User
클래스는 사용자의 전체적인 정보를 담고 있다. 주소와 회사는 다른 클래스로 작성하여 각각 Address
와 Company
객체를 사용한다.
class Company {
String name; //회사 이름
String catchPhrase; //회사 문구
String bs; //직무
//생성자
Company({
required this.name,
required this.catchPhrase,
required this.bs,
});
//데이터를 직렬화하여 Company 객체를 생성
factory Company.fromMap(Map<String, dynamic> map) {
return Company(
name: map['name'],
catchPhrase: map['catchPhrase'],
bs: map['bs'],
);
}
}
Company
클래스는 사용자의 회사 정보를 가지고 있다.
import 'package:my_app/model/geo.dart';
class Address {
String street; //스트리트
String suite; //거주지
String city; //도시
String zipcode; //우편번호
Geo geo; //지리적 주소
//생성자
Address({
required this.street,
required this.suite,
required this.city,
required this.zipcode,
required this.geo,
});
//데이터를 직렬화하여 Address 객체를 생성
factory Address.fromMap(Map<String, dynamic> map) {
return Address(
street: map['street'],
suite: map['suite'],
city: map['city'],
zipcode: map['zipcode'],
geo: Geo.fromMap(map['geo']),
);
}
}
Address
클래스는 사용자의 주소 정보를 담고 있다. Geo
는 다른 클래스로 작성했다.
class Geo {
String lat; //위도
String lng; //경도
//생성자
Geo({
required this.lat,
required this.lng,
});
//데이터를 직렬화하여 Geo 객체를 생성
factory Geo.fromMap(Map<String, dynamic> map) {
return Geo(
lat: map['lat'],
lng: map['lng'],
);
}
}
Geo
클래스는 위도와 경도의 정보를 저장한다.
import 'package:animate_do/animate_do.dart';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:my_app/model/user.dart';
import 'package:my_app/widget/contact_tile.dart';
class MainPage extends StatefulWidget {
const MainPage({super.key});
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
final Dio _dio = Dio(); //dio 객체
final String _url = 'https://jsonplaceholder.typicode.com/users'; //데이터 요청 url
Future<List<User>>? _result; //데이터 불러오기 결과
//데이터 불러오기
Future<List<User>> _readData() async {
var response = await _dio.get(_url); //데이터 요청
//데이터를 성공적으로 받아온 경우
if (response.statusCode == 200) {
var data = List<Map<String, dynamic>>.from(response.data); //결과 형변환
return data.map((e) => User.fromMap(e)).toList(); //데이터 직렬화 후 반환
}
return []; //빈 리스트 반환
}
void initState() {
super.initState();
_result = _readData(); //네트워크 데이터 초기화
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
foregroundColor: Colors.black,
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: const Text('My Contacts'),
flexibleSpace: GestureDetector(
//앱바를 눌렀을 때 데이터를 다시 받아와 새로고침
onTap: () => setState(() {
_result = _readData();
}),
),
),
body: FutureBuilder(
future: _result, //네트워크 데이터 결과 바인딩
builder: (context, snapshot) {
//데이터를 받아오는 중에는 로딩 화면 출력
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
//데이터를 받아오지 못했거나 없는 경우 결과 텍스트 출력
if (!snapshot.hasData || snapshot.data == null) {
return const Text('데이터가 없습니다!');
}
//데이터를 성공적으로 받아온 경우 리스트뷰를 출력
return ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: snapshot.data?.length ?? 0,
itemBuilder: (context, index) {
return FadeInRight(
delay: Duration(milliseconds: index * 300),
//각 사용자의 정보를 커스텀 타일로 출력
child: ContactTile(
user: snapshot.data![index],
image:
'https://xsgames.co/randomusers/assets/avatars/male/${snapshot.data![index].id}.jpg',
),
);
},
);
},
),
);
}
}
MainPage
에서는 우선 데이터를 받아오는 _readData()
를 작성했다. 이 메소드는 initState()
에서 초기화 했다.
앱바에서 GestureDetector
를 사용해 눌렀을 때 데이터를 다시 받아와 화면을 새로고침했다.
본문은 FutureBuilder
를 사용해 그려주었고 데이터를 가져온 경우 리스트뷰를 출력했다. 리스트 뷰의 각 요소는 animate_do
패키지의 FadeInRight
애니메이션을 사용했다. 각 요소의 delay
를 다르게 설정하여 순차적으로 사용자 정보들이 출력되게 했다.
각 사용자 정보는 ContactTile
을 직접 작성하여 사용했다.
import 'package:animate_do/animate_do.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:my_app/model/user.dart';
import 'package:my_app/widget/company_tile.dart';
import 'package:my_app/widget/information_tile.dart';
class ContactDetailPage extends StatelessWidget {
const ContactDetailPage({super.key, required this.user, required this.image});
final User user; //사용자 정보
final String image; //이미지 URL
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true, //body 확장
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: Text(user.name),
),
body: Stack(
children: [
Column(
children: [
//사용자 이미지 배경
Container(
height: 250,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
//이미지 어둡게
colorFilter: const ColorFilter.mode(
Colors.black54,
BlendMode.darken,
),
image: CachedNetworkImageProvider(image),
),
),
),
const SizedBox(height: 50),
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//사용자 이름
FadeIn(
delay: const Duration(milliseconds: 300),
child: Text(
style: const TextStyle(
color: Colors.grey,
fontSize: 32,
),
user.name,
),
),
const Divider(height: 32),
//사용자 정보
FadeIn(
delay: const Duration(milliseconds: 800),
child: InformationTile(user: user),
),
const Divider(height: 32),
//사용자 회사 정보
FadeIn(
delay: const Duration(milliseconds: 1300),
child: CompanyTile(company: user.company),
),
],
),
),
),
],
),
//원형 이미지
Positioned(
top: 200,
left: 16,
child: CircleAvatar(
radius: 50,
backgroundImage: CachedNetworkImageProvider(image),
),
)
],
),
);
}
}
MainPage
에서 클릭한 사용자의 정보를 보여주는 페이지인 ContactDetailPage
는 사용자 정보와 사용자의 이미지 URL을 전달받는다.
바디의 내용을 확장하여 사용자의 이미지를 상단에 넣어주었다. 이 때 이미지는 약간 어둡게 처리했다.
그리고 Column
으로 사용자의 이름과 사용자의 정보, 사용자의 회사 정보를 각각 출력했다. 사용자의 정보는 InformationTile
로, 사용자의 회사 정보는 CompanyTile
로 커스텀 위젯을 작성하여 사용했다. 세 가지 정보는 FadeIn
애니메이션을 적용했고, 각각 delay
를 다르게 설정했다.
전체를 Stack
으로 감싸고 Positoned
를 사용해 사용자의 원형 이미지를 배경 이미지와 사용자 정보 사이에 넣어 주었다.
import 'package:flutter/material.dart';
import 'package:my_app/model/user.dart';
import 'package:my_app/page/contact_detail_page.dart';
import 'package:cached_network_image/cached_network_image.dart';
class ContactTile extends StatelessWidget {
const ContactTile({super.key, required this.user, required this.image});
final User user; //사용자 정보
final String image; //사용자 이미지 URL
//페이지 이동 라우트 생성
Route _createRoute(Widget page) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var begin = const Offset(1.0, 0.0); //오른쪽 위 시작 지점
var end = Offset.zero; //왼쪽 위 끝 지점
var curve = Curves.ease;
var tween =
Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
);
}
Widget build(BuildContext context) {
return GestureDetector(
//상세 프로필 페이지로 이동
onTap: () => Navigator.of(context).push(
_createRoute(ContactDetailPage(user: user, image: image)),
),
child: ListTile(
title: Text(user.name), //사용자 이름
subtitle: Text(user.email), //사용자 이메일
//사용자 이미지
leading: CircleAvatar(
backgroundImage: CachedNetworkImageProvider(image),
),
trailing: const Icon(Icons.navigate_next),
),
);
}
}
ContactTile
은 MainPage
에서 각 사용자의 정보를 보여주는 타일이다. 사용자의 정보와 이미지 url을 전달받는다.
페이지 이동 라우트를 생성하는 함수를 _createRoute
로 작성하고, 해당 타일을 눌렀을 때 상세 프로필 페이지로 이동하도록 onTap
이벤트를 작성했다.
각 정보는 ListTile
을 사용해 출력했고, 이미지는 CircleAvatar
와 CachedNetworkImageProvider
를 사용했다.
import 'package:flutter/material.dart';
import 'package:my_app/model/user.dart';
class InformationTile extends StatelessWidget {
const InformationTile({super.key, required this.user});
final User user; //사용자 정보
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
style: TextStyle(
fontSize: 24,
),
'Information',
),
const SizedBox(height: 8),
//사용자 이메일
Row(
children: [
const Icon(Icons.mail),
const SizedBox(width: 8),
Text(user.email),
],
),
const SizedBox(height: 8),
//사용자 전화번호
Row(
children: [
const Icon(Icons.call),
const SizedBox(width: 8),
Text(user.phone),
],
),
const SizedBox(height: 8),
//사용자 주소
Row(
children: [
const Icon(Icons.location_on),
const SizedBox(width: 8),
Text(
'${user.address.city}, ${user.address.street}, ${user.address.suite}'),
],
),
],
);
}
}
InformationTile
은 사용자의 정보를 전달받아 사용자의 이메일과 사용자의 전화번호, 사용자의 주소를 출력한다.
상세 프로필 페이지에서 사용된다.
import 'package:flutter/material.dart';
import 'package:my_app/model/company.dart';
class CompanyTile extends StatelessWidget {
const CompanyTile({super.key, required this.company});
final Company company; //사용자 회사 정보
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
style: TextStyle(
fontSize: 24,
),
'Company',
),
const SizedBox(height: 8),
Text(company.name), //회사 이름
const SizedBox(height: 8),
Text(company.catchPhrase), //회사 문구
const SizedBox(height: 8),
Text(company.bs), //회사 직무
],
);
}
}
CompanyTile
은 사용자의 회사 정보를 전달받아 출력한다.
상세 프로필 페이지에서 사용된다.
과제를 수행하고 난 뒤 강의를 듣고 아쉬웠던 점을 정리했다.
코드를 작성할 때 ContactDetailPage
에서 사용자의 원형 이미지가 배경 이미지와 Stack
으로 묶으면 아래 부분이 잘려서 사용자 정보와 함께 전체를 Stack
으로 사용했는데 배경 이미지와만 묶고 clipBehavior
속성을 설정하면 잘린 부분도 보이게 할 수 있다. 또한 Positioned
에서 bottom
의 값을 설정한 radius
의 값의 음수를 적용하면 절반만 아래에 걸치게 할 수 있다.
위의 코드에서는 이미지를 약간 어둡게 하기 위해 Container
에 decoration
속성에서 이미지를 넣고 colorFilter
를 적용했는데 이렇게 하지 않고 Image.network
위젯에서 바로 colorBlendMode
속성을 사용하고 color
를 설정하면 어둡게 처리할 수 있다.
아래는 이러한 수정사항을 모두 적용한 코드이다.
Stack(
clipBehavior: Clip.none,
children: [
Image.network(
fit: BoxFit.cover,
width: double.infinity,
height: 250,
colorBlendMode: BlendMode.darken,
color: Colors.black54,
image
),
Positioned(
bottom: -50,
child: CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(image),
),
),
],
),
어제가 3.1절이라 쉬고 돌아왔습니다 ㅎㅎ (그래서 26일차 포스팅이 없고 바로 27일차로) 오늘도 역시 데이터 Serialization 연습을 했다. 이번에는 Map형태의 데이터 안에 또 Map 형태가 들어있는 것을 직렬화 했는데 클래스를 여러개 만들어서 데이터가 매핑되는 것이 뭔가 신기했다ㅋㅋㅋㅋ 어쨌든 이번 과제도 잘 마무리했고, 추가 내용 정리는 간단하게 인터페이스에 대해 정리했다. 다트에서는 인터페이스에 사용되는 키워드가 따로 없어서 약간 당황했다. 추상 클래스와 거의 비슷한데 사용 방식에 차이가 있어서 코드를 짤 때 두 가지를 잘 구분해서 사용해야 될 것 같다ㅎㅎ