Flutter | มาลองทำแอป Icon Showcase || Part Extra Bloc+Animation

Created on Jan 09, 2020

พาร์ทอื่นๆ

กลับมาอีกรอบกับ Icon Showcase โดยรอบนี้เราจะมาลองเพิ่มการจัดการข้อมูลแบบ BloC กับ อนิเมชั่น เข้ามาในแอปของเรา จึงตั้งโจทย์ไว้ว่า เราจะสามารถเพิ่มลด Icon ใน List ของเราได้ โดยจัดการข้อมูลเหล่านี้ด้วย BloC และเมื่อเพิ่มหรือลบ Icon ออกไปจะมีอนิเมชั่นการเพิ่มหรือลบออกไปด้วย เป็น Scale กับ Fade แบบง่ายๆ นอกเหนือจากนั้นแอปเรายังสามารถทำงานได้เหมือนก่อนหน้า
ในงานนี้ เราก็จะมาแนะนำ ให้รู้จัก 2 อย่าง
  • Flutter Bloc
  • AnimatedList
เราจะเอาโค๊ดเก่ามาต่อยอด สามารถโหลดได้จากข้างล่างเลย
หรืออยากกลับไปอ่านในพาร์ทก่อนหน้าก็กดลิงค์ข้างล่างได้
ถ้าพร้อมแล้วก็ไปกันต่อเลย
⚠️⚠️⚠️ (อัพเดท 8/7/2020) ถ้าใครใช้ flutter_bloc เวอร์ชั่นตั้งแต่ 1.0.0 ขึ้นไป จะมีการเปลี่ยนชื่อ Api บางส่วนดังนี้
bloc.state.listen -> bloc.listen
bloc.currentState -> bloc.state
bloc.dispatch -> bloc.add
bloc.dispose -> bloc.close
สามารถอ่านเพิ่มเติมได้จาก https://link.medium.com/qnfMcEcW00
ดังนั้นตอนทำตาม อาจใช้เวอร์ชั่นที่ระบุไว้ไปก่อนได้ หลักการทำงานของ flutter_bloc ยังเหมือนเดิม

เรามาดูในส่วนของ Bloc ก่อน

หรือว่าอีกอย่างคือ มาสร้างวิธีจัดการกับข้อมูล
สร้างโฟลเดอร์ใหม่ชื่อ
bloc

Event

สร้างไฟล์ไว้จัดการ event
bloc/iconlist_event.dart
event ของเรามีเพียง 3 อย่าง คือ โหลดค่าตั้งต้น(LoadDefaultIcon) เพิ่ม item(AddIcon) และ ลบ item(RemoveIcon)
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
@immutable
abstract class IconlistEvent extends Equatable {
IconlistEvent([List props = const[]]) : super(props);
}
class LoadDefaultIcon extends IconlistEvent{
@override
String toString() => 'LoadDefaultIcon';
}
class AddIcon extends IconlistEvent{
@override
String toString() => 'AddIcon';
}
class RemoveIcon extends IconlistEvent{
final int removeIndex;
RemoveIcon(this.removeIndex);
@override
String toString() => 'RemoveIcon';
}
LoadDefaultIcon กับ AddIcon เราไม่จำเป็นต้องรับพารามีเตอร์เข้ามาเพราะเราจะสุ่มเอา ส่วน RemoveIcon จะรับตำแหน่งที่จะลบออก(เพื่อความไม่ซับซ้อน ตำแหน่งที่จะลบ จะลบอันสุดท้ายเสมอ)

State

สร้างไฟล์ไว้จัดการ state ที่จะใช้ส่งไปโชว์ในแอป
bloc/iconlist_state.dart
state เราจะมี 2 สถานะ คือ กำลังโหลด(IconListLoaded) กับ โหลดเสร็จแล้ว(IconListLoading)
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:icon_showcase_animationwithbloc_part/icon_data.dart';
import 'package:meta/meta.dart';
@immutable
abstract class IconlistState extends Equatable {
IconlistState([List props = const []]) : super(props);
}
class IconListLoaded extends IconlistState {
final List<IconModel> iconList;
IconListLoaded( [this.iconList ]) : super([iconList]);
@override
String toString() {
return 'IconListLoaded { iconList : \$iconList}';
}
}
class IconListLoading extends IconlistState {
@override
String toString() {
return 'IconListLoading';
}
}
IconListLoaded จะสามารถส่งข้อมูล Icon List ได้ ส่วน IconListLoading ไม่จำเป็นต้องส่งข้อมูล ใช้เพื่อบอกสถานะว่ากำลังโหลดข้อมูล

Bloc

