LoginSignup
7
5

More than 3 years have passed since last update.

Flutterでバウンスするボタンを実装する

Posted at

仕事で、Material DesignではないUIを実装したので、そのパーツについて紹介します。

実装したもの

ユーザがタップすると少し小さくなることで、タップフィードバックを行うボタンです。

↓イメージ
test.gif
(GIFアニメなので、少しカクついてますが、実際の端末では滑らかに動きます。)

コード

全文は こちら

https://medium.com/flutter-community/flutter-bouncing-button-animation-ece660e19c91 の記事をベースにしつつ改変、ある程度自由度が高い形で作れたと思います。

コード説明

上から順番に抜粋していき、それぞれの説明をしていきます。

import 'package:flutter/material.dart';

class BounceButton extends MaterialButton {
  BounceButton({
    Key key,
    @required VoidCallback onPressed,
    VoidCallback onLongPress,
    ButtonTextTheme textTheme,
    Color textColor,
    Color disabledTextColor,
    Color color,
    Color disabledColor,
    Brightness colorBrightness,
    double elevation = 0,
    EdgeInsetsGeometry padding,
    ShapeBorder shape,
    Clip clipBehavior = Clip.none,
    FocusNode focusNode,
    bool autofocus = false,
    MaterialTapTargetSize materialTapTargetSize,
    Duration animationDuration = kThemeChangeDuration,
    Widget child,
    this.ratio = 0.95,
  })  : assert(autofocus != null),
        assert(clipBehavior != null),
        super(
          key: key,
          onPressed: onPressed,
          onLongPress: onLongPress,
          textTheme: textTheme,
          textColor: textColor,
          disabledTextColor: disabledTextColor,
          color: color,
          disabledColor: disabledColor,
          colorBrightness: colorBrightness,
          elevation: elevation,
          padding: padding,
          shape: shape,
          clipBehavior: clipBehavior,
          focusNode: focusNode,
          autofocus: autofocus,
          materialTapTargetSize: materialTapTargetSize,
          animationDuration: animationDuration,
          child: child,
        );

Flutter標準のRaisedButtonやFlatButtonを参考に、 MaterialButton を継承しています。
これにより、「ボタンとしての標準的なUI・動作」は実装不要となっています。

コンストラクタについては、 RaisedButtonのコンストラクタ定義FlatButtonのコンストラクタ定義 を参考にしました。

ratio は、このUI用に追加したパラメータで、小さくなったときの倍率を設定できます。

プロジェクトのコード内に入れるときには、更に複数のパラメータに初期値を設定しています。


  final double ratio;

  @override
  Widget build(BuildContext context) {
    return BounceAnimation(
      onPressed: onPressed,
      onLongPress: onLongPress,
      ratio: ratio,
      animationDuration: animationDuration,
      child: AbsorbPointer(
        child: super.build(context),
      ),
    );
  }
}

BounceButton の定義自体はここまでです。

後述する BounceAnimationsuper.build を囲んでいます。
タップ動作に関することは BounceAnimation に渡していますが、それ以外の「ボタン的なUI・動作」については、親クラスの動作に丸投げしています。

そのままだと、MaterialButtonの標準動作も実行されてしまったので、 AbsorbPointer を間にはさみ、JSの stopPropagation 的なことを実現しています。


class BounceAnimation extends StatefulWidget {
  BounceAnimation({
    @required this.onPressed,
    this.onLongPress,
    this.child,
    this.ratio = 0.95,
    this.animationDuration = kThemeChangeDuration,
    this.scaleAlignment = Alignment.center,
  });

BounceAnimation の定義です。こちらは、StatefulWidgetを継承しています。
他のUIパーツで作ったときに、「小さくなる起点」を変えたいパターンがあったため、scaleAlignmentをパラメータに追加しています。(別記事で記載する予定です。)

  final VoidCallback onPressed;
  final VoidCallback onLongPress;
  final Widget child;
  final double ratio;
  final Duration animationDuration;
  final Alignment scaleAlignment;

  bool get enabled => onPressed != null || onLongPress != null;

  @override
  _BounceAnimationState createState() => _BounceAnimationState();
}

enabled の定義は、標準ボタンの丸パクリです。
_BounceAnimationState で利用しています。(利用者側のコードでも使えるかと思って、privateにはしていません。)


class _BounceAnimationState extends State<BounceAnimation>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;
  Animation<double> _scale;
  TickerFuture _tickerFuture;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: widget.animationDuration,
    );
    _scale = _controller
        .drive(
          CurveTween(curve: Curves.linear),
        )
        .drive(
          Tween(begin: 1, end: widget.ratio),
        );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

Stateの定義です。
SingleTickerProviderStateMixin を適用することで、縮小・もとに戻るアニメーションを制御しています。

アニメーションカーブは linear に固定していますが、他のカーブを使うことで、面白いフィードバックにできると思います。

  void _forwardAnimation() {
    if (!widget.enabled) {
      return;
    }

    _tickerFuture = _controller.forward();
  }

  void _reverseAnimation() {
    if (!widget.enabled) {
      return;
    }

    // start the reverse animation after the forward animation ends.
    _tickerFuture.whenCompleteOrCancel(() {
      _controller.reverse();
    });
  }

タップ動作に合わせて、アニメーションの開始・終了を行っています。
先程の enabled を利用して、無効な場合はアニメーションしないようにしています。

TickerFuture.whenCompleteOrCancel を利用することで、「小さくなりきってから、もとに戻る」を実現しています。
(これが無いと、tapDown -> tapUpが高速に行われた場合に、フィードバックがほとんど見えませんでした。)


  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => _forwardAnimation(),
      onTapUp: (_) => _reverseAnimation(),
      onTapCancel: _reverseAnimation,
      onTap: widget.onPressed,
      onLongPress: widget.onLongPress,
      child: ScaleTransition(
        scale: _scale,
        alignment: widget.scaleAlignment,
        child: widget.child,
      ),
    );
  }
}

GestureDetector -> ScaleTransition -> child という階層構造にすることで、
「タップに反応する」 -> 「子要素の大きさを変える」 -> 「実際に表示したいもの」を表現できました。

onTapDownonTapUp でアニメーションの開始・反転を行っていますが、
onTapCancel でも、アニメーションの反転をすることで、押したままボタン領域外に出た場合にも大きさが戻るようにしています。

終わり

標準のUI部品を参考にしつつ、ある程度は移譲することで、比較的少ないコードでやりたいことが実現できました。

Flutterでは、多くのUI部品が child として Widget を受け取るので、それに従ってUI部品を作ることで、可用性の高い部品を作ることができました。(今回のボタンも、ボタン内にアイコンを表示したり、装飾したテキストを表示したりできます。)

参考

Flutter — Bouncing button animation - Flutter Community - Medium
FlutterのTransition系アニメーションWidgetをすべて紹介 - Flutter 🇯🇵 - Medium

7
5
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
7
5