16일차 과제로 진행했던 강아지 사진 앱을 업그레이드 하고자 한다.
16일차 과제 링크
[Flutter] 스나이퍼팩토리 16일차
아래와 같은 6개의 기능을 추가하고자 한다.
- AppBar title을 누르면 GridView의 스크롤이 최상단으로 이동합니다.
- (단 애니메이션이 적용되어 부드럽게 올라가야합니다.)
- 앱을 시작할 때 뜨는 Splash 화면 적용해봅시다
- 첨부된 강아지 사진을 이용해도 좋고 다른파일을 이용해도 좋습니다.
- 좋아요 아이콘 옆 댓글 아이콘을 누르면 새로운 페이지로 넘어가게되고, Hero 애니메이션을 적용하여 사진이 같이 이동합니다.
- pull to refresh하면 shimmer가 나오고 그 후 데이터가 불러오면 부드럽게 전환이 되게 합시다.
- Duration은 2초로 설정해주세요.
- 좋아요를 누르면 회색하트가 빨간색으로 변합니다. 우측상단 하트를 누르면 bottomSheet가 등장하여 좋아요 리스트를 볼 수 있습니다. 또한 앱을 종료 후 재실행 하더라도, 좋아요 한 것은 유지가 됩니다.
- (패키지 Hive 이용)
- 우측 상단의 X 를 누르면 좋아요가 모두 삭제가 됩니다. 단 하트가 빨간색에서 회색으로만 바뀌어야하고 화면 모두 새로고침이 되면 안됩니다.
-pubspec.yaml
dependencies:
pull_to_refresh: ^2.0.0
dio: ^5.0.0
connectivity_plus: ^3.0.3
shimmer: ^2.0.0
flutter_spinkit: ^5.1.0
animate_do: ^3.0.2
hive: ^2.2.3 #hive 패키지 사용을 위해 필요
hive_flutter: ^1.1.0 #hive 패키지 사용을 위해 필요
dev_dependencies:
hive_generator: ^2.0.0 #hive 패키지 사용을 위해 필요
build_runner: ^2.3.3 #hive 패키지 사용을 위해 필요
import 'package:first_app/screen/splash_screen.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
void main() async {
await Hive.initFlutter(); //Hive 초기화
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// root Widget
Widget build(BuildContext context) {
return MaterialApp(
home: SplashScreen(), // 스플래시 화면 호출
);
}
}
import 'dart:async';
import 'package:first_app/page/home_page.dart';
import 'package:flutter/material.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
void initState() {
//스플래시 화면을 1초 뒤에 HomePage로 이동하도록 설정
Timer(Duration(seconds: 1), () {
Navigator.push(
context, MaterialPageRoute(builder: (context) => HomePage()));
});
super.initState();
}
Widget build(BuildContext context) {
return Scaffold(
//화면 가운데에 스플래시 이미지를 둥근 테두리 형태로 출력
body: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(64),
child: Image.asset(
width: 150, height: 150, 'assets/images/splash_image.jpg'),
),
),
);
}
}
import 'package:animate_do/animate_do.dart';
import 'package:first_app/page/comment_page.dart';
import 'package:first_app/widget/ShimmerBox.dart';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:hive/hive.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late Future result; // 데이터 가져오기 결과
Dio dio = Dio(); //Dio 객체
bool isNetworkConnect = true; //네트워크 연결 상태
bool isCheckingNetwork = true; //네트워크 연결 확인 중
bool isLoading = true; //데이터 로딩 중
List likes = []; // 각 데이터의 좋아요 여부
RefreshController refreshController = RefreshController(); //리프레시 컨트롤러
ScrollController scrollController = ScrollController();
late Box box; //Hive box
getBox() async {
return await Hive.openBox('likes'); //Hive Box 열기
}
//데이터 가져오기
Future getData() async {
isLoading = true; //로딩 중
var url =
'https://sfacassignment-default-rtdb.firebaseio.com/.json'; //요청 url
try {
var res = await dio.get(url); //데이터 요청
box = await getBox(); //Hive box 가져오기
likes =
box.get('likes') ?? []; // box에서 가져온 데이터를 likes에 저장 (null일 경우 빈 리스트)
//네트워크의 데이터가 많아졌을 경우 likes의 길이 증가 및 초기화
likes.addAll(List.filled(res.data['body'].length - likes.length, false));
isLoading = false; //로딩 종료
return res.data['body']; //결과 리턴
} catch (e) {
print(e);
}
}
//새로고침(데이터를 다시 불러옴)
void onRefresh() async {
result = getData(); //데이터 가져오기
setState(() {}); //화면 그리기
refreshController.refreshCompleted(); //새로고침 완료
}
// 네트워크 연결 확인
void checkConnectivityNetwork() async {
isCheckingNetwork = true; //네트워크 연결 확인 중
setState(() {}); //네트워크 연결 확인 중 화면으로 그리기
final connectivityResult =
await (Connectivity().checkConnectivity()); //네트워크 연결 확인
await Future.delayed(
Duration(milliseconds: 1500)); // 연결 확인 중 화면을 출력하기 위한 딜레이
//네트워크가 연결된 경우
if (connectivityResult == ConnectivityResult.mobile ||
connectivityResult == ConnectivityResult.wifi) {
isNetworkConnect = true; //네트워크 연결 됨
result = getData(); //데이터 가져오기
} else {
//네트워크가 연결 되지 않은 경우
isNetworkConnect = false; //네트워크 연결 안됨
}
isCheckingNetwork = false; //네트워크 연결 확인 종료
setState(() {}); //결과에 맞는 화면 그리기
}
void initState() {
super.initState();
checkConnectivityNetwork();
}
void dispose() async {
refreshController.dispose(); //컨트롤러 해제
await box.close(); //Hive box 닫기
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
// 앱바
appBar: AppBar(
centerTitle: true,
title: GestureDetector(
onTap: (() {
scrollController.animateTo(0,
duration: Duration(seconds: 1), curve: Curves.ease);
}),
child: Text('도전하기!'),
),
actions: [
IconButton(
onPressed: () {
//좋아요 리스트를 바텀 시트로 출력
showModalBottomSheet(
context: context,
builder: ((BuildContext context) {
return FutureBuilder(
future: result,
builder: (context, snapshot) {
//좋아요가 눌린 데이터만 리스트 뷰로 출력
if (snapshot.hasData && likes.contains(true)) {
return ListView.builder(
itemCount: snapshot.data.length,
itemBuilder: (context, index) {
if (likes[index]) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
width: 80,
height: 80,
fit: BoxFit.cover,
snapshot.data[index]['url']
.toString()),
),
SizedBox(width: 16),
Text(snapshot.data[index]['msg'])
],
),
);
}
return SizedBox();
},
);
} else {
//좋아요 목록이 하나도 없을 경우
return Center(child: Text('좋아요 목록이 비어있습니다.'));
}
},
);
}),
);
},
icon: Icon(color: Colors.red, Icons.favorite),
),
IconButton(
onPressed: () {
//모든 좋아요 항목을 취소
for (var i = 0; i < likes.length; i++) {
if (likes[i]) {
likes[i] = false;
}
}
box.put('likes', likes);
setState(() {});
},
icon: Icon(Icons.close),
),
],
),
body: !isCheckingNetwork &&
isNetworkConnect //네트워크 연결 확인 중이 아니고, 네트워크가 연결된 경우(그리드 뷰 출력)
? FutureBuilder(
future: result,
builder: (context, snapshot) {
return SmartRefresher(
controller: refreshController, //새로고침 컨트롤러 연결
onRefresh: onRefresh, //새로고침 핸들러
enablePullDown: true, //내려서 새로고침 활성화
child: GridView.builder(
controller: scrollController,
physics: BouncingScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 그리드뷰 한 줄의 아이템 개수
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.73,
),
itemCount: snapshot.hasData ? snapshot.data.length : 6,
itemBuilder: (context, index) {
return !isLoading //데이터가 로딩 중이 아닌 경우(불러온 데이터를 카드에 출력)
// 카드가 그려질 때 애니메이션 적용
? FadeIn(
duration: Duration(seconds: 2), // 2초
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Column(
children: [
//모서리가 둥근 이미지
Expanded(
child: Container(
width: double.infinity,
margin: EdgeInsets.all(8.0),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(16.0),
),
clipBehavior: Clip.antiAlias,
//Hero 페이지 애니메이션을 적용하기 위한 위젯
child: Hero(
tag: index,
child: Image.network(
snapshot.data[index]['url']
.toString(),
),
),
),
),
// 메세지
Text(
snapshot.data[index]['msg'].toString(),
),
Row(
children: [
// 코멘트 아이콘
IconButton(
//상세 페이지로 이동(hero애니메이션 적용)
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CommentPage(
image: snapshot.data[index]
['url'],
msg: snapshot.data[index]
['msg'],
heroTag: index,
),
),
),
color: Colors.grey,
icon: Icon(Icons.comment),
),
//좋아요 버튼(누르면 색상 변경)
GestureDetector(
onTap: () {
likes[index] = !likes[index];
box.put('likes',
likes); //좋아요 리스트를 box에 저장
setState(() {});
},
child: Icon(
color: likes[index]
? Colors.red
: Colors.grey,
Icons.favorite,
),
)
],
),
],
),
),
)
// 데이터가 로딩 중인 경우(Shimmer 카드 출력)
: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Column(
children: [
Expanded(child: ShimmerBox()),
SizedBox(height: 8),
ShimmerBox(height: 56),
],
),
);
},
),
);
},
)
//네트워크 연결 확인 중이거나 네트워크가 연결되지 않은 경우
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isCheckingNetwork)
Text('인터넷이 연결 확인중입니다') // 연결 확인 중 메세지
else
Text('인터넷이 연결되지 않았습니다!'), // 연결되지 않음 메세지
SizedBox(height: 16),
// 연결 확인중 progress
if (isCheckingNetwork)
SpinKitWave(
color: Colors.blue,
size: 32.0,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
checkConnectivityNetwork(); // 네트워크 연결 확인
},
child: Icon(Icons.wifi_find),
),
);
}
}
import 'package:flutter/material.dart';
class CommentPage extends StatelessWidget {
const CommentPage(
{super.key,
required this.image,
required this.msg,
required this.heroTag});
final image; //이미지 url
final msg; // 메세지
final heroTag; //hero 태그
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//Hero 애니메이션을 위한 위젯
Hero(
tag: heroTag, //태그 설정
//이미지
child: Image.network(
width: 300,
height: 300,
image.toString(),
),
),
SizedBox(height: 80),
Text(msg), //메세지
],
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
//Shimmer를 보여주는 위젯
class ShimmerBox extends StatelessWidget {
const ShimmerBox({super.key, this.width = double.infinity, this.height = 0});
final double width; // 너비
final double height; // 높이
Widget build(BuildContext context) => Shimmer.fromColors(
baseColor: Colors.grey, // 기본 색상
highlightColor: Colors.white, // 하이라이트 색상
child: Container(
width: width, // 너비 설정
height: height, // 높이 설정
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(16.0), // 둥근 모서리
),
),
);
}
16일차의 기본 코드 설명은 아래 링크에서 확인 가능하다.
해당 포스팅에서는 추가한 기능들을 어떤 방식으로 구현했는지에 대해서만 간단히 설명하고자 한다.
우선 ScrollController
를 생성하여 그리드 뷰의 컨트롤러로 설정했다.
앱바의 title
에서 기존에 사용했던 Text
를 GestureDetector
로 감싸 onTap
이벤트에 scrollController
를 사용해 상단으로 이동하는 기능을 작성했다. 부드럽게 상단으로 이동해야 하므로 animatedTo()
메소드를 사용했다.
Splash 화면을 적용하는 방법에는 여러가지가 있는데 검색을 통해 찾아보니 주로 flutter_native_splash
패키지를 사용하는 것 같았다.
하지만 이번에는 해당 패키지를 사용하지 않고 따로 splash_screen.dart
파일로 스플래시 화면을 만들고 main.dart
에서 호출해 주었다.
SplashScreen
은 initState()
에서 Timer()
를 사용해 1초 뒤에 자동으로 HomePage
로 이동하도록 설정했다.
기존의 코드에서 이미지에 Hero
위젯을 감싸고 tag
로는 그리드 뷰의 index
를 사용하였다.
코멘트 아이콘 버튼의 onPressed
에서 상세 이미지 페이지로 이동하도록 했으며 상세 페이지는 comment_page.dart
파일로 작성했다.
CommentPage
에서는 이미지 url과 메세지와 히어로 태그를 전달 받는다. 이를 사용해 화면을 구성하는데 역시 이미지는 Hero
위젯으로 감싸고 tag
를 전달받은 값으로 설정해 주었다.
pull to refresh를 했을 때 shimmer가 적용되는 코드는 16일차 과제에서 작성했다.
shimmer 이후에 데이터가 부드럽게 전환되도록 animate_do
패키지의 FadeIn
을 사용했는데 기존의 그리드 뷰 내의 Card
위젯을 이 애니메이션 위젯으로 감쌌다.
문제에서 주어진 대로 duration
속성을 Duration(seconds: 2)
로 설정했다.
먼저 main.dart
의 main
함수에서 Hive를 초기화 했다.
Homepage
에서는 각 요소의 좋아요 여부를 저장할 likes
리스트를 만들고, getData
에서 초기화 해줬는데 박스에 저장된 데이터를 likes
에 저장했다. 기존에 저장된 좋아요 길이보다 네트워크의 데이터가 많아졌을 경우 addAll()
을 사용하여 데이터의 길이만큼 likes
에 false
를 추가했다.
앱바의 하트 버튼을 클릭하면 showModalBottomSheet
으로 하단 시트를 보여준다. 여기서는 리스트 뷰로 좋아요가 눌린 데이터들만 출력해 준다.
각 카드의 하트 아이콘은 클릭하면 좋아요 표시가 되며 붉은색으로 변경된다. 이 기능은 하트 아이콘에 GestureDetector
를 감싸 onTap
이벤트로 작성했으며 likes
리스트의 자신의 위치의 값을 변경하는 것으로 구현했다. 여기서 아이콘이 클릭될 때마다 Hive 박스에 값을 저장해 준다.
앱바의 close
아이콘 버튼을 누르면 기존의 likes
리스트의 값을 모두 false
로 변경해 좋아요를 취소했다. 그리고 likes
의 값을 Hive 박스에 저장했다.
FutureBuilder
에 데이터를 불러오는 함수를 연결하지 않고 결과인 result
를 연결했기 때문에 setState()
가 호출되어도 데이터를 새로 받아오지 않고 화면만 다시 그려준다.
(앱을 종료하고 실행했을 때 좋아요가 유지되는 것을 캡처하지 않았지만 해당 기능도 정상적으로 잘 작동한다.)
Hero
위젯은 한 화면에서 다른 화면으로 넘어갈 때 위젯에 애니메이션 효과를 줄 수 있다.
이렇게 두 화면을 이어주는 시각적 연결 고리를 만드는 순서는 아래와 같다.
- 같은 이미지를 보여주는 2개의 화면을 만든다.
- 첫 번째 화면에 Hero 위젯을 추가한다.
- 두 번째 화면에 Hero 위젯을 추가한다.
단, 두 화면의 Hero
위젯에 같은 tag
를 설정해 주어야 한다.
자세한 사용 예시는 아래의 공식 문서를 참고
화면을 넘나드는 위젯 애니메이션 - Flutter
Hive
는 가볍고 빠른 NoSQL
데이터베이스이다. 기본 유형의 타입과 List
Map
DateTime
Uint8List
를 지원한다. 그리고 다른 오브젝트를 저장하려면 TypeAdapter
를 등록해야 한다.
우선 Hive
를 사용하기 위해서는 아래와 같은 패키지들이 필요하다.
- hive
- Hive 패키지
- hive_flutter
- flutter에서 쉽게 Hive를 만들 수 있도록 해준다.
- hive_generator
- TypeAdapter를 자동으로 만들어 준다.
- build_runner
- Dart 코드 생성 및 모듈식 컴파일을 위한 빌드 도구
아래와 같이 pubspec.yaml
을 작성하여 패키지를 설치한다.
dependencies:
hive: ^[version]
hive_flutter: ^[version]
dev_dependencies:
hive_generator: ^[version]
build_runner: ^[version]
기본적인 Hive 메소드들은 아래와 같다.
void main() async {
await Hive.initFlutter();
runApp(MyApp());
}
var box = await Hive.openBox<E>('testBox');
var box = await Hive.openBox('myBox');
await box.put('hello', 'world');
await box.close(); // Box 닫기
var box = Hive.box('myBox');
box.put('name', 'Paul');
box.put('friends', ['Dave', 'Simon', 'Lisa']);
box.put(123, 'test');
box.putAll({'key1': 'value1', 42: 'life'});
var box = Hive.box('myBox');
String name = box.get('name');
DateTime birthday = box.get('birthday');
box.delete('key'); // 삭제
이번 과제에서는 기본적인 데이터 타입을 사용하여 Hive를 사용했기 때문에 위의 내용만 사용했다.
만약 Custom Object를 만들어 사용한다면 TypeAdapter를 생성해야 한다. 이와 관련된 내용과 더 많은 Hive에 대한 정보는 아래의 링크를 참고
Flutter Hive 라이브러리
4주차 도전과제를 마쳤다. 16일차 과제에서도 후기에 코드가 약간 복잡한 느낌이라 맘에 들지 않는다고 썼었는데 이걸 이용해 업그레이드를 해보니 기능은 모두 구현했지만 역시 깔끔하지는 않은 것 같다. ㅎㅎㅎㅎ home_page.dart 파일이 코드가 300줄 정도...? ㅋㅋㅋㅋ 아무래도 각 데이터를 출력했던 Card와 BottomSheet는 다른 파일의 커스텀 위젯으로 생성했으면 더 좋지 않았을까 생각된다. 이를 적용해서 다시 코드를 짜봐야겠다. 일단 포스팅은 여기서 마무리입니다!!