เรามาทางเชื่อมระหว่าง event ไป state ด้วย bloc
สร้างไฟล์ใหม่
bloc/iconlist_bloc.dart
เนื่องจากเรามี event 3 อัน เราเลยมีฟังก์ชั่น 3 อันที่จะเชื่อมไป State ทั้ง 2
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
import './iconlist.dart';
import 'package:icon_showcase_animationwithbloc_part/icon_data.dart'
as iconData;
import 'dart:math';
class IconlistBloc extends Bloc<IconlistEvent, IconlistState> {
@override
IconlistState get initialState => IconListLoading();
@override
Stream<IconlistState> mapEventToState(
IconlistEvent event,
) async* {
if (event is AddIcon) {
yield* _mapAddIconToState();
} else if (event is RemoveIcon) {
yield* _mapRemoveIconToState(event);
} else if (event is LoadDefaultIcon) {
yield* _mapLoadDefaultIconToState();
}
}
Stream<IconlistState> _mapAddIconToState() async* {
if (currentState is IconListLoaded) {
final newlist = iconData.newIconList();
final newIcon = newlist[Random().nextInt((newlist.length - 1))];
final List<iconData.IconModel> updatedIconList =
List.from((currentState as IconListLoaded).iconList);
updatedIconList.add(newIcon);
yield IconListLoaded(updatedIconList);
}
}
Stream<IconlistState> _mapRemoveIconToState(RemoveIcon event) async* {
if ((currentState as IconListLoaded).iconList.length != 0) {
final List<iconData.IconModel> updatedIconList =
List.from((currentState as IconListLoaded).iconList);
updatedIconList.removeAt(event.removeIndex);
yield IconListLoaded(updatedIconList);
}
}
Stream<IconlistState> _mapLoadDefaultIconToState() async* {
final iconList = iconData.iconList;
yield IconListLoaded(iconList);
}
}
ฟังก์ชั่นทั้ง 3 อัน ได้แก่
  • _mapAddIconToState() จะทำหน้าที่ สุ่มไอคอนแล้วเพิ่มเข้าไปใน Icon List
  • _mapRemoveIconToState(RemoveIcon event) จะรับตำแหน่งที่จะลบ แล้วลบออกไปจาก Icon List
  • _mapLoadDefaultIconToState() จะโหลดค่าตั้งต้นของ Icon List

รวบไฟล์ export

ตอนนี้ bloc เราครบแล้ว ตั้งแต่ event state bloc เพื่อความง่ายในการเรียกใช้ ก็รวบลง อีกไฟล์นึง แล้ว export เอา จะได้ import เข้ามาไฟล์เดียวได้
สร้างไฟล์ใหม่ชื่อ
bloc/iconlist.dart
export 'iconlist_bloc.dart';
export 'iconlist_event.dart';
export 'iconlist_state.dart';
view raw iconlist.dart hosted with ❤ by GitHub

มาทำส่วนดีไซน์กับอนิเมชั่นต่อ

