Flutter でネストに対応した独自ボタンを実装する

Flutter でアプリを開発しているときに、ボタンを使いたい場合 InkWell Widget を使うのが一般的ですが、デザインによっては独自のボタンを実装したい場合もあります。

特に、 InkWell はクリックした時のエフェクトが Ripple エフェクトと呼ばれるマテリアルデザイン特有なものなので、エフェクトに独自のアニメーションをつけたい場合などには向いていません。

サンプルアプリ

Flutter には GestureDetector というプリミティブな Widget が存在するので、これを使うことで比較的簡単にクリッカブルな Widget を自作できます。 今回は例として、クリックされたときにサイズが変わるエフェクトがついたボタンを実装してみましょう。

Clickable Container

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,
      ),
    );
  }
}

本来は自作のボタンを作るときにはマウスオーバーやスクリーンリーダー向けの実装が必要ですが、今回はサンプルのため簡略化しています。

ネストしたしたときのアニメーション

上記のコードは一見問題なく動きますが、ネストしたときに意図と違った挙動をします。

Clickable Container

少しわかりづらいですが、内側のボタンをクリックしたときに外側のボタンも同時にアニメーションをしてしまっています。 これは、 GestureDetectoronTapDown が範囲内にあるタップの全てに反応してしまうためです。

Flutter はこれを解決するための簡単な方法を提供していないので、自分でタップの判定をコントロールする必要があります。

GestureDetector には、 HitTestBehavior といういかにもこの問題を解決できそうな引数がありますが、これは Stack を使ったときのように 重なった Widget のタップをコントロールするためのもので、子コンポーネントとの競合を解決できるものではありません。

解決方法

色々なやり方があると思いますが、今回は素直にクリックを検知したらそのことを親 Widget に伝えて親のアニメーションを阻止するような実装をしていきます。 ざっくり図にすると以下のような感じです。

Nestable Clickable Container

親の Widget にアクセスする必要があるので、 InheritedWidget の出番です。 InheritedWidget については下の記事を参照してください。

Flutter の BuildContext と 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 の処理をスキップすることで、アニメーションが発火するのを防いでいます。

実際の動作をみてみましょう。

Nested Clickable Container

赤いボタンをクリックしたときには、青いボタンのアニメーションが発火しなくなりました。

まとめ

実は公式の 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,
        ),
      ),
    );
  }
}