[Flutter] 스나이퍼팩토리 4주차 주간평가 : 비밀 앱 작성 (SNS)

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

스나이퍼팩토리 플러터 4주차 주간평가

비밀 앱 작성 (SNS)

1. 비밀 앱 기반 요구사항

  • 주어진 패키지를 활용하여 비밀공유 앱을 제작합니다.
  • 구현이 되어야 하는 기능은 다음과 같습니다.
    • BottomSheet
    • Drawer
    • FAB
    • 밑으로 당겨서 새로고침 기능
    • 비밀 수 만큼 생성되는 커스텀 위젯(SecretCard) 생성
  • 이 때 사용된 의존성 패키지는 다음과 같습니다.
dependencies:
  flutter:
    sdk: flutter
  animated_bottom_navigation_bar: ^1.1.0+1
  cupertino_icons: ^1.0.2
  font_awesome_flutter: ^10.4.0
  intl: ^0.18.0
  pull_to_refresh: ^2.0.0
  secret_cat_sdk: ^0.0.5+2
  • 커스텀 위젯(SecretCard)의 조각코드가 제공됩니다. 다음의 코드를 필수로 사용하세요.
class SecretCard extends StatelessWidget {
  const SecretCard({super.key, required this.secret});
  final Secret secret;

  
  Widget build(BuildContext context) {
    ...

2. 결과물 예시

다음의 앱을 똑같이 구현하세요. 비밀듣는 고양이 API를 활용하여 다음과 같이 만들 수 있습니다.

3. 코드 작성

  • pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  animated_bottom_navigation_bar: ^1.1.0+1
  cupertino_icons: ^1.0.2
  font_awesome_flutter: ^10.4.0
  intl: ^0.18.0
  pull_to_refresh: ^2.0.0
  secret_cat_sdk: ^0.0.6

요구사항에 주어진 패키지들을 설치했다. secret_cat_sdk는 버전이 변경되어 ^0.0.6으로 작성했다.

  • main.dart
import 'package:flutter/material.dart';
import 'package:secret_app/page/main_page.dart';

void main() {
  runApp(const SecretApp());
}

class SecretApp extends StatelessWidget {
  const SecretApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(fontFamily: 'Neo'), //전체 폰트 적용
      home: MainPage(), //메인 페이지 호출
    );
  }
}

main.dart에서는MaterialApp으로 MainPage()를 호출한다. [Flutter] 스나이퍼팩토리 18일차에서 설정한 Neo 폰트를 전체 폰트로 설정하여 앱을 제작해 보았다.

  • main_page.dart
import 'package:flutter/material.dart';
import 'package:secret_app/screen/author_screen.dart';
import 'package:secret_app/screen/secret_screen.dart';
import 'package:secret_cat_sdk/api/api.dart';
import 'package:animated_bottom_navigation_bar/animated_bottom_navigation_bar.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';

class MainPage extends StatefulWidget {
  const MainPage({super.key});

  
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  var _bottomNavIndex = 0; //하단 네비게이션 바 인덱스
  final _textEditingController = TextEditingController(); //텍스트 필드 컨트롤러

