12일차에서는 앞서 만들었던 앱 중 두 가지 앱을 업그레이드 할 것이다. 두 가지 앱을 각각 다른 블로깅으로 작성해서 part1과 part2로 나누었다. part1에서는 2주차 주간평가로 진행했던 키오스크 앱을 업그레이드 할 것이다.
[Flutter] 스나이퍼팩토리 2주차 주간평가 : 키오스크 앱 기반 작성
학습한 내용
- part1 : 키오스크 앱 업그레이드하기
- part2 : 연락처 앱 업그레이드하기
Chip
위젯은 Material 기본 제공 위젯으로 아래 그림과 같은 버튼 형식의 텍스트와 함께 첨부하는 데이터 또는 아이콘을 말한다.
Chip
은 라벨링을 하는데 사용하기 좋다. 기본적으로 label
속성 안의 내용물을 표시하며, avatar
, onDeleted
등의 속성이 존재한다. 아래와 같은 방식으로 사용할 수 있다.
Chip(
avatar: CircleAvatar(
backgroundColor: Colors.grey.shade800,
child: const Text('AB'),
),
label: const Text('Aaron Burr'),
)
자세한 속성은 아래의 공식문서를 참고
Chip class - material library - Flutter - Dart API docs
페이지를 이동할때 애니메이션을 적용하는 방법을 작성하고자 한다. 이러한 애니메이션은 아래의 6가지 단계를 거쳐 생성할 수 있다.
페이지 애니메이션 적용 순서
- PageRouteBuilder 설정
- Tween 생성
- AnimatedWidget 사용
- CurveTween 사용
- Tween 조합
- 전체 코드 작성
아래에서 위로 화면이 올라오는 예시를 통해 사용 방법을 알아보자 (해당 내용은 아래의 공식 문서를 참고했음)
페이지 route 전환 애니메이션 - Flutter
먼저 PageRouteBuilder
를 사용해 Route를 생성해야 한다.
PageRouteBuilder
에는 2개의 콜백이 있는데 하나는(pageBuilder
) Route에 컨텐츠를 빌드하기 위해 사용하고, 다른 하나(transitionBuilder
)는 Route의 전환을 빌드하기 위해 사용한다.
즉, pageBuilder
에서 만들어진 위젯이 transitionBuilder
의 child 매개 변수로 전달된다.
아래는 "Go" 버튼이 있는 홈 Route와 제목이 "Page2"인 두 번째 Route를 만들고 Route를 리턴하는 _createRoute()를 작성한 것이다.
import 'package:flutter/material.dart';
main() {
runApp(MaterialApp(
home: Page1(),
));
}
class Page1 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: RaisedButton(
child: Text('Go!'),
onPressed: () {
Navigator.of(context).push(_createRoute());
},
),
),
);
}
}
Route _createRoute() {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => Page2(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return child;
},
);
}
class Page2 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text('Page 2'),
),
);
}
}
새 페이지가 아래에서 위로 올라오는 애니메이션을 만들기 위해 Offset(0, 1)
에서 Offset.zero
로 애니메이션을 하도록 tween
을 생성했다.
transitionBuilder
콜백에서는 animation
매개 변수가 있는데 이 매개 변수는 0에서 1 사이의 값을 생성하는 Animation<double>
이다. Tween
을 사용하여 Animation
을 변경한다.
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var begin = Offset(0.0, 1.0);
var end = Offset.zero;
var tween = Tween(begin: begin, end: end);
var offsetAnimation = animation.drive(tween);
return child;
},
Flutter에는 애니메이션 값이 변경 될 때 다시 빌드되는 AnimatedWidet
을 확장한 여러 위젯 모음이 있는데 아래에서 사용하는 SlideTransition
은 Animation<Offset>
을 가져와 애니메이션 값이 변경될 때마다 자식을 변환한다.
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var begin = Offset(0.0, 1.0);
var end = Offset.zero;
var tween = Tween(begin: begin, end: end);
var offsetAnimation = animation.drive(tween);
return SlideTransition(
position: offsetAnimation,
child: child,
);
},
Curves
클래스에는 많이 사용되는 curve
가 정의되어 있는데 다양한 애니메이션을 제공한다.
아래와 같이 Curve
를 사용하기 위해 CurveTween
을 생성할 수 있다.
var curve = Curves.ease;
var curveTween = CurveTween(curve: curve);
chain()
을 사용하여 Tween
을 조합한다.
var begin = Offset(0.0, 1.0);
var end = Offset.zero;
var curve = Curves.ease;
var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
이렇게 조합한 Tween
을 animation.drive()
를 통해 전달한다. 이를 통해 SlideTransition
위젯에 전달할 수 잇는 새로운 Animatino<Offset>
이 생성된다.
return SlideTransition(
position: animation.drive(tween),
child: child,
);
위의 예시들을 종합해 아래와 같은 전체 코드를 작성할 수 있다.
import 'package:flutter/material.dart';
main() {
runApp(MaterialApp(
home: Page1(),
));
}
class Page1 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: RaisedButton(
child: Text('Go!'),
onPressed: () {
Navigator.of(context).push(_createRoute());
},
),
),
);
}
}
Route _createRoute() {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => Page2(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var begin = Offset(0.0, 1.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,
);
},
);
}
class Page2 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text('Page 2'),
),
);
}
}
위의 코드를 실행하면 Page1에서 "Go" 버튼을 누르면 Page2가 실행되는데 아래에서 위로 올라오는 애니메이션이 적용된다.
AnimatedOpacity
위젯을 사용하여 위젯이 나타다거나 사라질때 Fade in과 Fade out을 적용할 수 있다.
AnimatedOpacity
의 opacity
속성에서 0.0을 설정하면 보이지 않는 상태가 되고 1.0을 설정하면 완전히 보이는 상태가 된다.
아래는 AnimatedOpacity
위젯을 사용하여 _visible
값에 따라 Container
위젯을 사라지게 하거나 나타나게 하는 예시 코드이다.
AnimatedOpacity(
// If the widget is visible, animate to 0.0 (invisible).
// If the widget is hidden, animate to 1.0 (fully visible).
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
// The green box must be a child of the AnimatedOpacity widget.
child: Container(
width: 200.0,
height: 200.0,
color: Colors.green,
),
)
자세한 내용은 아래의 공식 문서를 참고
Fade a widget in and out - Flutter
- 키오스크 앱 업그레이드하기
- 추가 사항
기존에 작성했던 키오스크 앱에 대한 내용은 아래의 링크를 통해 확인할 수 있다.
[Flutter] 스나이퍼팩토리 2주차 주간평가 : 키오스크 앱 기반 작성
클릭시 메뉴가 주문 리스트에 담깁니다. 이 때 Chip 위젯으로 들어가게 됩니다.
기존의 버튼은 초기화하기였으나, 동작하지 않는 결제하기 버튼으로 바뀝니다.
주문 리스트가 비었다면, 하단의 결제하기 버튼이 표시되지 않습니다.
앱바의 분식왕 이테디 주문하기를 더블클릭하면, 관리자 페이지로 이동하게 됩니다.
결과물 예시
main.dart
import 'package:flutter/material.dart';
import 'package:my_app/AdminPage.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// root Widget
Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(title: '분식왕 이테디 주문하기'), // 홈 페이지 호출(title 매개변수로 전달)
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({super.key, required this.title});
final String title; //앱 제목
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
List<String> order = []; //주문 리스트
//음식 리스트
final List<Map> foodList = [
{'imgUrl': 'assets/images/option_bokki.png', 'name': '떡볶이'},
{'imgUrl': 'assets/images/option_beer.png', 'name': '맥주'},
{'imgUrl': 'assets/images/option_kimbap.png', 'name': '김밥'},
{'imgUrl': 'assets/images/option_omurice.png', 'name': '오므라이스'},
{'imgUrl': 'assets/images/option_pork_cutlets.png', 'name': '돈까스'},
{'imgUrl': 'assets/images/option_ramen.png', 'name': '라면'},
{'imgUrl': 'assets/images/option_udon.png', 'name': '우동'}
];
//페이지 이동 라우트 생성
Route _createRoute(Widget page) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var begin = 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 Scaffold(
//앱바
appBar: AppBar(
title: GestureDetector(
//앱바 더블탭 이벤트 (어드민 페이지 이동)
onDoubleTap: () => Navigator.of(context).push(
_createRoute(AdminPage()),
),
child: Text(widget.title),
),
centerTitle: true, //타이틀 글씨 가운데 정렬
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
elevation: 0,
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
'주문 리스트',
),
//주문할 내역 출력(없을 경우 텍스트, 있을 경우 Chip)
Expanded(
child: order.isEmpty
? Center(child: Text('주문한 메뉴가 없습니다'))
: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: order.length,
itemBuilder: ((context, index) {
return Chip(
label: Text(order[index]),
onDeleted: () => setState(() {
order.removeAt(index); // 주문 제거
}));
})),
),
SizedBox(height: 8),
Text(
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
'음식',
),
Expanded(
flex: 10,
// 음식 리스트 그리드 뷰
child: GridView.count(
crossAxisCount: 3,
children: List.generate(foodList.length, (index) {
return GestureDetector(
onTap: () => setState(() {
order.add(foodList[index]['name']);
}),
// 하나의 음식 요소
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Image(
image: AssetImage(
foodList[index]['imgUrl'],
),
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
fit: BoxFit.cover,
),
),
Text(
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
),
foodList[index]['name'].toString(),
),
Text(
style: TextStyle(
color: Colors.black,
),
'[담기]',
),
],
),
),
);
}),
),
),
],
),
),
// 하단 버튼(주문 내역이 있을 경우에만 표시)
floatingActionButton: AnimatedOpacity(
opacity: order.isNotEmpty ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: FloatingActionButton.extended(
onPressed: () {},
label: Text('결제하기'),
),
),
// 하단 버튼 위치
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
}
AdminPage.dart
import 'package:flutter/material.dart';
class AdminPage extends StatelessWidget {
const AdminPage({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Admin Page'),
centerTitle: true,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text('메뉴 추가'),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text('메뉴 삭제'),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text('메뉴 수정'),
),
],
),
);
}
}
main.dart
기존에 만든 키오스크 앱과 기반은 동일하다. MyHomePage
위젯에서 주문을 담을 order
리스트가 존재하고, 음식들의 정보를 담고 있는 foodList
가 있다.
추가로 build
전에 Route를 리턴하는 _createRoute
함수를 작성했다. 이는 어드민 페이지로 이동 시 페이지가 오른쪽에서 왼쪽으로 슬라이드 되도록 하기 위해 만들었다. 사용 방식은 위의 추가 내용 정리에서 작성했고 begin
과 end
의 Offset
만 오른쪽 위에서 왼쪽 위로 변경했다.
build
내부에서는 앱바의 title
에 더블 클릭 이벤트를 적용하기 위해 텍스트를 GestureDetector
위젯으로 감싸고 onDoubleTap
이벤트에서 앞서 작성한 _createRoute
를 사용하여 Navigator.of(context).push()
로 페이지가 이동하는 기능을 구현했다.
이외의 내용은 같고 주문 리스트의 UI를 변경했는데 우선 삼항 연산자를 사용해서 order
리스트가 비어있을 경우 "주문한 메뉴가 없습니다"를 출력하고 비어있지 않을 경우 ListView
를 그리도록 했다.
주문 내역 ListView
는 ListView.builder
를 사용해서 만들었는데 itemCount
는 주문 내역 리스트의 길이로 하고 itmeBuilder
에서는 Chip
위젯을 반환했다.
Chip
위젯에서는 label
로 주문할 메뉴 텍스트를 보여주고 onDeleted
이벤트에서 setState
를 사용해 주문한 내역을 리스트에서 삭제하고 화면을 다시 그려주도록 했다.
추가로 이번에 생성한 주문할 내역 리스트 뷰와 음식 정보들 그리드 뷰는 각각 Expanded
로 감싸져 있는데 비율을 위해 기존의 그리드 뷰를 감싼 Expanded
의 flex
값을 10 정도로 설정했다.
마지막으로 FAB의 기능을 제거하고 label
을 결제하기
로 변경했다. 여기서 버튼이 order
리스트가 비어있는지 여부에 따라 보여지는 것을 애니메이션을 적용해서 사용하기 위해 AnimatedOpacity
위젯을 사용했다.
AdminPage.dart
AdminPage
는 크게 기능은 없고 페이지 이동을 구현하기 위한 페이지이다. 간단하게 UI만 구성했으며 StatelessWidget
으로 위젯을 생성했다.
Scaffold
위젯에서 앱바를 생성하고, body
로 Coulumn
에 Padding
을 적용해 세 가지 텍스트를 출력해 주었다.
강의 수강 후 아쉬웠던 점 몇 가지를 추가로 정리하고자 한다.
파일 하나에 위젯이 하나만 있으면 더 이해하기 쉽고 보기도 편할 것 같다. 이번 과제에서는 main.dart에 MyApp
위젯과 MyHomePage
위젯 두 개가 존재한다. MyHomePage
는 다른 파일로 나눠서 작성하면 더 이해하기 쉬운 프로그램이 될 것 같다.
지금까지는 파일 이름을 모두 클래스 이름과 같게 파스칼 케이스로 작성했다. 하지만 강의를 수강하는 페이지나 스크린과 관련된 페이지는 스네이크 케이스를 사용하고 소규모 위젯의 파일 이름에만 파스칼 케이스를 작성하면 파일의 내용이 무엇을 의미하는지 쉽게 파악할 수 있을 것 같다.
예를 들어 AdminPage.dart
대신 admin_page.dart
와 같은 방식으로 파일을 생성하는 것이다. 만약 이 프로그램에서 커스텀 타일을 사용한다면 이는 소규모 위젯이므로 CustomTile.dart
파일을 생성하여 작성할 것이다.
FAB을 보여주거나 숨길 때 애니메이션 없이 없어지고 생겨서 애니메이션을 적용하기 위해 AnimatedOpacity
를 사용했었다. 하지만 boolean 값에 따라 false일 때 SizedBox
가 아닌 null을 사용하니 자동으로 Fade in Fade out 애니매이션이 적용 되었다.
아래 코드는 두 코드의 차이이다.
// 없어지거나 생길 때 애니메이션이 적용됨
floatingActionButton: order.isNotEmpty
? FloatingActionButton.extended(onPressed: () {}, label: Text('결제하기'))
: null,
// 없어지거나 생길 때 애니메이션이 적용되지 않음
floatingActionButton: order.isNotEmpty
? FloatingActionButton.extended(onPressed: () {}, label: Text('결제하기'))
: SizedBox(),
우선 키오스크 앱 업그레이드 하기를 마쳤다. Chip 위젯과 같은 경우에는 학습할 때 봐서 사용하는게 크게 어렵지 않았는데 예시에 나와 있는 화면 이동 애니메이션과 FAB의 Fade in Fade out을 하는데 조금 시간이 걸렸다.(근데 이거 애니메이션 적용까지 해야되는거 맞겠지...?) 어쨌든 아직 끝난거 아니고 하나 남았으니까 자세한 후기는 다음 포스팅에서.....
다음 포스팅은 여기로... [Flutter] 스나이퍼팩토리 12일차 part2