Flutter でアプリを開発しているときに、ボタンを使いたい場合 InkWell
Widget を使うのが一般的ですが、デザインによっては独自のボタンを実装したい場合もあります。
特に、 InkWell
はクリックした時のエフェクトが Ripple エフェクトと呼ばれるマテリアルデザイン特有なものなので、エフェクトに独自のアニメーションをつけたい場合などには向いていません。
サンプルアプリ
Flutter には GestureDetector
というプリミティブな Widget が存在するので、これを使うことで比較的簡単にクリッカブルな Widget を自作できます。
今回は例として、クリックされたときにサイズが変わるエフェクトがついたボタンを実装してみましょう。
import 'package:flutter/widgets.dart';
class ClickableContainer extends StatefulWidget {
final VoidCallback? onPressed;
final Widget? child;
const ClickableContainer({
Key? key,
required this.onPressed,
this.child,
}) : super(key: key);
@override
_ClickableContainerState createState() => _ClickableContainerState();
}
class _ClickableContainerState extends State<ClickableContainer>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
late final _animation = _controller.drive(Tween<double>(begin: 1, end: 0.95));
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onPressed,
onTapDown: (_) => _controller.forward(),
onTapUp: (_) => _controller.reverse(),
onTapCancel: () => _controller.reverse(),
child: ScaleTransition(
scale: _animation,
child: widget.child,
),
);
}
}
本来は自作のボタンを作るときにはマウスオーバーやスクリーンリーダー向けの実装が必要ですが、今回はサンプルのため簡略化しています。
ネストしたしたときのアニメーション
上記のコードは一見問題なく動きますが、ネストしたときに意図と違った挙動をします。
少しわかりづらいですが、内側のボタンをクリックしたときに外側のボタンも同時にアニメーションをしてしまっています。
これは、 GestureDetector
の onTapDown
が範囲内にあるタップの全てに反応してしまうためです。
Flutter はこれを解決するための簡単な方法を提供していないので、自分でタップの判定をコントロールする必要があります。
GestureDetector
には、 HitTestBehavior
といういかにもこの問題を解決できそうな引数がありますが、これは Stack
を使ったときのように
重なった Widget のタップをコントロールするためのもので、子コンポーネントとの競合を解決できるものではありません。
解決方法
色々なやり方があると思いますが、今回は素直にクリックを検知したらそのことを親 Widget に伝えて親のアニメーションを阻止するような実装をしていきます。 ざっくり図にすると以下のような感じです。
親の Widget にアクセスする必要があるので、 InheritedWidget
の出番です。 InheritedWidget
については下の記事を参照してください。
まずは子 Widget がクリックを検知したときに親 Widget にそれを知らせるためのインターフェースを用意します。
クリックを検知した子 Widget が、親 Widget の markChildClickableContainerPressed
を呼び出すためです。
abstract class _ParentClickableContainerState {
void markChildClickableContainerPressed(
_ParentClickableContainerState childState,
bool value,
);
}
次に、用意したインターフェースを子 Widget から参照するための InheritedWidget
を実装します。
class _ParentClickableContainerProvider extends InheritedWidget {
const _ParentClickableContainerProvider({
required this.state,
required Widget child,
}) : super(child: child);
final _ParentClickableContainerState state;
@override
bool updateShouldNotify(_ParentClickableContainerProvider oldWidget) =>
state != oldWidget.state;
static _ParentClickableContainerState? of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_ParentClickableContainerProvider>()
?.state;
}
}
では、実際にインターフェースを実装していきましょう。
final _activeChildren = ObserverList<_ParentClickableContainerState>();
_ParentClickableContainerState? get _parentState {
return _ParentClickableContainerProvider.of(context);
}
@override
void markChildClickableContainerPressed(
_ParentClickableContainerState childState,
bool value,
) {
final bool lastAnyPressed = _activeChildren.isNotEmpty;
if (value) {
_activeChildren.add(childState);
} else {
_activeChildren.remove(childState);
}
final bool nowAnyPressed = _activeChildren.isNotEmpty;
if (nowAnyPressed != lastAnyPressed) {
_parentState?.markChildClickableContainerPressed(
this,
nowAnyPressed,
);
}
}
子 Widget 側で markChildClickableContainerPressed
が呼び出されたときに、 _activeChildren
というリストにその Widget を登録していきましょう。このリストがアニメーション中の子 Widget を表します。
また、子 Widget からきた通知をさらに自分の親に伝播させるために、親の markChildClickableContainerPressed
を呼び出すのも忘れてはいけません。
最後にクリック時の挙動を実装していきます。
return GestureDetector(
onTap: widget.onPressed,
onTapDown: (_) {
if (_activeChildren.isNotEmpty) return;
_controller.forward();
_parentState?.markChildClickableContainerPressed(this, true);
},
onTapUp: (_) {
_controller.reverse();
_parentState?.markChildClickableContainerPressed(this, false);
},
onTapCancel: () {
_controller.reverse();
_parentState?.markChildClickableContainerPressed(this, false);
},
child: ScaleTransition(
scale: _animation,
child: widget.child,
),
);
onTapDown
が呼ばれたときには親にクリックされたことの登録を通知し、 onTapUp
onTapCancel
が呼ばれたときにその解除を通知します。
また、子 Widget がクリックされているときには onTapDown
の処理をスキップすることで、アニメーションが発火するのを防いでいます。
実際の動作をみてみましょう。
赤いボタンをクリックしたときには、青いボタンのアニメーションが発火しなくなりました。
まとめ
実は公式の InkWell
Widget も全く同じ方法でアニメーションのコントロールをしています。独自実装で困ったときは、公式のソースコードを参考にしてみるのもいいかもしれません。
最後に今回使ったサンプルのソースコードを置いておきます。
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
abstract class _ParentClickableContainerState {
void markChildClickableContainerPressed(
_ParentClickableContainerState childState,
bool value,
);
}
class _ParentClickableContainerProvider extends InheritedWidget {
const _ParentClickableContainerProvider({
required this.state,
required Widget child,
}) : super(child: child);
final _ParentClickableContainerState state;
@override
bool updateShouldNotify(_ParentClickableContainerProvider oldWidget) =>
state != oldWidget.state;
static _ParentClickableContainerState? of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_ParentClickableContainerProvider>()
?.state;
}
}
class ClickableContainer extends StatefulWidget {
final VoidCallback? onPressed;
final Widget? child;
const ClickableContainer({
Key? key,
required this.onPressed,
this.child,
}) : super(key: key);
@override
_ClickableContainerState createState() => _ClickableContainerState();
}
class _ClickableContainerState extends State<ClickableContainer>
with SingleTickerProviderStateMixin
implements _ParentClickableContainerState {
late final _controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
late final _animation = _controller.drive(Tween<double>(begin: 1, end: 0.9));
_ParentClickableContainerState? get _parentState {
return _ParentClickableContainerProvider.of(context);
}
final _activeChildren = ObserverList<_ParentClickableContainerState>();
@override
void markChildClickableContainerPressed(
_ParentClickableContainerState childState,
bool value,
) {
final bool lastAnyPressed = _activeChildren.isNotEmpty;
if (value) {
_activeChildren.add(childState);
} else {
_activeChildren.remove(childState);
}
final bool nowAnyPressed = _activeChildren.isNotEmpty;
if (nowAnyPressed != lastAnyPressed) {
_parentState?.markChildClickableContainerPressed(
this,
nowAnyPressed,
);
}
}
@override
Widget build(BuildContext context) {
return _ParentClickableContainerProvider(
state: this,
child: GestureDetector(
onTap: widget.onPressed,
onTapDown: (_) {
if (_activeChildren.isNotEmpty) return;
_controller.forward();
_parentState?.markChildClickableContainerPressed(this, true);
},
onTapUp: (_) {
_controller.reverse();
_parentState?.markChildClickableContainerPressed(this, false);
},
onTapCancel: () {
_controller.reverse();
_parentState?.markChildClickableContainerPressed(this, false);
},
child: ScaleTransition(
scale: _animation,
child: widget.child,
),
),
);
}
}