  List<Widget> _screens = [
    SecretScreen(), //비밀 스크린
    AuthorScreen(), //작성자 스크린
  ];

  
  void dispose() {
    _textEditingController.dispose(); //컨트롤러 해제
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: Colors.transparent,
        foregroundColor: Colors.black,
        title: const Text('비밀듣는 고양이'),
        leading: Builder(
          builder: (context) {
            return IconButton(
              icon: Icon(Icons.menu),
              onPressed: () {
                Scaffold.of(context).openDrawer(); //드로어 열기
              },
            );
          },
        ),
      ),
      drawer: Drawer(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            DrawerHeader(
              child: Text('비밀듣는 고양이 (SNS형)\n스나이퍼팩토리 교육용앱'),
            ),
            ListTile(
              title: Text('Teddy'),
              leading: FaIcon(FontAwesomeIcons.dev),
            ),
          ],
        ),
      ),
      body: _screens[_bottomNavIndex], // 하단 바의 인덱스에 맞는 스크린 출력
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
      floatingActionButton: FloatingActionButton(
        backgroundColor: Colors.deepOrange,
        onPressed: (() {
          //FAB을 클릭 시 bottom sheet 띄우기
          showModalBottomSheet(
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(16),
            ),
            isScrollControlled: true,
            context: context,
            builder: ((BuildContext context) {
              return Padding(
                // 키보드에 따라 bottomSheet이 같이 올라오게 설정하기 위한 padding
                padding: EdgeInsets.fromLTRB(
                  16,
                  16,
                  16,
                  MediaQuery.of(context).viewInsets.bottom,
                ),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    Center(child: Text('비밀을 공유해볼까요?')),
                    SizedBox(height: 16),
                    //비밀 입력 필드
                    TextField(
                      controller: _textEditingController, //컨트롤러 연결
                      decoration: InputDecoration(
                        hintText: '비밀을 입력하세요',
                        border: OutlineInputBorder(
                          borderRadius: BorderRadius.circular(16),
                        ),
                      ),
                    ),
                    SizedBox(height: 16),
                    ElevatedButton(
                      style: ElevatedButton.styleFrom(
                        backgroundColor: Colors.deepOrange,
                      ),
                      onPressed: () async {
                        if (_textEditingController.text != '') {
                          var secret = await SecretCatApi.addSecret(
                              _textEditingController.text); // 비밀 업로드
                          if (secret != null) {
                            _textEditingController.text = '';
                            Navigator.pop(context); //bottom sheet 닫기
                          }
                        }
                      },
                      child: Text('공유하기'),
                    ),
                    SizedBox(height: 16),
                  ],
                ),
              );
            }),
          );
        }),
        child: Icon(Icons.add),
      ),
      bottomNavigationBar: AnimatedBottomNavigationBar(
        activeIndex: _bottomNavIndex, //인덱스 변수 연결
        gapLocation: GapLocation.center,
        onTap: ((index) =>
            setState(() => _bottomNavIndex = index)), // 하단 바 아이템 클릭 시 인덱스 변경
        icons: [
          FontAwesomeIcons.cat, //고양이 아이콘
          FontAwesomeIcons.peopleGroup, //그룹 아이콘
        ],
      ),
    );
  }
}

MainPage에서는 우선 하단 바의 인덱스를 저장할 _bottomNavIndex를 0으로 선언했고, 텍스트 필드에 사용할 컨트롤러도 선언했다.

_screens는 하단 바의 아이템에 따라 바뀔 두 가지 스크린을 저장한 리스트이다. 각 스크린은 뒤에서 다른 파일로 작성한다.

본문에서는 앱바를 만들고 leadingIconButton을 만들었는데 드로어를 오픈하는 기능을 넣기 위해 Builder를 사용해 새로운 context를 만들,고 버튼의 onPressed에 드로어를 여는 기능을 작성했다.

드로어는 간단하게 DrawerHeader에 앱에 대한 정보를 작성하고 아래에는 주어진 예시와 같이 리스트 타일로 UI만 잡아 정보를 출력했다.

본문에서는 앞서 생성한 _screens 리스트에 하단 바의 인덱스인 _bottomNavIndex를 사용하여 화면을 출력했다.

FAB은 onPressed 이벤트에서 Bottom Sheet을 띄우기 위해 showModalBottomSheet을 사용했고, isScrolled: true 설정과 paddindbottomMediaQuery.of(context).viewInsets.bottom로 설정하여 키보드와 함께 올라오도록 했다.

내부의 내용은 Column으로 타이틀 텍스트와, 비밀을 입력할 필드, 버튼을 만들었다. 버튼은 onPressed 이벤트에서 비밀을 업로드하는 작업을 수행하고 업로드가 성공했으면 텍스트 필드의 텍스트를 지우고, Bottom Sheet을 닫아 주었다.

마지막으로 하단 바는 AnimatedBottomNavigationBar를 사용했고, onTap 이벤트로 setState() 를 호출하여 하단 바의 인덱스를 수정해 주었다.

  • secret_screen.dart
import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:secret_app/widget/SecretCard.dart';
import 'package:secret_cat_sdk/secret_cat_sdk.dart';

class SecretScreen extends StatefulWidget {
  const SecretScreen({super.key});

  
  State<SecretScreen> createState() => _SecretScreenState();
}

