例えばカートにアイテムを追加した時など、ある画面で行ったアクションが他画面に反映されることがあるかと思います。その際、他の画面に変化があったことを自然に知らせるために、BottomNavigationやToolbar上のアイコンにアニメーションを取らせることがあります
例えば、Amazonならカートに商品を追加した場合、画面右上のアプリバー上のアイコンがアニメーションでカウントアップすることで、カートにアイテムが追加されアプリバー上のアイコンから遷移できることを示唆しています。それと同じようにBottomNavigationItemを以前に作ったWidgetを使ってぴょんぴょんさせてみます
注意事項
このサンプルはriverpodを使ってかなり無理やりアニメーションをトリガーしています
他にいいやり方がありましたら教えてください
riverpodのバージョン指定
flutter_riverpod: ^0.14.0+3
シンプルに実装できる場合
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => MyHomePageState();
}
class MyHomePageState extends State<MyHomePage> {
final bounceIcon = BounceContainer(
child: Icon(Icons.looks_two_rounded)
);
var screenIndex = 0;
late List<Widget> screens;
@override
void initState() {
super.initState();
screens = [
Center(
child: ElevatedButton(
onPressed: () {
bounceIcon.bounce(); // Screen1のボタンをタップするとアクション開始
},
child: Text('jump!')
)
),
Center(child: Text('Screen2'))
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
toolbarHeight: 0,
),
body: IndexedStack( // 画面を切り替えるたびに描画し直されるのを防ぐ
index: screenIndex,
children: screens,
),
bottomNavigationBar: BottomNavigationBar(
items: [
BottomNavigationBarItem(
icon: Icon(Icons.looks_one_rounded),
label: 'screen 1'
),
BottomNavigationBarItem(
icon: bounceIcon, // 自作コンテナを指定する
label: 'screen 2'
),
],
currentIndex: screenIndex,
onTap: (index) {
setState(() {
screenIndex = index;
});
},
),
);
}
}
Screen1の真ん中のボタンをタップすると、Screen2のBottomNavigationIconが飛び跳ねるようになりました
小規模なアプリであれば、riverpodなども使わず、シンプルにIconをBounceContainerでラップすれば実現可能です
riverpodを使う場合
アプリがある程度の規模になってくると上記の例では厳しそうです
BounceContainerのインスタンスを、アニメーションをトリガーする子や孫、ひ孫Widgetにどんどん渡していく必要が出てきます
それを避けるために、riverpodでグローバルにトリガーをかけるような実装にしてみます
riverpodを追加する
ProviderScopeを追加する
void main() {
runApp(
ProviderScope( // <<<<< 追加
child: MyApp()
)
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Animation Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
StateProviderでアニメーションをトリガーする
BounceContainerをConsumerWidgetでラップする
bounceItemProviderをwatchして、状態に変化があれば飛び跳ねるようにします
この実装は強引だと思います
final bounceItemProvider = StateProvider((_) => false);
class BounceItem extends ConsumerWidget {
final child = BounceContainer(child: Icon(Icons.looks_two_rounded));
@override
Widget build(BuildContext context, ScopedReader watch) {
watch(bounceItemProvider).state;
child.bounce(); // このままだと初回でanimControllerが初期化されない
return child;
}
}
しかしこのままだと、最初にWidgetが描画されるときに、BounceContainer内のanimControllerが初期化される前にアニメーションを開始しようとしてしまい、「late変数が初期化されてないよ!」と言うエラーが出ます
BounceContainerのbounce()をnullチェックする
animControllerをnullableにして、初期化されていない場合にはアニメーションをしないようにします
この実装は強引だと思います(2度目)
class BounceContainer extends StatefulWidget {
// ~~ 略 ~~
}
final _bounceUp = // ~~ 略 ~~
final _bounceDown = // ~~ 略 ~~
final _sequence = // ~~ 略 ~~
class _BounceContainerState extends State<BounceContainer> with SingleTickerProviderStateMixin {
AnimationController? animController; // nullableにする
late Animation<double> animTween;
@override
void initState() {
super.initState();
animController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 2000),
);
animTween = _sequence.animate(animController!);
}
@override
void dispose () {
animController!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animController!,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, animTween.value),
child: widget.child);
}
);
}
void animate() {
// 初回描画時、animControllerが未初期化の場合はアニメーションを実行しないようにする
if (animController != null) {
animController!.reset(); // resetしないと2回目以降に動かない
animController!.forward();
}
}
}
トリガーをかける側の実装
全体レイアウト
StateProvider経由でトリガーをかけるので、BounceContainerインスタンスの受け渡しは一切なくなりました
class MyHomePageState extends State<MyHomePage> {
final bounceIcon = BounceItem();
var screenIndex = 0;
late List<Widget> screens;
@override
void initState() {
super.initState();
screens = [
Screen1(),
Screen2()
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
toolbarHeight: 0,
),
body: IndexedStack(
index: screenIndex,
children: screens,
),
bottomNavigationBar: BottomNavigationBar(
items: [
BottomNavigationBarItem(
icon: Icon(Icons.looks_one_rounded),
label: 'screen 1'
),
BottomNavigationBarItem(
icon: bounceIcon,
label: 'screen 2'
),
],
currentIndex: screenIndex,
onTap: (index) {
setState(() {
screenIndex = index;
});
},
),
);
}
}
トリガーをかける画面側の実装
BounceItemのStateProviderを使って、状態を反転させてトリガーします
class Screen1 extends ConsumerWidget {
@override
Widget build(BuildContext context, ScopedReader watch) {
final state = watch(bounceItemProvider).state;
final controller = context.read(bounceItemProvider);
return Center(
child: ElevatedButton(
onPressed: () {
controller.state = !state; // 状態を反転させてトリガー
},
child: Text('jump!')
)
);
}
}