どんなボタンにも対応できるprogress_button

こんにちは、たかせです。Flutterアプリを作りまくってます。

早速ですが、今回はこれを作ります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/548060/f9f16550-d919-c9ac-e61f-5fedfbf5d88c.gif" width="50%">


次の特徴を簡単に実現できるWidgetです。

- 任意の非同期処理を開始できる
- 非同期処理が走っている間、ボタンの見た目を変化させることができる
- 非同期処理が走っている間、他の開始命令を無視できる

[progress_button](https://pub.dev/packages/flutter_progress_button)等の既存ライブラリと比べると、手軽さを減らして汎用性を増やした作りになっています。アニメーション周りは自分で実装する必要がありますが、InkWellやGestureDetector、サードパーティー製の任意のボタンにも適用可能です。

3つ目の特徴である「非同期処理が走っている間、他の開始命令を無視できる」が割と便利だなーと感じていて、ボタン警察（見かけたボタンはすべて連打して不具合を報告してくれる人）を上司に持つ方には特におすすめです。

## LazyFutureBuilderの説明

ソースコードです。

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

class LazyFutureBuilder extends StatefulWidget {
  final Future Function() futureBuilder;
  final Widget Function(BuildContext context, Future Function() futureBuilder, bool isFutureBuilding) builder;

  const LazyFutureBuilder({
    @required this.futureBuilder,
    @required this.builder,
  });

  @override
  State<StatefulWidget> createState() => _State();
}

class _State extends State<LazyFutureBuilder> {
  var _isFutureBuilding = false;

  @override
  Widget build(BuildContext context) {
    return widget.builder(
      context,
      () async {
        if (_isFutureBuilding) {
          return;
        }
        setState(() {
          _isFutureBuilding = true;
        });
        try {
          await widget.futureBuilder();
        } finally {
          setState(() {
            _isFutureBuilding = false;
          });
        }
      },
      _isFutureBuilding,
    );
  }
}
```

こんなで1記事書くの？ってくらいシンプルです。

LazyFutureBuilderは2つのプロパティからなるWidgetで、その2つのプロパティはそれぞれ

1. 排他的に処理したい非同期処理（を生成する関数）
2. その非同期処理の開始タイミングを決めるWidget（を生成する関数）

を表現します。LazyFutureBuilderは、内部で「非同期処理を実行中かどうか」のフラグを管理しており、このフラグが立っている間は、非同期処理の開始をブロックします。

## LazyFutureBuilderの使い方

ミニマムな使い方はこんな感じです。

![1opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/548060/aaf1b1d9-ed6d-becb-ed2d-6398a2b38d12.gif)


```dart
LazyFutureBuilder(
  futureBuilder: () async {
    // 何らかの時間のかかる処理
    await Future.delayed(const Duration(seconds: 2));
  },
  builder: (context, futureBuilder, isFutureBuilding) => RaisedButton(
    onPressed: futureBuilder,
    child: Text(
      isFutureBuilding ? "ローディング中だよ!" : "連打してごらんよ",
    ),
  ),
),
```

ちょっとかっこよくするとこんな感じ。アニメーションには[AnimatedCrossFade](https://api.flutter.dev/flutter/widgets/AnimatedCrossFade-class.html)と[flutter_spinkit](https://pub.dev/packages/flutter_spinkit)を組み合わせています。

AniamtedCrossFade、雑に作ってもいい感じに動くという意味ですごく便利です。

![2opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/548060/2695990f-fb31-abe7-6a7f-f54928649045.gif)


```dart
LazyFutureBuilder(
  futureBuilder: () async {
    // 何らかの時間のかかる処理
    await Future.delayed(const Duration(seconds: 2));
  },
  builder: (context, futureBuilder, isFutureBuilding) => RaisedButton(
    color: Colors.indigo,
    disabledColor: Colors.indigo,
    textColor: Colors.white70,
    shape: const StadiumBorder(),
    padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
    onPressed: isFutureBuilding ? null : futureBuilder,
    child: AnimatedCrossFade(
      layoutBuilder: (first, _, second, __) => IntrinsicWidth(
        child: IntrinsicHeight(
          child: Stack(
            alignment: Alignment.center,
            children: <Widget>[
              first,
              second,
            ],
          ),
        ),
      ),
      firstChild: Text("ちょっとかっこよくなったよ"),
      secondChild: SpinKitThreeBounce(
        color: Colors.white70,
        size: 24,
      ),
      crossFadeState: isFutureBuilding ? CrossFadeState.showSecond : CrossFadeState.showFirst,
      duration: const Duration(milliseconds: 200),
    ),
  ),
),
```

InkWellでも使い方は同じです。非同期処理中にアニメーションが不要な場合でも、ボタン警察への対策として手軽に使うことができます。

![3opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/548060/e015435f-431f-562b-92d5-c46eaeb2350c.gif)


```dart
LazyFutureBuilder(
  futureBuilder: () async {
    // 何らかの時間のかかる処理
    await Future.delayed(const Duration(seconds: 2));
  },
  builder: (context, futureBuilder, isFutureBuilding) => Align(
    alignment: Alignment.centerRight,
    child: InkWell(
      customBorder: const RoundedRectangleBorder(
        borderRadius: BorderRadius.all(Radius.circular(8)),
      ),
      onTap: isFutureBuilding ? null : futureBuilder,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.end,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text("もっと見る"),
            Icon(
              Icons.arrow_forward_ios,
              size: 16,
            ),
          ],
        ),
      ),
    ),
  ),
),
```

## ちなみに

[provider](https://pub.dev/packages/provider)を使っている場合は、LazyFutureBuilderの定義がもっと短くなります。

```dart

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

