Flutter

【Flutter】Widgetをまたぐタップイベントを検知する方法

背景

以下動画のような部品を作成したいことがありました。

当初は、GestureDetectorで実現できるかと思っていましたが、「GestureDetectorを使用する際の問題点」の章に記載する問題が発覚し、困りました。

GestureDetectorを使用する際の問題点

Flutterをある程度実装したことがある方なら使ったことがあると思いますが、タッチ関係のイベントを処理する際は、GestureDetectorを使用します。
今回もGestureDetectorのonPanStart,onPanUpdateあたりを使用する使用する予定でした。
しかし、これらはタップが開始されたGestureDetectorでしかイベントを検知できず、他のWidget上でタッチが開始され、画面に触れたまま指をスライドさせて、別のWidget上に指をスライドさせても検知ができないという問題が発覚しました。

これでは、上に書いたような部品の作成ができません。
よって、次に記載するような対応を実施しました。

実際の問題

実際に問題の挙動を確認してみます。
以下の動画のように、複数のセルをなぞっているのですが、イベントを検知できているのはタップを開始したセルのみになります。

対応方法

hitTest関数を使用して、タップされたセルのコールバックを実行する仕組みを作成します。

hitTest関数でセルを取得するための準備

hitTest関数はタップされたRenderObjectを取得できる関数です。

よって、WidgetにRenderObjectを組み込むコードを作成します。

コードを以下に示します。

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class HitTestDetector extends SingleChildRenderObjectWidget {
  /// タッチされた際のコールバック
  final VoidCallback? onTouch;

  const HitTestDetector({
    Key? key,
    Widget? child,
    this.onTouch,
  }) : super(
          key: key,
          child: child,
        );

  @override
  RenderObject createRenderObject(BuildContext context) {
    return HitTestDetectorRenderBox()..onHit = onTouch;
  }

  @override
  void updateRenderObject(
    BuildContext context,
    covariant HitTestDetectorRenderBox renderObject,
  ) {
    super.updateRenderObject(context, renderObject);

    renderObject.onHit = onTouch;
  }
}

class HitTestDetectorRenderBox extends RenderProxyBox {
  VoidCallback? onHit;
}

セルにRenderObjectを組み込む

hitTest関数でタッチされたセルを取得するためにセルに上記で作成したWIdgetを組み込みます。

以下にコードを示します。

/// マトリクスのセル
class MatrixCell extends StatelessWidget {
  /// 座標
  final Coordinate? coordinate;

  /// 選択状態
  final bool isSelected;

  /// 選択された際のコールバック
  final VoidCallback? onSelected;

  /// コンストラクタ
  /// 引数:
  ///     coordinate:座標
  ///     isSelected:選択有無
  const MatrixCell({
    Key? key,
    this.coordinate,
    this.isSelected = false,
    this.onSelected,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return AspectRatio(
      aspectRatio: 1.0,
      child: HitTestDetector(
        onTouch: isSelected ? null : onSelected,
        child: AnimatedSwitcher(
          duration: const Duration(milliseconds: 100),
          child: _cell(isSelected),
        ),
      ),
    );
  }

  Widget _cell(bool isSelected) {
    if (isSelected) {
      return const _SelectedCell();
    } else {
      return const _DeselectedCell();
    }
  }
}

/// 未選択のセル
class _DeselectedCell extends StatelessWidget {
  const _DeselectedCell({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: Colors.transparent,
      ),
    );
  }
}

/// 選択されているセル
class _SelectedCell extends StatelessWidget {
  const _SelectedCell({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Transform.scale(
      scale: 1.12,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.transparent,
          border: Border.all(
            color: Colors.white,
            width: 5.0,
          ),
          borderRadius: BorderRadius.circular(5.0),
          boxShadow: const [
            SelectedCellShadow(
              color: Colors.black54,
              blurRadius: 15,
            ),
          ],
        ),
      ),
    );
  }
}

