Flutterで状態管理されたイメージをprecacheImageを使って、画像読み込みまで画面描画を遅らせる

2024.05.13

こんにちは、ゲームソリューション部のsoraです。
今回は、Flutterで状態管理されたイメージをprecacheImageを使って、画像読み込みまで画面描画を遅らせることについて書いていきます。

状態管理には、Riverpod(flutter_riverpod)を使用します。

実装することについて

タイトルの文言だけでは伝わりづらいかもしれないため、画面描画を遅らせない場合は以下の矢印の流れで表示されます。
上記の表示だと、画像読み込み前にテキストとボタンが表示されて、画像が読み込まれるとテキストとボタンが画像の表示分、移動してしまいます。
これだとボタンを押そうとしたのに、ボタンの位置がずれてユーザ体験的に悪くなってしまうことがあります。
そのため、画像読み込みまで画面描画を遅らせて、上記画像の1枚目の画面を経由せずに、2枚目の画面を表示するようにしていきます。

コードの解説

コードは以下です。
起動時のみでなく、画面遷移時にref.invalidate(imageListProvider);をした場合も機能するかを確認するためにpage_next.dartも作成しましたが、単純なコードのため割愛します。
ref.invalidate(imageListProvider);した場合も、問題なく機能することも確認できています。

main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'page_next.dart';
import 'provider/image_list.dart';

void main() {
  runApp(
    const ProviderScope(
        child: MyApp()
    )
  );
}
class MyApp extends StatelessWidget   {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.lightBlueAccent),
        useMaterial3: true,
          fontFamily: 'Noto Sans JP'
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final imageList = ref.watch(imageListProvider(context));

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('プリキャッシュ'),
      ),
      body: Center(
        child: Column(
          children: [
            imageList.when(
              data: (data){
                return Column(
                  children: [
                    Row(
                      children: [
                        Image.network(data[0]['avatar']),
                        Image.network(data[1]['avatar']),
                        const Text('test'),
                      ]
                    ),
                    ElevatedButton(
                        onPressed: () {
                          Navigator.pushReplacement(
                            context,
                            MaterialPageRoute(builder: (context) => const PageNext()),
                          );
                        },
                        child: const Text('別ページへ')
                    )
                  ],
                );
              },
              error: (error, trace) {
                return Text(error.toString());
              },
              loading: () {
                return const Center(
                  child: CircularProgressIndicator(),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

after_image_list.dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;

final imageListProvider = FutureProvider.family<List, dynamic>((ref, context) async {

  // フレームの描画ストップ
  final binding = WidgetsFlutterBinding.ensureInitialized();
  binding.deferFirstFrame();

  final imageGetList = await getImageList();
  final jsonResponse = jsonDecode(imageGetList);
  List imageList = jsonResponse['data'] as List;

  // 描画が停止できたら実行する関数
  binding.addPostFrameCallback((_) {
    String imageAvatar1 = imageList[0]['avatar'];
    String imageAvatar2 = imageList[1]['avatar'];
    if (context != null) {
      // resolve...:画像を読み込んだ後に実行する関数
      final image = NetworkImage(imageAvatar1)
        ..resolve(const ImageConfiguration())
            .addListener(ImageStreamListener((_, __) {
        }));
      // 読み込んだ画像をキャッシュする
      precacheImage(image, context);

      final image2 = NetworkImage(imageAvatar2)
        ..resolve(const ImageConfiguration())
            .addListener(ImageStreamListener((_, __) {
        }));
      precacheImage(image2, context);
    }
    // binding.allowFirstFrame():フレームの描画を許可する
    // binding.deferFirstFrame()で止めていたのをやめて描画する
    binding.allowFirstFrame();
  });
  return imageList;
});

Future<String> getImageList() async {
  String responseDataList;
  final response = await http.get(Uri.https('reqres.in', '/api/users'));
  if (response.statusCode == 200) {
    responseDataList = response.body.toString();
  } else {
    throw Exception('Failed to load sentence');
  }
  return responseDataList;
}

画面描画の停止・再開

詳細はコメントに記載してある通りですが、コードの解説です。

まずフレームの描画をストップします。
binding.addPostFrameCallbackは画面描画が停止している場合に実行する関数です。
今回の場合であればなくても動くかもしれませんが、画面描画が停止している上で実行することを明示的にするために記述しています。

対象の画像に対して、画像を読み込んだ後にprecacheImage(image, context);で読み込んだ画像をキャッシュしています。

最後に、binding.allowFirstFrame();で描画の許可をします。

after_image_list.dart

// フレームの描画ストップ
final binding = WidgetsFlutterBinding.ensureInitialized();
binding.deferFirstFrame();

final imageGetList = await getImageList();
final jsonResponse = jsonDecode(imageGetList);
List imageList = jsonResponse['data'] as List;

// 描画が停止できたら実行する関数
binding.addPostFrameCallback((_) {
  String imageAvatar1 = imageList[0]['avatar'];
  String imageAvatar2 = imageList[1]['avatar'];
  if (context != null) {
    // resolve...:画像を読み込んだ後に実行する関数
    final image = NetworkImage(imageAvatar1)
      ..resolve(const ImageConfiguration())
          .addListener(ImageStreamListener((_, __) {
      }));
    // 読み込んだ画像をキャッシュする
    precacheImage(image, context);

    final image2 = NetworkImage(imageAvatar2)
      ..resolve(const ImageConfiguration())
          .addListener(ImageStreamListener((_, __) {
      }));
    precacheImage(image2, context);
  }
  // binding.allowFirstFrame():フレームの描画を許可する
  // binding.deferFirstFrame()で止めていたのをやめて描画する
  binding.allowFirstFrame();
});

参考にしたページ

【Flutter】precacheImageで画面描画前に画像を読み込む

最後に

今回は、Flutterで状態管理されたイメージをprecacheImageを使って、画像読み込みまで画面描画を遅らせることを記事にしました。
どなたかの参考になると幸いです。