[Flutter] 스나이퍼팩토리 4주차 도전하기

KWANWOO·2023년 2월 19일
0
post-thumbnail

스나이퍼팩토리 플러터 4주차 도전과제

16일차 과제 업그레이드

16일차 과제로 진행했던 강아지 사진 앱을 업그레이드 하고자 한다.

16일차 과제 링크
[Flutter] 스나이퍼팩토리 16일차

1.추가할 기능

아래와 같은 6개의 기능을 추가하고자 한다.

  1. AppBar title을 누르면 GridView의 스크롤이 최상단으로 이동합니다.
    - (단 애니메이션이 적용되어 부드럽게 올라가야합니다.)

  2. 앱을 시작할 때 뜨는 Splash 화면 적용해봅시다
    - 첨부된 강아지 사진을 이용해도 좋고 다른파일을 이용해도 좋습니다.

  3. 좋아요 아이콘 옆 댓글 아이콘을 누르면 새로운 페이지로 넘어가게되고, Hero 애니메이션을 적용하여 사진이 같이 이동합니다.

  4. pull to refresh하면 shimmer가 나오고 그 후 데이터가 불러오면 부드럽게 전환이 되게 합시다.
    - Duration은 2초로 설정해주세요.

  5. 좋아요를 누르면 회색하트가 빨간색으로 변합니다. 우측상단 하트를 누르면 bottomSheet가 등장하여 좋아요 리스트를 볼 수 있습니다. 또한 앱을 종료 후 재실행 하더라도, 좋아요 한 것은 유지가 됩니다.
    - (패키지 Hive 이용)

  6. 우측 상단의 X 를 누르면 좋아요가 모두 삭제가 됩니다. 단 하트가 빨간색에서 회색으로만 바뀌어야하고 화면 모두 새로고침이 되면 안됩니다.

2. 코드 작성

-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 패키지 사용을 위해 필요
  • main.dart
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(), // 스플래시 화면 호출
    );
  }
}
  • splash_screen.dart
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'),
        ),
      ),
    );
  }
}
  • home_page.dart
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),
      ),
    );
  }
}
  • comment_page.dart
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), //메세지
          ],
        ),
      ),
    );
  }
}
  • ShimmerBox.dart
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), // 둥근 모서리
          ),
        ),
      );
}

3. 코드 설명

16일차의 기본 코드 설명은 아래 링크에서 확인 가능하다.

[Flutter] 스나이퍼팩토리 16일차

해당 포스팅에서는 추가한 기능들을 어떤 방식으로 구현했는지에 대해서만 간단히 설명하고자 한다.

1) AppBar title을 누르면 GridView의 스크롤이 최상단으로 이동

우선 ScrollController를 생성하여 그리드 뷰의 컨트롤러로 설정했다.

앱바의 title에서 기존에 사용했던 TextGestureDetector로 감싸 onTap 이벤트에 scrollController를 사용해 상단으로 이동하는 기능을 작성했다. 부드럽게 상단으로 이동해야 하므로 animatedTo()메소드를 사용했다.

2) 앱을 시작할 때 뜨는 Splash 화면 적용

Splash 화면을 적용하는 방법에는 여러가지가 있는데 검색을 통해 찾아보니 주로 flutter_native_splash패키지를 사용하는 것 같았다.

하지만 이번에는 해당 패키지를 사용하지 않고 따로 splash_screen.dart 파일로 스플래시 화면을 만들고 main.dart에서 호출해 주었다.

SplashScreeninitState()에서 Timer()를 사용해 1초 뒤에 자동으로 HomePage로 이동하도록 설정했다.

3) 좋아요 아이콘 옆 댓글 아이콘을 누르면 새로운 페이지로 넘어가게되고, Hero 애니메이션을 적용하여 사진이 같이 이동

기존의 코드에서 이미지에 Hero 위젯을 감싸고 tag로는 그리드 뷰의 index를 사용하였다.

코멘트 아이콘 버튼의 onPressed에서 상세 이미지 페이지로 이동하도록 했으며 상세 페이지는 comment_page.dart 파일로 작성했다.

CommentPage에서는 이미지 url과 메세지와 히어로 태그를 전달 받는다. 이를 사용해 화면을 구성하는데 역시 이미지는 Hero 위젯으로 감싸고 tag를 전달받은 값으로 설정해 주었다.

4) pull to refresh하면 shimmer가 나오고 그 후 데이터가 불러오면 부드럽게 전환

pull to refresh를 했을 때 shimmer가 적용되는 코드는 16일차 과제에서 작성했다.

shimmer 이후에 데이터가 부드럽게 전환되도록 animate_do 패키지의 FadeIn을 사용했는데 기존의 그리드 뷰 내의 Card 위젯을 이 애니메이션 위젯으로 감쌌다.

문제에서 주어진 대로 duration 속성을 Duration(seconds: 2)로 설정했다.

