🎨

FlutterでフルスクラッチでWidgetを作る時に知っておきたいこと

2021/01/13に公開

Flutterで開発をしていると、ときおり標準Widgetやpuv.devに無い、独自な形状と機能を持つWidget(以下、カスタムウィジェット)を作りたくなります。

カスタムウィジェットは、次の2つを組み合わせて作ることが出来ます。

  1. CustomPaint (独自の形状を描画する)
  2. GestureDetector (CustomPaintに対する、タップやスクロール等のジェスチャを推定する)

CustomPaintについては、簡単にこちらに解説してます。
FlutterのCustomPaintで図形を描いて遊ぶ

この記事では、Flutterにおけるジェスチャ推定について解説し、また GestureDetector+CustomPaintを使って簡単なスライダーを作ります。

Flutterにおけるジェスチャ推定システム

Flutterのジェスチャ推定システムは、次2つのレイヤーからなります。

  1. pointer events(以下、Pointer)
    • 画面上のポインタ(タッチ、マウス、スタイラス)の位置と動きを生でハンドリングする
  2. gesture events(以下、Gesture)
    • 1つ以上の Pointerからなる意味的なジェスチャ(タップ、ドラッグ、スケール等)をハンドリングする

公式にはPointerよりも Gestureでジェスチャ推定することが推奨されています。
Taps, drags, and other gestures - Flutter

Gestureは、ドラッグ開始、ドラッグの最中、ドラッグ停止、といった各ジェスチャのライフサイクルに対応するイベントを、複数発行することが出来ます。
例えばタップイベントなら、次のような感じです。

onTapDown: タップイベントの可能性があるポインタが、画面に触れた
onTapUp: タップイベントのトリガーとなったポインタが、画面から離れた

タップイベント以外にも、水平方向のドラッグ、垂直方向のドラッグ、パン(任意の方向へタップしたまま移動)などをハンドリングできます。

GestureDetectorについて

Widgetにジェスチャ推定を追加する場合、GestureDetectorを使います。

GestureDetectorは、どのコールバック関数(onTapDown:,onTapUp等)が設定されている(非null)かを元に、どのジェスチャを認識しようとするかを決定します。
複数コールバック関数が設定されている場合、gesture arenaに各ジェスチャ推定器を入れて、勝者を決定します。

例えば、画面に指が触れた時点では 水平ドラッグ推定器、垂直ドラッグ推定器がgesture arenaが入ります。(これらがコールバック関数に設定されていれば)
その後、ポインタが横に動いたら、水平ドラッグ推定器が勝利し、水平ドラッグイベントを検出、といった具合です。

作例(カスタムスライダー)

ここでは、CustomPaintGestureDetectorを使って、簡単なスライダーを作ってみます。

CustomPaintについては、こちらに解説してます。
FlutterのCustomPaintで図形を描いて遊ぶ

作成するのは、冒頭でもお見せしたこちらのスライダーです。

次のコードで、このスライダーを作ることが出来ます。

import 'dart:math';

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

class MySliderPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: MySlider(),
      ),
    );
  }
}

class MySlider extends StatefulWidget {
  const MySlider();
  
  MySliderState createState() => MySliderState();
}

class MySliderState extends State<MySlider> {
  Offset center = const Offset(0, 0); // ハンドラの位置
  final size = const Size(200, 200); // 描画領域のサイズ
  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (DragUpdateDetails details) {
        var handlerX = details.localPosition.dx;
        handlerX = min(handlerX, size.width); // ハンドラが描画領域を超えないように調整
        handlerX = max(0, handlerX); // ハンドラが描画領域を超えないように調整
        setState(() {
          center = Offset(handlerX, 0); // ハンドラの位置を更新
        });
      },
      child: CustomPaint(
        size: size,
        painter: BarPaint(),
        foregroundPainter: HandlerPaint(
          center: center,
        ),
      ),
    );
  }
}

/// スライダーのハンドラ(青部分)を描画
class HandlerPaint extends CustomPainter {
  HandlerPaint({
    this.center,
  });
  final Offset center;
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = Colors.blue;
    canvas.drawCircle(center, 10, paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true; // ここがfalseだと、onPanUpdate:が発火してもハンドラの描画が更新されない
  }
}

/// スライダーのバー(赤部分)を描画
class BarPaint extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.stroke
      ..strokeWidth = 5;
    canvas.drawLine(
      const Offset(0, 0),
      Offset(size.width, 0),
      paint,
    );
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

GestureDetectoronPanUpdateを設定しているので、描画領域を指がスライドするたびにonPanUpdateが発火します。
onPanUpdate中でsetState()を呼び出すことで、ハンドラの位置を更新・再描画しています。

このように、GestureDetectorCustomPaintを組み合わせることで、カスタムウィジェットを作成することが出来ます。

参考文献

Building a Custom Slider in Flutter with GestureDetector | by RJS Tech | Medium
Taps, drags, and other gestures - Flutter

Discussion