class LazyFutureBuilder extends StatelessWidget {
  final Future Function() futureBuilder;
  final Widget Function(BuildContext context, Future Function() futureBuilder, bool isFutureBuilding) builder;

  const LazyFutureBuilder({
    @required this.futureBuilder,
    @required this.builder,
  });

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<ValueNotifier<bool>>(
      create: (context) => ValueNotifier<bool>(false),
      child: Consumer<ValueNotifier<bool>>(
        builder: (context, notifier, child) => builder(
          context,
          () async {
            if (notifier.value) {
              return;
            }
            try {
              notifier.value = true;
              await futureBuilder();
            } finally {
              notifier.value = false;
            }
          },
          notifier.value,
        ),
      ),
    );
  }
}
```

すごいぞprovider。

## もっとちなみに

2つ目の紹介したボタンを至るところに配置したくなったので、普段の開発ではLoadingCrossFadeという名前で別クラスを作って使ってます。

```dart
import 'package:collabo_base_app/resource/color_resource.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';

class LoadingCrossFade extends StatelessWidget {
  final bool isLoading;
  final Widget child;

  const LoadingCrossFade({
    @required this.isLoading,
    @required this.child,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedCrossFade(
      layoutBuilder: (first, _, second, __) => IntrinsicWidth(
        child: IntrinsicHeight(
          child: Stack(
            alignment: Alignment.center,
            children: [
              first,
              second,
            ],
          ),
        ),
      ),
      crossFadeState: isLoading == true ? CrossFadeState.showSecond : CrossFadeState.showFirst,
      duration: const Duration(milliseconds: 200),
      firstChild: child,
      secondChild: SpinKitThreeBounce(
        color: Colors.white,
        size: 16,
      ),
    );
  }
}
```

これを使えば、

```dart
LazyFutureBuilder(
      futureBuilder: () async {
        await Future.delayed(const Duration(seconds: 2));
      },
      builder: (context, futureBuilder, isFutureBuilding) => FlatButton(
        color: Colors.indigo,
        disabledColor: Colors.indigo,
        textColor: Colors.white70,
        shape: const StadiumBorder(),
        padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
        onPressed: isFutureBuilding ? null : futureBuilder,
        child: LoadingCrossFade(
          child: Text("ちょっとかっこよくなったよ"),
          isLoading: isFutureBuilding,
        ),
      ),
    );
```

だいぶすっきりしますね。

## おわります

## 環境

```
$ flutter doctor -v
[✓] Flutter (Channel dev, v1.14.6, on Mac OS X 10.14.5 18F132, locale ja-JP)
    • Flutter version 1.14.6 at /~~~/Flutter/sdk/flutter
    • Framework revision fabeb2a16f (5 days ago), 2020-01-28 07:56:51 -0800
    • Engine revision c4229bfbba
    • Dart version 2.8.0 (build 2.8.0-dev.5.0 fc3af737c7)

```