【Flutter】LinearProgressIndicatorと CircularProgressIndicator で待ち時間を演出!

対象者

  • Flutterを用いたモバイルアプリの開発経験があり、ユーザー体験を向上させるための新しい方法を求めている方。
  • ProgressIndicatorの基本的な使用方法やカスタマイズ方法、そしてそのコントロールについて学びたい方。
  • 一般的なFlutterのベストプラクティスを学び、自身の開発スキルを向上させたい方。

はじめに

アプリ開発では時間の掛かる処理があり、ユーザーにロード時間を通知する必要があります。これはProgressIndicator を使用して実現できます。これらの小さな要素がユーザー体験を大きく向上させます。この記事では、LinearProgressIndicatorとCircularProgressIndicatorについて学びます。

LinearProgressIndicatorとCircularProgressIndicatorは、日本語で「線形進行状況表示器」と「円形進行状況表示器」という意味です。プログラムの世界では一般的に進行状況をユーザーに視覚的に示すウィジェットを指します。
Flutterにおいては、タスクの進行状況を表示するウィジェットです。そのため、長時間かかるタスクや、終了時間が予測できないタスクが実行中であることをユーザーに示すことができます。
実際のアプリとしては、データのダウンロードやアップロード、ファイルの読み込みなどのケースにLinearProgressIndicatorやCircularProgressIndicatorを用いて進行状況を表示することで、ユーザー体験を向上させることができます。

棒で進行状況を示すLinearProgressIndicatoと円形のCircularProgressIndicatorを、それぞれの特性とカスタマイズ方法について解説します。また、見た目だけでなく、それらをどのように制御し、さまざまな状況で最適なユーザーエクスペリエンスを提供するかについても触れます。

実際のコードを交えながら、一緒に学んでいきましょう。この記事を読むことで、FlutterにおけるProgressIndicatorの完全な理解と効果的な使用法を身につけることができます。

LinearProgressIndicatorについて

概要と使用方法

LinearProgressIndicatorはFlutterのMaterialコンポーネントで、進行状況を視覚化するために使用されます。画面上に水平な進行バーとして表示され、タスクの進行状況を示すための簡易的で直感的な手段です。例えば、ファイルのアップロード中やデータのダウンロード中など、ユーザーが待ち時間を理解できるようにするために使用されます。

基本的な使用方法は、LinearProgressIndicatorウィジェットをbuildメソッド内に配置することです。指定したvalueプロパティによって、進行バーの進行状況が制御されます。

以下に、基本的な使用例を示します。

LinearProgressIndicator(
  value: _progress, // _progressは進行状況を制御するための変数です
);

ボーダー/角の半径の追加

LinearProgressIndicatorはデフォルトでは四角形ですが、角を丸くすることで見た目を変更することも可能です。これは、LinearProgressIndicatorContainerClipRRectウィジェットと組み合わせることで実現します。

具体的には、LinearProgressIndicatorContainerウィジェットで囲み、そのContainerBoxDecorationを適用します。BoxDecorationborderRadiusプロパティを使用して、角の半径を指定します。これにより、プログレスバーの角が丸くなります。

以下に、角の半径を追加する使用例を示します。

ClipRRect(
  borderRadius: BorderRadius.circular(8),
  child: LinearProgressIndicator(
    value: _progress, // _progressは進行状況を制御するための変数です
  ),
);

これにより、角の丸いLinearProgressIndicatorが作成できます。見た目をカスタマイズすることで、アプリのデザインと一致させることが可能です。また、使用者の目を引くためにも有効な手段と言えるでしょう。

幅、アニメーション、ラインの高さの変更

FlutterのLinearProgressIndicatorは幅、アニメーション、そしてラインの高さといった要素を自由にカスタマイズすることができます。これにより、アプリケーションのデザインに合わせて進行状況のバーを調整できるので、ユーザビリティの向上に大いに役立ちます。

まず、LinearProgressIndicatorの幅はその親ウィジェットに依存します。つまり、Containerウィジェット等を使って親ウィジェットのサイズを制御することで、LinearProgressIndicatorの幅も同時に変更されます。

また、ラインの高さはLinearProgressIndicatorminHeight属性を使って変更することが可能です。minHeight属性に指定した数値がそのままラインの高さとなります。