/// 選択されているセルの影
/// 備考:セル自体は透明なので、単純なBoxShadowを使用するとセルが影の色で真っ黒になってしまうため必要
class SelectedCellShadow extends BoxShadow {
  const SelectedCellShadow({
    super.color = const Color(0xFF000000),
    super.offset = Offset.zero,
    super.blurRadius = 0.0,
    super.spreadRadius = 0.0,
  });

  @override
  Paint toPaint() {
    final Paint result = super.toPaint();
    result.color = color;
    result.maskFilter = MaskFilter.blur(BlurStyle.outer, blurSigma);

    return result;
  }
}

タップされたセルを強調表現するWidgetを作成する

上記で作成したセルを組み込んで、タップされたセルを強調表現するWidgetを作成します。

以下にコードを示します。

/// 座標
class Coordinate {
  /// 列数
  int column;

  /// 行数
  int row;

  /// コンストラクタ
  /// 引数: column:行数
  ///           row:列数
  Coordinate({
    required this.column,
    required this.row,
  });
}

class Matrix extends StatelessWidget {
  /// 選択中の座標
  final Coordinate? selected;

  /// セルが選択された際のコールバック
  final Function(Coordinate?)? onChangeSelect;

  /// 有効状態
  final bool enable;

  /// 背景
  final Widget? background;

  /// 列数
  final int columnCount;

  /// 行数
  final int rowCount;

  /// コンストラクタ
  /// 引数:
  ///       selected:選択中の座標
  ///       onChangeSelect:セルが選択された際のコールバック
  ///       enable:有効状態
  const Matrix({
    Key? key,
    this.selected,
    this.onChangeSelect,
    this.enable = true,
    this.background,
    this.columnCount = 5,
    this.rowCount = 5,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return AspectRatio(
      aspectRatio: 1.0,
      child: Container(
        // 無効の時はグレーに
        foregroundDecoration: enable
            ? null
            : BoxDecoration(
                color: Colors.grey.withOpacity(0.8),
              ),
        child: Stack(
          fit: StackFit.expand,
          children: [
            background ?? Container(),
            GestureDetector(
              onPanStart: (detail) {
                _judgeHit(context, detail.globalPosition);
              },
              onPanUpdate: (detail) {
                _judgeHit(context, detail.globalPosition);
              },
              child: Table(
                border: TableBorder.all(color: Colors.white, width: 2.0),
                children: [
                  for (var row = 0; row < rowCount; row++)
                    TableRow(
                      children: [
                        for (var column = 0; column < columnCount; column++)
                          _cell(row, column)
                      ],
                    )
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  /// セル作成
  Widget _cell(int row, int column) {
    final coordinate = Coordinate(column: column, row: row);
    void onSelected() {
      // 選択されたセルと現在のセルが違う場合に通知する
      if (selected != coordinate) {
        onChangeSelect?.call(coordinate);
      }
    }

    // 無効の時は、選択セルはなし
    if (!enable) {
      return MatrixCell(
        isSelected: false,
        coordinate: coordinate,
        onSelected: onSelected,
      );
    }

    // 選択している座標のセルは選択状態に
    if (selected != null &&
        selected!.row == row &&
        selected!.column == column) {
      return MatrixCell(
        isSelected: true,
        coordinate: coordinate,
        onSelected: onSelected,
      );
    } else {
      return MatrixCell(
        isSelected: false,
        coordinate: coordinate,
        onSelected: onSelected,
      );
    }
  }

  void _judgeHit(BuildContext context, Offset globalPosition) {
    final RenderBox? box = context.findRenderObject() as RenderBox?;
    final result = BoxHitTestResult();
    var local = box?.globalToLocal(globalPosition);

    if (box == null || local == null) {
      return;
    }

    if (box.hitTest(result, position: local)) {
      for (final hit in result.path) {
        final target = hit.target;
        if (target is HitTestDetectorRenderBox) {
          target.onHit?.call();
        }
      }
    }
  }

完成

これで、タッチしているWidgetを検知することができるようになり、タッチされたセルを強調表示することができるようになりました

-Flutter
-, , , , , ,