4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

前の記事 に引き続き、仕事用に作ったUIパーツです。

実装したもの

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

test.gif

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

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

コード

全文はこちら

コード説明

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

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 の実態を定義していきます。

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を継承しています。


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 で制御しているため、 boolisHighlight を状態として管理しています。

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

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

  @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開けた領域を埋めるようにする」といったことも実現したかったので、指定しています。

      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 が、半円を描画している部分です。後述します。

          // 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つの大きさの異なる半円を重ねています。
(今気づいたのですが、ボタンを半透明にしたい場合は、意図しない表示になってしまいそうですね。。。)

  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 にする必要があるため、このように場合分けをして記述しています。


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 が行っています。

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

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

  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度分回す感じです。

  @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で描画する形が良いのかと思います。
(とはいえ、これもかなり複雑になってくると、描画されないパターンもありました。。公式のSVGサポートがほしいです。。)

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?