さらに、LinearProgressIndicatorのアニメーションはvalueColorプロパティを用いてカスタマイズできます。valueColorプロパティにAlwaysStoppedAnimation<Color>を指定し、進行状況のバーが変化する色を定義します。

以下に、これらのカスタマイズを行うサンプルコードを示します。

Container(
  width: 200.0, // LinearProgressIndicatorの幅を制御します
  child: LinearProgressIndicator(
    minHeight: 10.0, // ラインの高さを指定します
    value: _progress, // 進行状況を制御します
    valueColor: AlwaysStoppedAnimation<Color>(Colors.red), // アニメーションの色を制御します
  ),
);

このように、FlutterのLinearProgressIndicatorでは様々な要素をカスタマイズして、ユーザ体験を向上させることが可能です。

親ウィジェットにフィットしない問題

ある状況下で、LinearProgressIndicatorが親ウィジェットにフィットしないという問題が発生することがあります。これは主に、親ウィジェットのサイズが未定義であったり、制約が存在しないときに起こります。

Flutterでは、ウィジェットが描画されるときにはその大きさや位置が確定している必要があります。そのため、ウィジェットのサイズが未定義の場合、エラーが発生することがあります。

この問題を解決するには、親ウィジェットのサイズを明示的に定義するか、または親ウィジェットに制約を与えることが一般的な解決策となります。

例えば、以下のようにContainerウィジェットを使用して親ウィジェットの幅を明示的に指定することができます。

SizedBox(
  width: 200.0, // LinearProgressIndicatorの幅を明示的に指定します
  child: LinearProgressIndicator(
    value: _progress, // 進行状況を制御します
  ),
);

このように、親ウィジェットのサイズを明示的に指定することで、LinearProgressIndicatorが正しく描画されるようになります。これにより、ユーザが進行状況を正確に把握できるようになります。

CircularProgressIndicatorについて

概要と使用方法

CircularProgressIndicatorはFlutterの中でも特によく利用されるウィジェットの一つです。このウィジェットは一般的には、アプリケーションがバックグラウンドで処理を行っているときに、ユーザにその進行状況を視覚的に示すために使われます。CircularProgressIndicatorは、その名の通り、円形のプログレスバーとして表示されます。

基本的な使用方法は非常にシンプルで、以下のようにCircularProgressIndicatorウィジェットを作成するだけです。

CircularProgressIndicator();

この状態でウィジェットを作成すると、無限のアニメーション(スピニング)が表示されます。進行状況をユーザに示すために、value属性を利用して0.0から1.0までの値を指定することも可能です。

CircularProgressIndicator(
  value: _progress,
);

ここで、_progressは進行状況を示す0.0から1.0までの値です。_progressの値を更新することで、ユーザに進行状況を反映させることができます。

サイズの調整

CircularProgressIndicatorのサイズは、SizedBoxウィジェットを使用して調整することができます。具体的には、SizedBoxウィジェットのheightwidth属性を指定することで、CircularProgressIndicatorのサイズを自由に設定することが可能です。
またstrokeWidthを使うことで、円形の太さを調整することができます。

以下に、サイズを調整する例を示します。

SizedBox(
  height: 50.0,
  width: 50.0,
  child: CircularProgressIndicator( strokeWidth: 10.0),
);

このコードは、高さと幅が共に50ピクセルのCircularProgressIndicatorを作成します。

色の変更

CircularProgressIndicatorの色はvalueColor属性を使用して変更することができます。具体的には、valueColor属性にAlwaysStoppedAnimation<Color>ウィジェットを指定し、その中で色を定義します。

以下に、色を変更する例を示します。

