Flutterで半円形のボタンを実装する

[前の記事](https://qiita.com/noboru_i/items/404a77d7f5383df76b8e) に引き続き、仕事用に作ったUIパーツです。

## 実装したもの

画面端に表示して、アクセントとなるような半円形のボタンです。

![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/775c771d-ab87-f5c6-2d9e-c0aa7dfcc147.gif)

標準のタップフィードバックとしては、TopとRightにある同心円が表示されるもの、
前の記事で書いた `BounceAnimation` を組み合わせることで、タップ時にボタンが小さくなるフィードバックも可能です。

黒や白の枠は、想定したサイズで描画されているかを確認するために重ねています。
詳細は、コード全文を確認いただければと思います。

## コード

全文は[こちら](https://github.com/noboru-i/flutter_sample/blob/master/lib/screens/half_circle_button/half_circle_button_screen.dart)。

## コード説明

まず、実装イメージは下記のようになります。

```dart
class HalfCircleButtonScreen extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('HalfCircleButton'),
      ),
      backgroundColor: Colors.blueGrey,
      body: Stack(
        children: <Widget>[
          _buildTop(context),
        ],
      ),
    );
  }

  Widget _buildTop(BuildContext context) {
    return Align(
      alignment: Alignment.topCenter,
      child: Container(
        width: 324,
        height: 162,
        child: HalfCircleButton(
          strokeWidth: 16,
          direction: AxisDirection.up,
          color: Colors.blue,
          strokeColor: Colors.lightBlue,
          highlightColor: Colors.yellow,
          onTap: () {
            print('tapped top button');
          },
          child: Center(
            child: Text(
              'Top button',
              style: Theme.of(context).textTheme.display1.copyWith(
                    color: Colors.white,
                  ),
            ),
          ),
        ),
      ),
    );
  }
}
```

`Stack` + `Align`を利用することで、他のUIを無視して自由に画面端に表示できます。
ボタンの大きさについては、 `HalfCircleButton` を囲む `Container` で指定するのが良いかと思っています。
（Containerを固定値で表示したり、画面幅からマージンを確保してそれ以外の領域で表示したり）

`HalfCircleButton` に各種パラメータを設定、`child` としてボタンに乗せる要素を指定します。

次に、 `HalfCircleButton` の実態を定義していきます。

```dart
class HalfCircleButton extends StatefulWidget {
  HalfCircleButton({
    this.strokeWidth,
    this.tapHighlightScale = 1.1,
    this.direction,
    this.color,
    this.strokeColor,
    this.highlightColor,
    this.onTap,
    this.child,
  });

  final double strokeWidth;
  final double tapHighlightScale;
  final AxisDirection direction;
  final Color color;
  final Color strokeColor;
  final Color highlightColor;
  final GestureTapCallback onTap;
  final Widget child;

  @override
  _HalfCircleButtonState createState() => _HalfCircleButtonState();
}
```

今回は、通常のボタンUIとはかけ離れているため、`StatefulWidget`を継承しています。

```dart

class _HalfCircleButtonState extends State<HalfCircleButton> {
  bool isHighlight = false;

  void _showHighlight(TapDownDetails details) {
    setState(() {
      isHighlight = true;
    });

    // after few milliseconds, automatically hide highlight.
    Timer(Duration(milliseconds: 200), () {
      setState(() {
        isHighlight = false;
      });
    });
  }

  void _hideHighlight() {
    setState(() {
      isHighlight = false;
    });
  }

```

今回は、ハイライト表示を `AnimatedOpacity` で制御しているため、 `bool` の `isHighlight` を状態として管理しています。

このボタンでは、ユーザが長押ししていた場合にも200msec後にフィードバック部分が消え始めるようにしてみました。

ユーザが、押す→離すを高速に行うと、opacityが1になる前に0になり始めるので、そこは改善の余地があるかもしれないです。

```dart
  @override
  Widget build(BuildContext context) {
    final alignment = _alignment();

    return AspectRatio(
      aspectRatio: widget.direction == AxisDirection.up ||
              widget.direction == AxisDirection.down
          ? 2 / 1
          : 1 / 2,
```

`build` メソッドが比較的長いので、細切れで。

まずは、 `AspectRatio` を利用して、縦横比を固定化しています。
今回、「ボタンサイズは画面幅から左右24pt開けた領域を埋めるようにする」といったことも実現したかったので、指定しています。

```dart
      child: Stack(
        children: <Widget>[
          // tap highlight
          AnimatedOpacity(
            opacity: isHighlight ? 1 : 0,
            duration: Duration(milliseconds: 200),
            child: Transform.scale(
              scale: widget.tapHighlightScale,
              alignment: alignment,
              child: CustomPaint(
                painter: HalfCirclePainter(
                  color: widget.highlightColor,
                  direction: widget.direction,
                ),
                child: Container(),
              ),
            ),
          ),
```

`Stack` の最下層には、ハイライト表示部分を配置しています。

外部から与えられた「ボタンのサイズ」を超えて表示したかったので、 `Transform.scale` を利用しています。

`HalfCirclePainter` が、半円を描画している部分です。後述します。

```dart
          // button area
          GestureDetector(
            onTap: () {
              _hideHighlight();
              widget.onTap();
            },
            onTapDown: _showHighlight,
            onTapCancel: _hideHighlight,
            behavior: HitTestBehavior.deferToChild,
            child: Stack(
              alignment: alignment,
              children: <Widget>[
                // outer circle
                CustomPaint(
                  painter: HalfCirclePainter(
                    color: widget.strokeColor,
                    direction: widget.direction,
                  ),
                  child: Container(),
                ),
                // inner circle
                Container(
                  padding: _edgeInsets(),
                  alignment: alignment,
                  child: CustomPaint(
                    painter: HalfCirclePainter(
                      color: widget.color,
                      direction: widget.direction,
                    ),
                    child: Container(),
                  ),
                ),
                Positioned(
                  top: 0,
                  left: 0,
                  right: 0,
                  bottom: 0,
                  child: widget.child ?? Container(),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

```

ボタン部分です。
borderをつけたかったのですが、Paintのstrokeを使うと計算が難しかったため、単純に2つの大きさの異なる半円を重ねています。
（今気づいたのですが、ボタンを半透明にしたい場合は、意図しない表示になってしまいそうですね。。。）

```dart
  Alignment _alignment() {
    switch (widget.direction) {
      case AxisDirection.up:
        return Alignment.topCenter;
      case AxisDirection.right:
        return Alignment.centerRight;
      case AxisDirection.down:
        return Alignment.bottomCenter;
      case AxisDirection.left:
        return Alignment.centerLeft;
    }
    return null;
  }

  EdgeInsets _edgeInsets() {
    switch (widget.direction) {
      case AxisDirection.up:
        return EdgeInsets.symmetric(horizontal: widget.strokeWidth) +
            EdgeInsets.only(bottom: widget.strokeWidth);
      case AxisDirection.right:
        return EdgeInsets.symmetric(vertical: widget.strokeWidth) +
            EdgeInsets.only(left: widget.strokeWidth);
      case AxisDirection.down:
        return EdgeInsets.symmetric(horizontal: widget.strokeWidth) +
            EdgeInsets.only(top: widget.strokeWidth);
      case AxisDirection.left:
        return EdgeInsets.symmetric(vertical: widget.strokeWidth) +
            EdgeInsets.only(right: widget.strokeWidth);
    }
    return null;
  }
}

```

`build` 内で利用していた、細かな変換・計算を行っているメソッドです。

`_edgeInsets` については、向きごとに1箇所だけ `0` にする必要があるため、このように場合分けをして記述しています。

```dart

class HalfCirclePainter extends CustomPainter {
  HalfCirclePainter({
    @required this.color,
    this.direction = AxisDirection.down,
  });

  final Color color;
  final AxisDirection direction;

  Path _path;

  @override
  void paint(Canvas canvas, Size size) {
    _path = _getHalfCirclePath(size.width, size.height);
    Paint paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;
    canvas.drawPath(
      _path,
      paint,
    );
  }

```

`CustomPainter` を継承した、 `HalfCirclePainter` の定義です。

色と方向（円の見えている部分）を受け取って、Widgetの領域内に半円を描きます。
半円の指定については、後述する `_getHalfCirclePath` が行っています。

```dart
  @override
  bool hitTest(Offset position) {
    if (_path == null) {
      return false;
    }
    return _path.contains(position);
  }

```

半円の周り部分をタップしたときに反応するとかっこ悪いので、 `hitTest` を実装し、
`paint` のときに描画した `_path` の枠内がタップされたのか？を判定しています。

```dart
  Path _getHalfCirclePath(double x, double y) {
    num degToRad(num deg) => deg * (math.pi / 180.0);

    final offset = _getOffset(x, y);
    final startAngle = _getStartAngle();
    return Path()
      ..moveTo(offset.dx, offset.dy)
      ..arcTo(
        _getRect(x, y),
        degToRad(startAngle),
        degToRad(180),
        false,
      );
  }
```

`degToRad` は、Stack Overflowから拾った気がするのですが、URLが見当たらず。。。

方向によって、始点・円弧の領域・円弧の開始角が違うので、そこは別メソッドで計算しています。
例えば下部ボタンであれば、左下から始めて、180度の方向から、180度分回す感じです。

```dart
  @override
  bool shouldRepaint(HalfCirclePainter oldDelegate) {
    return oldDelegate.color != color || oldDelegate.direction != direction;
  }

  Offset _getOffset(double x, double y) {
    switch (direction) {
      case AxisDirection.up:
        return Offset(x, 0);
      case AxisDirection.right:
        return Offset(x, y);
      case AxisDirection.down:
        return Offset(0, y);
      case AxisDirection.left:
        return Offset(0, 0);
    }
    return null;
  }

  num _getStartAngle() {
    switch (direction) {
      case AxisDirection.up:
        return 0;
      case AxisDirection.right:
        return 90;
      case AxisDirection.down:
        return 180;
      case AxisDirection.left:
        return 270;
    }
    return null;
  }

  Rect _getRect(double x, double y) {
    switch (direction) {
      case AxisDirection.up:
        return Rect.fromCenter(
          center: Offset(x / 2, 0),
          width: x,
          height: y * 2,
        );
      case AxisDirection.right:
        return Rect.fromCenter(
          center: Offset(x, y / 2),
          width: x * 2,
          height: y,
        );
      case AxisDirection.down:
        return Rect.fromCenter(
          center: Offset(x / 2, y),
          width: x,
          height: y * 2,
        );
      case AxisDirection.left:
        return Rect.fromCenter(
          center: Offset(0, y / 2),
          width: x * 2,
          height: y,
        );
    }
    return null;
  }
}
```

指定された方向によって、それぞれの値を判定・計算しています。

## 終わり

`CustomPainter` をうまく使えば、Dartのコード上で好きな図形を書くことができそうです。

とはいえ、ある程度難しい図形になる場合は、デザイナーさんにイラレで書いてもらいSVGで出力、[flutter_svg](https://pub.dev/packages/flutter_svg)で描画する形が良いのかと思います。
（とはいえ、これもかなり複雑になってくると、描画されないパターンもありました。。公式のSVGサポートがほしいです。。）