Flutter FutureBuilderでリロードしたい

無性にリロードがしたい。人間にはそういう時期だってある。

FutureBuilderは完了するまで別のウィジェットを表示できる

api.flutter.dev

FutureBuilderは、HTTPリクエストの結果を表示する時に、リクエストが完了するまでインジケーターを表示したいケースに使える。 基本的な使い方は以下の通り。

FutureBuilder<String>(
  future: _futureObj,
  builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
    if(snapshot.hasData) {
      return Text(snapshot.data);
    } else if (snapshot.hasError) {
      return Text("Something wrong");
    } else {
      return CircularProgressIndicator();
    }
  },
);

futureプロパティにFutureオブジェクトを配置し、builderプロパティにはFutureオブジェクトがSuccess/Errorを返すまでどんなウィジェットを使うべきかの処理を記述する。

Futureオブジェクトは非同期処理をするための機能を提供してくれる。

api.dart.dev

Future<int> future = getFuture();
future.then((value) => handleValue(value))
      .catchError((error) => handleError(error));

時間のかかる処理をFutureオブジェクトでラップして、処理が完了したタイミングで何かしたい時に使う。 JavaScriptのPromiseみたいなもの。

一回きりなんて、そんな冷たいこと言わないで

前述の通り、FutureBuilderは何らかの処理が完了するまでインジケーターを表示して、処理が完了したら別のウィジェットを表示する時に使える。 しかし、このウィジェットをRefreshIndicatorと組み合わせて使いたくなった時に問題が発生する。

RefreshIndicatorはいわゆる引っ張って更新する機能を提供してくれるウィジェットなんだけど、単純にFutureBuilderを小要素として配置しただけではうまくリロードができない。

今回は、RefreshIndicatorとFutureBuilderを組み合わせて、リロードするたびにインジケーターが表示→ロードが完了したらウィジェットを表示させるコードを書いてみる。

precondition

  • MacOS v10.14.5
  • AndroidStudio v3.5.1
  • Flutter v1.12.13
  • Dart v2.7.0

StatefulWidgetでfutureを更新する

結論から書くと、FutureBuilderを小要素として持つWidgetをStatefulWidgetで定義して、Futureオブジェクトを更新できるようにしてやれば良い。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

// FutureBuilderを小要素として持つStatefulWidgetを定義する
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  // FutureBuilderに渡すFutureオブジェクトを更新可能なプロパティとして持つ
  Future<String> _imageUrl;


  // 初期化時にFutureオブジェクトも初期化しておく
  @override
  void initState() {
    super.initState();
    _imageUrl = _fetchImageUrl();
  }

  // asyncをつけていると、返り値をFutureオブジェクトでラップしてくれる
  Future<String> _fetchImageUrl() async {
    var result = "https://via.placeholder.com/150";
    await Future.delayed(Duration(seconds: 3));

    return result;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Container(
        child: RefreshIndicator(
          // onRefreshは Future<void>を返す関数を引数に取る
          onRefresh: () async {
            _imageUrl= _fetchImageUrl();
            setState(() {
            });
          },
          child:
          GridView.builder(
            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 2,
              crossAxisSpacing: 8,
              mainAxisSpacing: 8,
              childAspectRatio: 1.0,
            ),
            padding: EdgeInsets.all(8.0),
            itemCount: 2,
            itemBuilder: (context, index) {
              return FutureBuilder(
                future: _imageUrl,
                // FutureがSuccessを返すまで、builderに渡した無名関数が呼ばれる
                builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
                  switch(snapshot.connectionState) {
                    case ConnectionState.waiting:
                      return CircularProgressIndicator();
                      break;
                    default:
                      break;
                  }
                  if (snapshot.hasError) {
                    print(snapshot.error);
                  }
                  return snapshot.hasData ? Image.network(snapshot.data) : CircularProgressIndicator();
                },
              );
            },
          ),
        ),
      ),
    );
  }
}

FutureBuilderの部分を少し詳しく解説する。

return FutureBuilder(
  future: _imageUrl,
  builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
    switch(snapshot.connectionState) {
      case ConnectionState.waiting:
        return CircularProgressIndicator();
        break;
      default:
        break;
    }
    if (snapshot.hasError) {
      return Text(snapshot.error);
    }
    return snapshot.hasData ? Image.network(snapshot.data) : CircularProgressIndicator();
  },
);

画面を引っ張って更新するとRefreshIndicatorのonRefreshが走る。このとき、_imageUrlが更新され、setState()が呼ばれる。 このsetState()が呼ばれると、このメソッドを持つウィジェット自身が再描画され、小要素であるFutureBuilderも再描画される。

_imageUrlが新しいFutureオブジェクトに更新されているので、Successが発生するまで再びCircularProgressIndicatorが表示される仕組みになっている。

connectionStateはAsyncSnapshotのプロパティとして定義されていて、_imageUrlがSuccessを返すまでConnectionState.waitingが格納されている。 これを判定することで、処理が完了したかが分かる。

api.flutter.dev

できあがったものはコチラ。引っ張って更新するたびにFutureBuilderが動いていることがわかる。

今回使用したサンプルコードはこちらに公開してある。

github.com

ちなみに

身も蓋もない話だけど、画像のロードをいい感じに見せるだけだったらFadeInImage.memoryNetworkを使うのが一番楽。

flutter.dev

おわりに

こういうクロスプラットフォームフレームワークってちょっと凝ったことしようとすると派手に自作しないといけなかったりするんだけど、 Flutterの場合はMaterial Designっぽいウィジェットが一通り揃っているので、その文脈の中であればそこそこ複雑なことも実現しやすそうだった。*1

*1:でもインデントがすごい深くなるのはちょっと微妙