<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="d" data-joplin-source-open="```d
" data-joplin-source-close="
```">CircularProgressIndicator(
  valueColor: AlwaysStoppedAnimation<Color>(Colors.red),
);

このコードは、赤色のCircularProgressIndicatorを作成します。

以上、CircularProgressIndicatorの基本的な使用方法からサイズと色の調整までを解説しました。これらの設定を適切に用いることで、アプリケーションの見た目や使い勝手を向上させることが可能です。

応用

特定の期間だけ表示する方法

Flutterでは、特定の期間だけCircularProgressIndicatorを表示することが可能です。これはFuture.delayedメソッドとsetStateメソッドを組み合わせて行います。以下に具体的な例を示します。

bool _isLoading = true;

@override
void initState() {
  super.initState();
    Future.delayed(Duration(seconds: 2)).then((value) => setState(() {
          _isLoading = false;
        }));
}


@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: _isLoading ? CircularProgressIndicator() : Text('Loading Finished!'),
    ),
  );
}

この例では、アプリケーションが起動した時に2秒間だけCircularProgressIndicatorを表示し、その後にテキストメッセージを表示します。Future.delayedを用いて非同期に2秒間の遅延を作り、その後setState_isLoadingfalseにセットします。これにより、ウィジェットが再描画され、CircularProgressIndicatorの代わりにテキストが表示されます。

決定的と非決定的なProgress Indicatorの違い

Progress Indicatorには大きく分けて2つのタイプがあります。それが「決定的なProgress Indicator」(Determinate Progress Indicator)と「非決定的なProgress Indicator」(Indeterminate Progress Indicator)です。

決定的なProgress Indicatorは、処理が完了するまでの進行状況を具体的なパーセンテージで表示するものです。つまり、処理全体が100%であるとき、処理が50%完了すればProgress Indicatorも50%の位置になります。このタイプのIndicatorは、処理の全体の長さや進行状況をあらかじめ知ることができる場合に適しています。

一方、非決定的なProgress Indicatorは、処理が進行中であることを示すためだけのもので、具体的な進行状況は示しません。このタイプのIndicatorは、処理の全体の長さや進行状況をあらかじめ知ることができない場合に適しています。

FlutterのCircularProgressIndicatorでは、value属性に具体的な値を指定することで決定的なIndicatorとして機能します。一方、value属性を指定しない場合は非決定的なIndicatorとして機能します。

以上が、特定の期間だけProgress Indicatorを表示する方法と、決定的と非決定的なProgress Indicatorの違いについての解説です。これらを理解し、適切な状況で適切なタイプのIndicatorを使用することで、ユーザ体験を向上させることが可能です。

Timerを使用した進行状況の表示

アプリケーションで進行状況を表示する場合、一般的な方法の1つは、Timerを用いて進行状況を逐次更新することです。この方法は特に、ファイルのダウンロード進行状況など、一定間隔で進行状況を更新する必要がある場合に有用です。

以下に、Timerを用いてLinearProgressIndicatorの進行状況を更新する具体的な例を示します。

class _MyHomePageState extends State<MyHomePage> {
  var _progressByTimer = 0.0;

  @override
  void initState() {

    super.initState();
      Timer.periodic(Duration(seconds: 1), (Timer timer) {
      setState(() {
        _progressByTimer += 0.25;
        if (1.0 <= _progressByTimer) {
          timer.cancel();
        }
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Linear Progress Indicator"),
      ),
      body: Center(
        child: LinearProgressIndicator(value: _progressByTimer),
      ),
    );
  }
}

このコードでは、initStateメソッド内でTimer.periodicを用いて1秒ごとに進行状況を25%ずつ増加させ、LinearProgressIndicatorに反映させています。また、進行状況が100%に達したらタイマーをキャンセルします。

SemanticsLabelとSemanticsValueのアクセシビリティ機能

FlutterのProgressIndicatorウィジェットには、視覚障害を持つユーザー向けにアクセシビリティ機能を提供するsemanticsLabelsemanticsValueの2つのプロパティがあります。

semanticsLabelはスクリーンリーダーがIndicatorを読み上げる際の説明を指定します。semanticsValueはIndicatorの現在の値を表す文字列を指定します。これらのプロパティを使用することで、視覚障害を持つユーザーでもIndicatorの進行状況を理解することが可能になります。

以下に、これらのプロパティを用いた例を示します。

LinearProgressIndicator(
  value: progress,
  semanticsLabel: 'Linear progress indicator',
  semanticsValue: '${(progress * 100).round()}%',
)

このコードでは、semanticsLabelには「Linear progress indicator」、semanticsValueには進行状況のパーセンテージが指定されています。これにより、スクリーンリーダーは「Linear progress indicator, 50%」のようにIndicatorを読み上げます。

Indicatorの表示制御

ProgressIndicatorの表示は、通常、処理が進行中であることをユーザーに伝えるために使用されます。具体的には、ダウンロード、アップロード、大量のデータの読み込みなどの進行中の処理を示す場合に便利です。一方で、あまりに長い時間、ProgressIndicatorが表示され続けると、ユーザー体験に悪影響を及ぼす可能性があります。したがって、適切な表示制御が重要となります。

実際の使用例を以下に示します。

bool _isLoading = false;

void loadData() async {
  setState(() {
    _isLoading = true;
  });

  // ここに時間のかかる処理を記述します。

  setState(() {
    _isLoading = false;
  });
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('Progress Indicator'),
    ),
    body: Center(
      child: _isLoading
        ? CircularProgressIndicator()
        : Text('No loading'),
    ),
  );
}

この例では、_isLoadingという状態変数を使って、時間のかかる処理の前後でProgressIndicatorの表示/非表示を切り替えています。このように、適切な表示制御を行うことで、ユーザーに対して適切な情報を提供し、良好なユーザー体験を維持することが可能となります。

色を随時変えるようにする

valueColorでAlwaysStoppedAnimationを使ってきたので、他のアニメーションクラスを使うとどうなるかテストしました。随時色が変化し続けるようになりました。

var _progressByTimer = 0.0;

late final _animationController = AnimationController(
    duration: const Duration(seconds: 1),
    vsync: this,
);

final _colorTween = ColorTween(begin: Colors.blue, end: Colors.red);

@override
void initState() {
    super.initState();
    _animationController.repeat(reverse: true);
}

@override
void dispose() {
    _animationController.dispose();
    super.dispose();
}
  
AnimatedBuilder(
  animation: _animationController,
  builder: (context, child) {
        return LinearProgressIndicator(
          value: _progressByTimer,
          valueColor: _colorTween.animate(_animationController),
        );
  }),

Q&A

Q1: LinearProgressIndicatorCircularProgressIndicatorの主な違いは何ですか?

A: LinearProgressIndicatorCircularProgressIndicatorはどちらもタスクの進行状況を表示するためのFlutterのウィジェットですが、その表示の形状が異なります。LinearProgressIndicatorは進行状況を線形に、CircularProgressIndicatorは円形に表示します。

Q2: 決定的なProgress Indicatorと非決定的なProgress Indicatorの違いは何ですか?

A: 決定的なProgress Indicatorは、明確な進行状況(例えばダウンロードのパーセンテージなど)を表示します。一方、非決定的なProgress Indicatorは進行中であることを示すだけで、具体的な進行状況は表示しません。

Q3: SemanticsLabelSemanticsValueは何のために使用するのですか?

A: SemanticsLabelSemanticsValueは、アクセシビリティ機能を提供するために使用されます。特に視覚障害のあるユーザーに対して、進行状況を伝えるために役立ちます。

まとめ

ウェブサイトやアプリケーションのユーザーエクスペリエンスを高めるために、タスクの進行状況をユーザーに示すのが重要です。これを達成するために、FlutterはLinearProgressIndicatorCircularProgressIndicatorという二つの素晴らしいウィジェットを提供しています。

LinearProgressIndicatorは、一連のタスクが完了するまでの進行状況を線形に表示します。色、幅、角の半径を自由に設定でき、さらにはアニメーションやラインの高さもカスタマイズ可能です。しかし、親ウィジェットのサイズにフィットしないという問題が発生することがありますが、これは適切な制約と配置を行うことで解決できます。一方、CircularProgressIndicatorは、進行中のタスクを円形に表示します。これもサイズや色を調整可能で、特定の期間だけ表示することも可能です。
また、決定的なProgress Indicatorと非決定的なProgress Indicatorの違いについても理解しました。決定的なものは明確な進行状況(例:ダウンロードのパーセンテージ)を表示し、非決定的なものは進行中であることを示すだけです。
さらに、進行状況の表示にはTimerを使用した例を学びました。これは一定時間後に特定のアクションをトリガーするのに役立ちます。さらに、SemanticsLabelSemanticsValueを使ったアクセシビリティ機能についても学びました。これらは、視覚障害のあるユーザーに進行状況を伝えるのに役立ちます。
最後に、ProgressIndicatorの動作について学びました。具体的には、Indicatorの表示制御、継続的なスピニング行動、そして停止に必要な特定の呼び出しについて学びました。これらの知識により、ユーザーに最高のエクスペリエンスを提供できるようになりました。
全体を通して、ProgressIndicatorはアプリケーションのユーザビリティを向上させ、ユーザーに安心感を与える重要な要素であると理解しました。

参考

ソース(main.dartにコピペして動作確認用)

import 'dart:async';

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

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

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  var _progress = 0.8;
  var _isLoading = true;

  var _progressByTimer = 0.0;

  late final _animationController = AnimationController(
    duration: const Duration(seconds: 1),
    vsync: this,
  );

  final _colorTween = ColorTween(begin: Colors.blue, end: Colors.red);

  @override
  void initState() {
    super.initState();
    Future.delayed(Duration(seconds: 2)).then((value) => setState(() {
          _isLoading = false;
        }));

    Timer.periodic(Duration(seconds: 1), (Timer timer) {
      setState(() {
        _progressByTimer += 0.25;
        if (1.0 <= _progressByTimer) {
          timer.cancel();
        }
      });
    });

    _animationController.repeat(reverse: true);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Column(
        children: [
          const SizedBox(height: 16),
          const LinearProgressIndicator(),
          const SizedBox(height: 16),
          const LinearProgressIndicator(
              color: Colors.amber, backgroundColor: Colors.grey),
          const SizedBox(height: 16),
          const LinearProgressIndicator(
            color: Colors.amber,
            backgroundColor: Colors.grey,
            valueColor:
                AlwaysStoppedAnimation<Color>(Colors.red), // アニメーションの色を制御します
          ),
          const SizedBox(height: 16),
          const CircularProgressIndicator(
            color: Colors.amber,
            backgroundColor: Colors.grey,
            valueColor:
                AlwaysStoppedAnimation<Color>(Colors.red), // アニメーションの色を制御します
          ),
          const SizedBox(height: 16),
          const LinearProgressIndicator(
            value: 0.1,
            color: Colors.amber,
            backgroundColor: Colors.grey,
            valueColor:
                AlwaysStoppedAnimation<Color>(Colors.red), // アニメーションの色を制御します
          ),
          const SizedBox(height: 16),
          const LinearProgressIndicator(
            value: 0.1,
            color: Colors.amber,
            backgroundColor: Colors.grey,
          ),
          const SizedBox(height: 16),
          LinearProgressIndicator(
            value: _progress, // _progressは進行状況を制御するための変数です
          ),
          const SizedBox(height: 16),
          SizedBox(
            width: 200,
            child: ClipRRect(
              borderRadius: BorderRadius.circular(8),
              child: LinearProgressIndicator(
                minHeight: 16.0, // ラインの高さを指定します
                value: _progress, // 進行状況を制御します
                valueColor: AlwaysStoppedAnimation<Color>(
                    Colors.red), // アニメーションの色を制御します
              ),
            ),
          ),
          const SizedBox(height: 16),
          CircularProgressIndicator(),
          const SizedBox(height: 16),
          CircularProgressIndicator(value: _progress),
          SizedBox(
            height: 50.0,
            width: 50.0,
            child: CircularProgressIndicator(
              valueColor: AlwaysStoppedAnimation<Color>(Colors.red),
              strokeWidth: 10.0,
            ),
          ),
          _isLoading ? CircularProgressIndicator() : Text('Loading Finished!'),
          LinearProgressIndicator(value: _progressByTimer),
          const SizedBox(height: 16),
          LinearProgressIndicator(
            value: _progress,
            semanticsLabel: 'Linear progress indicator',
            semanticsValue: '${(_progress * 100).round()}%',
          ),
          const SizedBox(height: 16),
          AnimatedBuilder(
              animation: _animationController,
              builder: (context, child) {
                return LinearProgressIndicator(
                  value: _progressByTimer,
                  valueColor: _colorTween.animate(_animationController),
                );
              }),
        ],
      ),
    );
  }
}