class _SecretScreenState extends State<SecretScreen> {
  late Future result; // 데이터 불러오기 결과
  RefreshController refreshController = RefreshController(); //새로고침 컨트롤러

  //새로고침 기능
  onRefresh() {
    setState(() {
      result = SecretCatApi.fetchSecrets(); //데이터 불러오기
    });
    refreshController.refreshCompleted();
  }

  
  void initState() {
    super.initState();
    result = SecretCatApi.fetchSecrets(); //데이터 불러오기
  }

  
  void dispose() {
    refreshController.dispose(); //컨트롤러 해제
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: result, //불러온 데이터 연결
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return SmartRefresher(
            controller: refreshController, //리프레시 컨트롤러 연결
            onRefresh: onRefresh, //리프레시 핸들러 연결
            header: WaterDropHeader(),
            child: ListView.builder(
              itemCount: snapshot.data?.length, //가져온 데이터의 길이만큼 생성
              itemBuilder: ((context, index) {
                return SecretCard(secret: snapshot.data![index]); //커스텀 비밀 위젯 출력
              }),
            ),
          );
        }
        return Center(child: CircularProgressIndicator()); //로딩
      },
    );
  }
}

SecretScreen은 저장된 비밀들을 보여주는 화면이다. 먼저 불러온 결과를 저장할 변수 result와 새로고침을 제어할 refreshController를 생성했다.

새로고침 기능은 onRefresh()로 작성했는데 setState()를 호출하고 결과를 다시 불러와 저장한다.

initState()에서는 데이터를 불러와 초기화 한다.

본문에서는 FutureBuilder를 사용해 불러온 데이터를 확인하고 리스트 뷰로 출력했다. 새로고침이 가능하게 리스트 뷰는 SmartRefresher로 감싸 주었다.

리스트 뷰는 가져온 데이터를 사용해 SecretCard로 출력하는데 SecretCard는 뒤에서 작성할 커스텀 위젯이다.

  • author_screen.dart
import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:secret_cat_sdk/secret_cat_sdk.dart';

class AuthorScreen extends StatefulWidget {
  const AuthorScreen({super.key});

  
  State<AuthorScreen> createState() => _AuthorScreenState();
}

class _AuthorScreenState extends State<AuthorScreen> {
  late Future result; // 데이터 불러오기 결과
  RefreshController refreshController = RefreshController(); //새로고침 컨트롤러

//새로고침 기능
  onRefresh() {
    setState(() {
      result = SecretCatApi.fetchAuthors(); //데이터 불러오기
    });
    refreshController.refreshCompleted();
  }

  
  void initState() {
    super.initState();
    result = SecretCatApi.fetchAuthors(); //데이터 불러오기
  }

  
  void dispose() {
    refreshController.dispose(); //컨트롤러 해제
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: result, //불러온 데이터 연결
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return SmartRefresher(
            controller: refreshController, //리프레시 컨트롤러 연결
            onRefresh: onRefresh, //리프레시 핸들러 연결
            header: WaterDropHeader(),
            child: ListView.builder(
              itemCount: snapshot.data?.length, //가져온 데이터의 길이만큼 생성
              itemBuilder: ((context, index) {
                return ListTile(
                  title: Text(snapshot.data![index].username), //유저 이름 출력
                  // 유저 아미지 출력
                  leading: CircleAvatar(
                    backgroundColor: Colors.red,
                    backgroundImage:
                        NetworkImage(snapshot.data![index].avatar.toString()),
                  ),
                );
              }),
            ),
          );
        }
        return Center(child: CircularProgressIndicator()); //로딩
      },
    );
  }
}

AuthorScreenSecretScreen과 거의 코드가 같다. 다른 점은 데이터를 불러오는 것을 비밀이 아니고 작성자들의 정보를 불러오는 것이고, 본문을 다르게 그린다는 점이다.

본문의 내용은 리스트 뷰에서 리스트 타일을 사용해 작성자 이름과 이미지를 출력했다.

  • SecretCard.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:secret_cat_sdk/model/secret.dart';