5) 좋아요를 누르면 회색하트를 빨간색으로 변경, 우측상단 하트를 누르면 bottomSheet가 등장하여 좋아요 리스트를 출력, 앱을 종료 후 재실행 하더라도, 좋아요 한 것은 유지

먼저 main.dartmain 함수에서 Hive를 초기화 했다.

Homepage에서는 각 요소의 좋아요 여부를 저장할 likes리스트를 만들고, getData에서 초기화 해줬는데 박스에 저장된 데이터를 likes에 저장했다. 기존에 저장된 좋아요 길이보다 네트워크의 데이터가 많아졌을 경우 addAll()을 사용하여 데이터의 길이만큼 likesfalse를 추가했다.

앱바의 하트 버튼을 클릭하면 showModalBottomSheet으로 하단 시트를 보여준다. 여기서는 리스트 뷰로 좋아요가 눌린 데이터들만 출력해 준다.

각 카드의 하트 아이콘은 클릭하면 좋아요 표시가 되며 붉은색으로 변경된다. 이 기능은 하트 아이콘에 GestureDetector를 감싸 onTap 이벤트로 작성했으며 likes리스트의 자신의 위치의 값을 변경하는 것으로 구현했다. 여기서 아이콘이 클릭될 때마다 Hive 박스에 값을 저장해 준다.

6) 우측 상단의 X 를 누르면 좋아요가 모두 삭제

앱바의 close 아이콘 버튼을 누르면 기존의 likes 리스트의 값을 모두 false로 변경해 좋아요를 취소했다. 그리고 likes의 값을 Hive 박스에 저장했다.

FutureBuilder에 데이터를 불러오는 함수를 연결하지 않고 결과인 result를 연결했기 때문에 setState()가 호출되어도 데이터를 새로 받아오지 않고 화면만 다시 그려준다.

4. 결과

(앱을 종료하고 실행했을 때 좋아요가 유지되는 것을 캡처하지 않았지만 해당 기능도 정상적으로 잘 작동한다.)

5. 추가 내용 정리

Hero

Hero위젯은 한 화면에서 다른 화면으로 넘어갈 때 위젯에 애니메이션 효과를 줄 수 있다.

이렇게 두 화면을 이어주는 시각적 연결 고리를 만드는 순서는 아래와 같다.

  1. 같은 이미지를 보여주는 2개의 화면을 만든다.
  2. 첫 번째 화면에 Hero 위젯을 추가한다.
  3. 두 번째 화면에 Hero 위젯을 추가한다.

단, 두 화면의 Hero 위젯에 같은 tag를 설정해 주어야 한다.

자세한 사용 예시는 아래의 공식 문서를 참고
화면을 넘나드는 위젯 애니메이션 - Flutter

Hive

Hive는 가볍고 빠른 NoSQL 데이터베이스이다. 기본 유형의 타입과 List Map DateTime Uint8List를 지원한다. 그리고 다른 오브젝트를 저장하려면 TypeAdapter를 등록해야 한다.

우선 Hive를 사용하기 위해서는 아래와 같은 패키지들이 필요하다.

  1. hive
    • Hive 패키지
  2. hive_flutter
    • flutter에서 쉽게 Hive를 만들 수 있도록 해준다.
  3. hive_generator
    • TypeAdapter를 자동으로 만들어 준다.
  4. 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());
}
  • Box 열기
var box = await Hive.openBox<E>('testBox');
  • Box 닫기
var box = await Hive.openBox('myBox');
await box.put('hello', 'world');
await box.close(); // Box 닫기
  • 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'});
  • Box 읽기
var box = Hive.box('myBox');
String name = box.get('name');
DateTime birthday = box.get('birthday');
  • Box 삭제
box.delete('key'); // 삭제

이번 과제에서는 기본적인 데이터 타입을 사용하여 Hive를 사용했기 때문에 위의 내용만 사용했다.

만약 Custom Object를 만들어 사용한다면 TypeAdapter를 생성해야 한다. 이와 관련된 내용과 더 많은 Hive에 대한 정보는 아래의 링크를 참고
Flutter Hive 라이브러리


4주차 도전과제도 끝...

4주차 도전과제를 마쳤다. 16일차 과제에서도 후기에 코드가 약간 복잡한 느낌이라 맘에 들지 않는다고 썼었는데 이걸 이용해 업그레이드를 해보니 기능은 모두 구현했지만 역시 깔끔하지는 않은 것 같다. ㅎㅎㅎㅎ home_page.dart 파일이 코드가 300줄 정도...? ㅋㅋㅋㅋ 아무래도 각 데이터를 출력했던 Card와 BottomSheet는 다른 파일의 커스텀 위젯으로 생성했으면 더 좋지 않았을까 생각된다. 이를 적용해서 다시 코드를 짜봐야겠다. 일단 포스팅은 여기서 마무리입니다!!

📄 Reference

profile
관우로그

0개의 댓글

관련 채용 정보