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
class SecretCard extends StatelessWidget {
const SecretCard({super.key, required this.secret});
final Secret secret;
Widget build(BuildContext context) {
...
다음의 앱을 똑같이 구현하세요. 비밀듣는 고양이 API를 활용하여 다음과 같이 만들 수 있습니다.
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
으로 작성했다.
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 폰트를 전체 폰트로 설정하여 앱을 제작해 보았다.
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
는 하단 바의 아이템에 따라 바뀔 두 가지 스크린을 저장한 리스트이다. 각 스크린은 뒤에서 다른 파일로 작성한다.
본문에서는 앱바를 만들고 leading
에 IconButton
을 만들었는데 드로어를 오픈하는 기능을 넣기 위해 Builder
를 사용해 새로운 context
를 만들,고 버튼의 onPressed
에 드로어를 여는 기능을 작성했다.
드로어는 간단하게 DrawerHeader
에 앱에 대한 정보를 작성하고 아래에는 주어진 예시와 같이 리스트 타일로 UI만 잡아 정보를 출력했다.
본문에서는 앞서 생성한 _screens
리스트에 하단 바의 인덱스인 _bottomNavIndex
를 사용하여 화면을 출력했다.
FAB은 onPressed
이벤트에서 Bottom Sheet을 띄우기 위해 showModalBottomSheet
을 사용했고, isScrolled: true
설정과 paddind
의 bottom
을 MediaQuery.of(context).viewInsets.bottom
로 설정하여 키보드와 함께 올라오도록 했다.
내부의 내용은 Column
으로 타이틀 텍스트와, 비밀을 입력할 필드, 버튼을 만들었다. 버튼은 onPressed
이벤트에서 비밀을 업로드하는 작업을 수행하고 업로드가 성공했으면 텍스트 필드의 텍스트를 지우고, Bottom Sheet을 닫아 주었다.
마지막으로 하단 바는 AnimatedBottomNavigationBar
를 사용했고, onTap
이벤트로 setState()
를 호출하여 하단 바의 인덱스를 수정해 주었다.
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
는 뒤에서 작성할 커스텀 위젯이다.
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()); //로딩
},
);
}
}
AuthorScreen
은 SecretScreen
과 거의 코드가 같다. 다른 점은 데이터를 불러오는 것을 비밀이 아니고 작성자들의 정보를 불러오는 것이고, 본문을 다르게 그린다는 점이다.
본문의 내용은 리스트 뷰에서 리스트 타일을 사용해 작성자 이름과 이미지를 출력했다.
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), // 비밀 텍스트 출력
),
],
),
);
}
}
SecretCard
는 SecretScreen
에서 데이터를 출력할 때 사용한 커스텀 위젯이다. 매개변수로 비밀에 대한 데이터인 secret
을 받는다.
Container
안에서 Column
을 사용해 데이터를 출력했으며 작성자의 이름과 이미지, 작성 시간은 ListTile
로 보여주고 아래에 구분선을 넣은 뒤 Text
로 작성한 비밀을 출력해 주었다.
Builder 클래스는 내부 위젯들을 새로운 위젯으로 강제적으로 만들며 그 부모의 context로 접근가능하게 만들어줍니다.
위의 코드에서는 앱바의 아이콘 버튼의 클릭으로 드로어를 열 수 있게 하려면 Scaffold.of(context).openDrawer()
를 사용해야 하는데 Builder
로 감싸 새로 context
를 만들어야 부모의 Scaffold
에 접근이 가능하다.
이러한 방식은 드로어를 여는 것 이외에도 다양한 모달 창을 띄울 때 사용해야 되는 방식이다.
또는 해당 부분을 다른 파일을 사용해 새로운 Widget class를 구현하는 것은 재사용성 측면에서 매우 뛰어날뿐만 아니라 가독성과 깔끔함까지 같이 가져갈 수 있어 더 좋을 수 있다.
ModalBottomSheet는 사용자가 버튼을 클릭하면 뒤에 있는 내용을 가리는 하단 시트를 표시하는 데 사용한다.
이러한 하단 시트를 사용할 때 과제 처럼 키보드를 사용하게 된다면 하단 시트가 키보드 위로 올라가야 한다. 이를 위한 설정이 필요한데 아래 코드 처럼
isScrollControlled
를 true
로 설정해야 하고, 내부 요소를 Padding
으로 감싸 bottom
에 MediaQuery.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
는 시스템 UI에 의해 가려지는 부분의 크기를 받아온다.
즉, 위의 코드에서 처럼 키보드에 의해 가려지는 부분의 크기를 구하려면 MediaQuery.of(context).viewInsets.bottom
을 사용하면 되고, Bottom Sheet에 아래쪽 패딩 값으로 이 값을 주게 되면 키보드에 따라 Bottom Sheet가 올라가도록 적용할 수 있는 것이다.
4주차 주간평가는 기존에 사용했던 위젯들이 많이 나왔다. 특히, 18일차에서 사용한 비밀 고양이 API를 똑같이 사용했기 때문에 큰 어려움 없이 해결할 수 있었다. 과제를 진행하면서 만든 결과는 원하는대로 잘 나왔지만 코드가 최적의 코드인지는 잘 모르겠다... 길이라던지 아니면 좀 더 효율적으로 짤 수는 없을지... 뭔가 더 좋은 코드를 만들 수 있을 것 같긴 한데 나중에 시간이 남으면 다시 수정해 봐야겠다.(우선은 4주차 도전과제 부터 하러 가겠습니다 ㅠㅠ) 주간 과제는 일단 여기서 마무리 ㅎㅎ