class SecretCard extends StatelessWidget {
  const SecretCard({super.key, required this.secret});
  final Secret secret; //비밀 데이터

  
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.all(8.0),
      padding: const EdgeInsets.all(8.0),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          ListTile(
            title: Text(secret.author?.name ?? '익명의 누군가'), // 작성자 이름
            subtitle:
                Text(DateFormat('E, M/dd').format(secret.createdAt)), //작성 시간
            // 작성자 이미지
            leading: CircleAvatar(
              backgroundColor: Colors.black12,
              backgroundImage: secret.author != null
                  ? NetworkImage(secret.author!.avatar.toString())
                  : null,
            ),
          ),
          //구분 선
          const Divider(
            thickness: 1,
            indent: 8,
            endIndent: 8,
            height: 1,
          ),
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Text(secret.secret), // 비밀 텍스트 출력
          ),
        ],
      ),
    );
  }
}

SecretCardSecretScreen에서 데이터를 출력할 때 사용한 커스텀 위젯이다. 매개변수로 비밀에 대한 데이터인 secret을 받는다.

Container 안에서 Column을 사용해 데이터를 출력했으며 작성자의 이름과 이미지, 작성 시간은 ListTile로 보여주고 아래에 구분선을 넣은 뒤 Text로 작성한 비밀을 출력해 주었다.

4. 결과

5. 추가 내용 정리

Builder

Builder 클래스는 내부 위젯들을 새로운 위젯으로 강제적으로 만들며 그 부모의 context로 접근가능하게 만들어줍니다.

위의 코드에서는 앱바의 아이콘 버튼의 클릭으로 드로어를 열 수 있게 하려면 Scaffold.of(context).openDrawer()를 사용해야 하는데 Builder로 감싸 새로 context 를 만들어야 부모의 Scaffold에 접근이 가능하다.

이러한 방식은 드로어를 여는 것 이외에도 다양한 모달 창을 띄울 때 사용해야 되는 방식이다.

또는 해당 부분을 다른 파일을 사용해 새로운 Widget class를 구현하는 것은 재사용성 측면에서 매우 뛰어날뿐만 아니라 가독성과 깔끔함까지 같이 가져갈 수 있어 더 좋을 수 있다.

showModalBottomSheet

ModalBottomSheet는 사용자가 버튼을 클릭하면 뒤에 있는 내용을 가리는 하단 시트를 표시하는 데 사용한다.

이러한 하단 시트를 사용할 때 과제 처럼 키보드를 사용하게 된다면 하단 시트가 키보드 위로 올라가야 한다. 이를 위한 설정이 필요한데 아래 코드 처럼
isScrollControlledtrue로 설정해야 하고, 내부 요소를 Padding 으로 감싸 bottomMediaQuery.of(context).viewInsets.bottom의 패딩 값을 주어야 한다.

showModalBottomSheet(
    isScrollControlled: true,
    context: context,
    builder: ((BuildContext context) {
      return Padding(
        padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
        child: Column(

MediaQuery.of(context).viewInsets

MediaQuery.of(context).viewInsets는 시스템 UI에 의해 가려지는 부분의 크기를 받아온다.

즉, 위의 코드에서 처럼 키보드에 의해 가려지는 부분의 크기를 구하려면 MediaQuery.of(context).viewInsets.bottom을 사용하면 되고, Bottom Sheet에 아래쪽 패딩 값으로 이 값을 주게 되면 키보드에 따라 Bottom Sheet가 올라가도록 적용할 수 있는 것이다.


4주차 주간평가는 크게 어렵지는 않았다.

4주차 주간평가는 기존에 사용했던 위젯들이 많이 나왔다. 특히, 18일차에서 사용한 비밀 고양이 API를 똑같이 사용했기 때문에 큰 어려움 없이 해결할 수 있었다. 과제를 진행하면서 만든 결과는 원하는대로 잘 나왔지만 코드가 최적의 코드인지는 잘 모르겠다... 길이라던지 아니면 좀 더 효율적으로 짤 수는 없을지... 뭔가 더 좋은 코드를 만들 수 있을 것 같긴 한데 나중에 시간이 남으면 다시 수정해 봐야겠다.(우선은 4주차 도전과제 부터 하러 가겠습니다 ㅠㅠ) 주간 과제는 일단 여기서 마무리 ㅎㅎ

📄 Reference

profile
관우로그

0개의 댓글

관련 채용 정보