จากโค๊ดเก่า เราเพิ่มมา 2 ปุ่ม เป็น FloatingActionButton ที่มีปุ่ม + กับ ปุ่ม -
เราไปดูโค๊ดใหม่ที่แก้ไขทุกอย่างแล้วเลยดีกว่า
import 'package:flutter/material.dart';
import 'package:icon_showcase_animationwithbloc_part/color_palette.dart';
import 'package:icon_showcase_animationwithbloc_part/fade_page_route.dart';
import 'package:icon_showcase_animationwithbloc_part/icon_data.dart'
as ModelIcondata;
import 'package:icon_showcase_animationwithbloc_part/detail_page.dart';
import 'package:icon_showcase_animationwithbloc_part/title_hero_flight.dart';
import 'package:icon_showcase_animationwithbloc_part/view_state.dart';
import 'package:icon_showcase_animationwithbloc_part/bloc/iconlist.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
builder: (context) => IconlistBloc()..dispatch(LoadDefaultIcon()),
child: MaterialApp(
title: 'Icon Showcase',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Icon Showcase'),
debugShowCheckedModeBanner: false,
),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
AnimationController _animationController;
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
@override
void initState() {
super.initState();
_initAnimationController();
}
void _initAnimationController() {
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 500),
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final iconListBloc = BlocProvider.of<IconlistBloc>(context);
return BlocBuilder(
bloc: iconListBloc,
builder: (BuildContext context, IconlistState state) {
if (state is IconListLoading) {
return Container(); // return a Container when app is loading data
} else if (state is IconListLoaded) {
final iconList = state.iconList;
return Scaffold(
backgroundColor: ColorPalette.grey90,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0.0,
title: Text(
widget.title,
style: TextStyle(color: ColorPalette.grey10),
),
leading: IconButton(
icon: Hero(
tag: 'menuarrow',
child: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: _animationController,
color: ColorPalette.grey10,
),
),
onPressed: () {},
),
),
body: AnimatedList(
key: _listKey,
initialItemCount: iconList.length,
itemBuilder: (context, index, animation) =>
buildCard(iconList[index], animation),
),
floatingActionButton: Padding(
padding: const EdgeInsets.only(left: 35.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
FloatingActionButton(
heroTag: 'add',
child: Icon(Icons.add),
onPressed: () {
iconListBloc.dispatch(AddIcon());
_listKey.currentState.insertItem(iconList.length);
},
backgroundColor: Colors.green,
),
SizedBox(
width: 20,
),
FloatingActionButton(
heroTag: 'remove',
child: Icon(Icons.remove),
onPressed: () {
if (iconList.length != 0) {
final removeicon = iconList.last;
final removeiconIndex = iconList.length - 1;
iconListBloc.dispatch(RemoveIcon(removeiconIndex));
_listKey.currentState.removeItem(
removeiconIndex,
(index, animation) =>
buildCard(removeicon, animation));
}
},
backgroundColor: Colors.redAccent,
)
],
),
),
);
}
});
}
Widget buildCard(ModelIcondata.IconModel iconData, Animation animation) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
child: SizeTransition(
sizeFactor: animation,
child: InkWell(
onTap: () async {
_animationController.forward(from: 0.0);
bool returnVal = await Navigator.of(context).push(FadePageRoute(
builder: (context) => DetailPage(
iconData: iconData,
)));
if (returnVal) {
_animationController.reverse(from: 1.0);
}
},
child: Stack(children: <Widget>[
Hero(
tag: iconData.cardkey,
child: Card(
color: ColorPalette.grey10,
margin: EdgeInsets.all(10),
elevation: 5.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.0)),
child: Padding(
padding: const EdgeInsets.all(30.0),
child: ListTile(
leading: Icon(
iconData.icon,
size: 45.0,
color: Colors.transparent,
),
),
)),
),
Padding(
padding: const EdgeInsets.all(40.0),
child: ListTile(
title: Hero(
tag: iconData.titlekey,
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
return DestinationTitle(
viewState:
flightDirection == HeroFlightDirection.push
? ViewState.enlarge
: ViewState.shrink,
smallFontSize: 20.0,
largeFontSize: 60.0,
title: iconData.title,
isOverflow: true,
);
},
child: DestinationTitle(
title: iconData.title,
color: Colors.black87,
viewState: ViewState.shrunk,
smallFontSize: 20.0,
largeFontSize: 60.0,
),
),
leading: Hero(
tag: iconData.iconkey,
child: Icon(
iconData.icon,
size: 45.0,
color: ColorPalette.grey60,
),
)),
),
])),
));
}
}
view raw main.dart hosted with ❤ by GitHub
มาค่อยๆดูไปทีละส่วนกัน
ตรงนี้เพราะเราใช้ Bloc เลยหุ้ม Widget ที่อยู่นอกสุดด้วย BlocProvider แล้วส่ง builder เป็น IconlistBloc() หรือ Bloc ที่เราพึ่งสร้างไป และเมื่อรันครั้งแรกที่เปิดแอป เราก็จะสั่งให้มันโหลดค่าตั้งต้น dispatch(LoadDefaultIcon())
เราเพิ่ม _listKey ไว้ใช้กับ AnimatedList
เราเพิ่มตัวแปร iconListBloc ไว้เรียก Bloc ของเรา แล้วตอนจะสร้าง Widget เราก็หุ้มไว้ด้วย BlocBuilder ที่ bloc ส่ง iconListBloc ส่วนที่ builder จะส่งฟังก์ชั่นที่สร้าง Widget ในนั้นเราก็เช็คได้ว่า เป็น State ไหน ถ้าเป็น IconListLoading เราจะโชว์ Container() เปล่าๆ (ถ้าใครจะทำจริงจัง ก็เปลี่ยนเป็นไอคอน Loading ได้) แต่ถ้าเป็น IconListLoaded จะโชว์แอปที่เราสร้างมาก่อนหน้า
เปลี่ยนจาก ListView.builder ไปใช้ AnimatedList แทน
เพื่อความเรียบร้อย และจะได้เรียกใช้ซ้ำได้ เราเอา card ของเราไปสร้างเป็นฟังก์ชั่น buildCard ซึ่งจะทำหน้าที่ return Card แล้วได้ผลลัพธ์เหมือนเดิม
ฟังก์ชั่น buildCard
เนื่องจากเป็นอนิเมชั่น เราก็เพิ่ม FadeTransition และ SizeTransition เข้าไปด้วย
ในส่วนนี้เราเพิ่มปุ่ม + และ - วางตรงกลางล่างของแอปตอนกด เราก็จะเรียก event ที่ bloc และ อัพเดท _listKey สำหรับ + _listKey เราจะเพิ่มแค่ตำแหน่งที่จะแทรกเข้า ส่วน - _listKey เราจะลบออกตามตำแหน่งที่ต้องการ นอกจากนั้นต้องส่งฟังก์ชั่นที่สร้าง Card ด้วย (buildCard) นอกจากนี้อย่าลืมตั้ง heroTag ของ FloatingActionButton ให้ต่างกันด้วย เพราะค่าตั้งต้นจะถูกตั้งให้เหมือนกัน
เพียงเท่านี้ แอปก็ออกมาเรียบร้อยแล้ว
ใครมีคำถามสงสัย ก็ส่งทิ้งไว้ข้างล่างได้เช่นเคย ไว้เจอกันใหม่ในครั้งหน้า
ใครอยากดูโค๊ดเต็มแล้ว โหลดได้จากข้างล่างเลย

แนะนำเรื่องถัดไป