[読書会]ずらした(Staggered)アニメーション

(このページは、docs.flutter.devhttps://docs.flutter.dev/ui/animations/staggered-animations)のリファレンスブログです)

1.概要

1.1 ポイント

  • ずれるアニメーション(Staggered Animation)は、連続的または重なり合ったアニメーションで構成されています。
  • ずれるアニメーションを作成するには、複数の Animation オブジェクトを使用します。
  • 1つの AnimationController が、全てのアニメーションを制御します。
  • 各 Animation オブジェクトは、アニメーションの進行中に発生するタイミングInterval で指定します。
  • アニメーションする各プロパティに対して Tween を作成します。

1.2 Tween

 Tweenとは、ある開始値から終了値までの間を補間するためのオブジェクトで、アニメーションの動きの軌跡を定義します。(Tween の基本については、Flutterのアニメーションチュートリアルで説明されています。)

1.3 ずれるアニメーションの基本概念

 ずれるアニメーションは、視覚的な変化を一度にではなく、一連の操作として順次発生させるアニメーション手法です。
 このアニメーションでは、次のようなバリエーションが可能です。

  • 完全に連続して実行されるもの(1つの変化が終了した後に次が始まる)
  • 部分的または完全に重なり合って実行されるもの
  • 一定の空白期間が設けられ、その間は変化が起きないもの

 ずれるアニメーションは、複数の要素に対してタイミングをずらして順番に変化させることで、ダイナミックでリズミカルな効果を生み出すのに役立ちます。

1.4 ずれるアニメーションの例

 このガイドでは、basic_staggered_animation の例について説明しています。
また、より複雑な例として staggered_pic_selection も参照できます。

(1)basic_staggered_animation

  • 単一のウィジェットに対して、連続的かつ重なり合ったアニメーションの一連の変化を表示します。
  • 画面をタップすると、アニメーションが始まり、ウィジェットの透明度(opacity)、サイズ、形状、色、パディングが順に変化します。

(以下は、Flutterでのスタッガードアニメーション順次アニメーション)の例です。画面をタップすると、透明度・サイズ・形状・色・パディングが順にアニメーションされますtimeDilation 10倍設定することで、アニメーションの進行がゆっくりになるため、各ステップの変化が確認し易くなっています。)

スタッガードアニメーション(順次アニメーション)の例です。画面をタップすると、透明度・サイズ・形状・色・パディングが順にアニメーションされます。
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;

void main() {
  runApp(
    const MaterialApp(
      home: StaggerDemo(),
    ),
  );
}

//スタッガードアニメーションを構築するためのクラス
class StaggerAnimation extends StatelessWidget {

  //各プロパティのアニメーションオブジェクト
  final Animation<double> controller;
  final Animation<double> opacity;
  final Animation<double> width;
  final Animation<double> height;
  final Animation<EdgeInsets> padding;
  final Animation<BorderRadius?> borderRadius;
  final Animation<Color?> color;

  //AnimationControllerを受け取り、各プロパティのアニメーションを設定
  StaggerAnimation({super.key, required this.controller})
      : //←イニシャライザ:
        //( コンストラクタが呼ばれる直前に、プロパティを初期化するために使われます )

        //透明度のアニメーション: 0.0から1.0までの間で実行
        opacity = Tween<double>(
          begin: 0.0,
          end: 1.0,
        ).animate(
          CurvedAnimation(
            parent: controller,
            curve: const Interval(
              0.0,
              0.100,
              curve: Curves.ease,
            ),
          ),
        ),
        //幅のアニメーション: 50.0から150.0までの間で実行
        width = Tween<double>(
          begin: 50.0,
          end: 150.0,
        ).animate(
          CurvedAnimation(
            parent: controller,
            curve: const Interval(
              0.125,
              0.250,
              curve: Curves.ease,
            ),
          ),
        ),
        //高さのアニメーション: 50.0から150.0までの間で実行
        height = Tween<double>(begin: 50.0, end: 150.0).animate(
          CurvedAnimation(
            parent: controller,
            curve: const Interval(
              0.250,
              0.375,
              curve: Curves.ease,
            ),
          ),
        ),
        //パディングのアニメーション: bottomが16から75まで変化
        padding = EdgeInsetsTween(
          begin: const EdgeInsets.only(bottom: 16),
          end: const EdgeInsets.only(bottom: 75),
        ).animate(
          CurvedAnimation(
            parent: controller,
            curve: const Interval(
              0.250,
              0.375,
              curve: Curves.ease,
            ),
          ),
        ),
        //角丸のアニメーション: 半径が4から75まで変化
        borderRadius = BorderRadiusTween(
          begin: BorderRadius.circular(4),
          end: BorderRadius.circular(75),
        ).animate(
          CurvedAnimation(
            parent: controller,
            curve: const Interval(
              0.375,
              0.500,
              curve: Curves.ease,
            ),
          ),
        ),
        //色のアニメーション: インディゴ色からオレンジ色に変化
        color = ColorTween(
          begin: Colors.indigo[100],
          end: Colors.orange[400],
        ).animate(
          CurvedAnimation(
            parent: controller,
            curve: const Interval(
              0.500,
              0.750,
              curve: Curves.ease,
            ),
          ),
        );
  
  //アニメーションビルド関数: 現在のアニメーション値を使ってUIを更新
  Widget _buildAnimation(BuildContext context, Widget? child) {
    return Container(
      padding: padding.value, //アニメーションされたパディング値
      alignment: Alignment.bottomCenter,
      child: Opacity(
        opacity: opacity.value, //アニメーションされた透明度値
        child: Container(
          width: width.value, //アニメーションされた幅
          height: height.value, //アニメーションされた高さ
          decoration: BoxDecoration(
            color: color.value, //アニメーションされた色
            border: Border.all(
              color: Colors.indigo[300]!,
              width: 3,
            ),
            borderRadius: borderRadius.value, //アニメーションされた角丸
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      builder: _buildAnimation,
      animation: controller,
    );
  }
}

//デモ画面全体を構成するクラス
class StaggerDemo extends StatefulWidget {
  const StaggerDemo({super.key});

  @override
  State<StaggerDemo> createState() => _StaggerDemoState();
}

class _StaggerDemoState extends State<StaggerDemo>
    with TickerProviderStateMixin {
  late AnimationController _controller;

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

    //AnimationController を初期化
    _controller = AnimationController(
      duration: const Duration(milliseconds: 2000), //2秒でアニメーション完了
      vsync: this,
    );
  }

  @override
  void dispose() {
    _controller.dispose(); //AnimationController を破棄
    super.dispose();
  }

  //アニメーションを再生する関数
  Future<void> _playAnimation() async {
    try {
      await _controller.forward().orCancel; //再生
      await _controller.reverse().orCancel; //逆再生
    } on TickerCanceled {
      //アニメーションがキャンセルされた場合の処理
    }
  }

  @override
  Widget build(BuildContext context) {
    timeDilation = 10.0; //アニメーション速度を10倍に遅くする
    return Scaffold(
      appBar: AppBar(
        title: const Text('Staggered Animation'),
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: () {
          _playAnimation(); //タップでアニメーションを再生
        },
        child: Center(
          child: Container(
            width: 300,
            height: 300,
            decoration: BoxDecoration(
              color: Colors.black.withOpacity(0.1), //背景色を薄く設定
              border: Border.all(
                color: Colors.black.withOpacity(0.5), //枠線の色を薄く設定
              ),
            ),
            child: StaggerAnimation(controller: _controller.view), //スタッガードアニメーションを配置
          ),
        ),
      ),
    );
  }
}

(2)staggered_pic_selection

  • 画像をリストから削除する動作を、3つの異なるサイズで表示します。
  • この例では2つのアニメーションコントローラーを使用しています。1つは画像の選択/選択解除のアニメーション用、もう1つは画像の削除のアニメーション用です。
  • 選択/選択解除のアニメーションはスタッガード形式で行われます。
    • この効果をより明確にするために、timeDilation 値(アニメーション速度の遅延)を増やすと良いでしょう。
  • 操作例:
    • 1つの大きな画像を選択すると、その画像が縮小され、青い円の中にチェックマークが表示されます。
    • 次に、小さな画像を選択すると、大きな画像がチェックマークを消して拡大します。
    • 大きな画像が拡大を終える前に、小さな画像が縮小してチェックマークを表示します。
  • このスタッガードな動作は、Google Photosなどでよく見られるアニメーションに似ています。

(以下は、Flutterでの写真選択や削除にスタッガードアニメーションを使用したデモアプリです。写真がタップされると選択状態がアニメーションで反映され、削除アイコンを押すと選択された写真が削除されますまた、timeDilationを20倍に設定することで、アニメーションが遅く実行され、アニメーションの各ステップが視覚的に確認しやすくなっています。

スタッガードアニメーション例:
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;

void main() {
  runApp(
    const MaterialApp(
      home: ImagesDemo(),
    ),
  );
}

//写真を表すクラス
class Photo {
  const Photo(this.asset, this.id);

  final String asset; //画像のパス
  final int id;       //画像のID

  //==演算子のオーバーライド:
  //( Photo オブジェクトが他のオブジェクトと等しいかを判断します )
  @override
  bool operator ==(Object other) =>
    //参照が同じオブジェクトである場合に true を返します。
    //( もし参照が異なるオブジェクトであっても、other が Photo 型であり、
    //  id フィールドの値が同じであれば、同じ画像として扱い true を返します。
    //  こうすることで、異なるインスタンスであっても id が一致する場合、同じ画像として認識されるようになります )
    //identical(): 同じメモリかどうかをチェックする
    //this: 現在のインスタンスを指します
    //other: 比較対象のオブジェクトを指します
    //( もし this と other が同じインスタンスであれば、identical(this, other) は true を返します )
    identical(this, other) ||
    //other is photo: other が photo 型であるかどうかをチェックします
    //runtimeType: オブジェクトの型を取得するプロパティ
    //( other.runtimeType: 比較対象otherの型を表します
    //  つまり、this と other の型が同じ場合に true になります
    //id: Photoクラス内で定義されたフィールドで、Photoオブジェクトの一意の識別子です
    //( id == other.id は、this と other の idフィールドが同じ値を持つかどうかを確認します
    //  これにより、2つの Photo インスタンスが同じ id を持っているかどうかをチェックします )
    other is Photo && runtimeType == other.runtimeType && id == other.id;

  //hashCode のオーバーライド:
  //( Photo クラスのハッシュ値が id のハッシュ値に基づいて計算されます。
  //  hashCode は、コレクション(例: Set や Map)におけるオブジェクトの一意性を確保するために使われます
  //  id が同じ Photo オブジェクトは同じ hashCode を持つため、Set などのコレクションでの重複処理が行いやすくなります )
  @override
  int get hashCode => id.hashCode;
}

//サンプル画像のインスタンスを生成
final List<Photo> allPhotos = List<Photo>.generate(30, (index) {
  return Photo('images/pic${index + 1}.jpg', index);
});

//写真のレイアウトフレームを表すクラス
class PhotoFrame {
  const PhotoFrame(this.width, this.height);

  final double width;  //写真の幅の割合
  final double height; //写真の高さの割合
}

//写真ブロックのレイアウトを定義
const List<List<PhotoFrame>> photoBlockFrames = [
  [PhotoFrame(1.0, 0.4)], //写真1枚(幅が100%、高さが40%)
  [PhotoFrame(0.25, 0.3), PhotoFrame(0.75, 0.3)], //写真が2枚(1枚目の幅が25%、2枚目の幅が75%、両方の高さは30%)
  [PhotoFrame(0.75, 0.3), PhotoFrame(0.25, 0.3)], //写真が2枚(1枚目の幅が75%、2枚目の幅が25%、両方の高さは30%)
];

//選択済みのチェックマーク表示用ウィジェット
class PhotoCheck extends StatelessWidget {
  const PhotoCheck({super.key});

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: Theme.of(context).primaryColor,
        borderRadius: const BorderRadius.all(Radius.circular(16)),
      ),
      child: const Icon(
        Icons.check,
        size: 32,
        color: Colors.white,
      ),
    );
  }
}

//写真アイテムウィジェット
class PhotoItem extends StatefulWidget {
  const PhotoItem({
    super.key,
    required this.photo,
    this.color,
    this.onTap,
    required this.selected,
  });

  final Photo photo;
  final Color? color;
  final VoidCallback? onTap;
  final bool selected;

  @override
  State<PhotoItem> createState() => _PhotoItemState();
}

//写真アイテムの状態管理クラス
//( PhotoItem ウィジェットの選択状態や写真の差し替えなど、さまざまなアニメーションを制御するために、
//  複数の AnimationController と Animation オブジェクトを定義しています。
//  これらは、ユーザーが写真を選択または差し替えた際の視覚効果を表現するために使用されます )
class _PhotoItemState extends State<PhotoItem> with TickerProviderStateMixin {
  late final AnimationController _selectController; //選択状態の AnimationController
  late final Animation<double> _stackScaleAnimation; //スケールアニメーション
  //RelativeRect型:
  //( left,top,right,bottom の4つのプロパティを持つクラス
  //  例: RelativeRect.fromLTRB(0, 0, 0, 0) ウィジェットは親の左上に配置される )
  late final Animation<RelativeRect> _imagePositionAnimation; //位置アニメーション
  late final Animation<double> _checkScaleAnimation; //チェックマークのスケールアニメーション
  late final Animation<double> _checkSelectedOpacityAnimation; //チェックマークの透明度アニメーション

  late final AnimationController _replaceController; //差し替え AnimationController
  //Offset:
  //( 中心からの距離
  //  例: Offset(1.0, 0.0)は、ウィジェットの幅1倍文だけ右にずれた位置 )
  late final Animation<Offset> _replaceNewPhotoAnimation; //新しい写真のアニメーション
  late final Animation<Offset> _replaceOldPhotoAnimation; //古い写真のアニメーション
  late final Animation<double> _removeCheckAnimation; //チェックマーク削除アニメーション

  late Photo _oldPhoto;  //古い写真の情報
  Photo? _newPhoto; //新しい写真の情報(削除アニメーション時のみ使用)

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

    _oldPhoto = widget.photo; //初期化時に古い写真を設定

    //選択状態の AnimationController の初期化
    //( 選択状態を示すアニメーションを実装しています。
    //  AnimationController と Tween を用いて、さまざまなアニメーション効果(スケール、透明度、位置)
    //  を設定しています。 )
    _selectController = AnimationController(
        duration: const Duration(milliseconds: 300), vsync: this);
    final Animation<double> easeSelection = CurvedAnimation(
      parent: _selectController,
      curve: Curves.easeIn, //アニメーションの開始をゆっくりし、終了に向かって速くする効果
    );
    //Tween を使用して、スケール(サイズ)を 1.0 から 0.85 へと縮小するアニメーションを作成します。
    //easeSelection を渡して、カーブが適用されたアニメーションを生成しています。
    //このアニメーションは、選択時にウィジェットが少し縮むエフェクトを提供します。
    _stackScaleAnimation =
        Tween<double>(begin: 1.0, end: 0.85).animate(easeSelection);  //縮小するアニメーション
    _checkScaleAnimation =
        Tween<double>(begin: 0.0, end: 1.25).animate(easeSelection);  //拡大するアニメーション
    _checkSelectedOpacityAnimation =
        Tween<double>(begin: 0.0, end: 1.0).animate(easeSelection);   //透明度を下げるアニメーション
    //画像の位置アニメーション:
    //( RelativeRectTween を使用して、画像の位置を少し内側に移動させるアニメーションです
    //  begin では四辺の余白が 0 の位置から、end では四辺に 12 ピクセルの余白を追加するように指定しています
    //  このアニメーションは、選択時に画像が内側に少し縮むような効果を与え、選択状態を視覚的に強調します )
    _imagePositionAnimation = RelativeRectTween(
      begin: const RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
      end: const RelativeRect.fromLTRB(12.0, 12.0, 12.0, 12.0),
    ).animate(easeSelection);

    //差し替え AnimationController の初期化
    _replaceController = AnimationController(
        duration: const Duration(milliseconds: 300), vsync: this);

    final Animation<double> easeInsert = CurvedAnimation(
      parent: _replaceController,
      curve: Curves.easeIn,
    );

    _replaceNewPhotoAnimation = Tween<Offset>(
      begin: const Offset(1.0, 0.0),
      end: Offset.zero,
    ).animate(easeInsert);

    _replaceOldPhotoAnimation = Tween<Offset>(
      begin: const Offset(0.0, 0.0),
      end: const Offset(-1.0, 0.0),
    ).animate(easeInsert);

    _removeCheckAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
      CurvedAnimation(
        parent: _replaceController,
        curve: const Interval(0.0, 0.25, curve: Curves.easeIn),
      ),
    );
  }

  @override
  void dispose() {
    _selectController.dispose();
    _replaceController.dispose();
    super.dispose();
  }

  //Flutter ウィジェットの didUpdateWidget メソッドをオーバーライドしており、
  //PhotoItem ウィジェットの状態が更新されたときに特定のアニメーションを実行する仕組みになっています。
  //( 親ウィジェットから渡される widget プロパティが更新されたときに呼び出されます
  //  oldWidget は更新前のウィジェットの状態を保持しており、新しい widget と比較することで、何が変わったかを判断できます。
  //  ここでは、PhotoItem ウィジェットのプロパティである photo や selected が変更されたかどうかを確認し、
  //  変更があった場合にアニメーションを開始します )
  @override
  void didUpdateWidget(PhotoItem oldWidget) {
    super.didUpdateWidget(oldWidget);

    //新しい写真が選択された場合、アニメーションを実行
    if (widget.photo != oldWidget.photo) {
      _replace(oldWidget.photo, widget.photo);
    }

    //選択状態が変更された場合にアニメーションを実行
    if (widget.selected != oldWidget.selected) _select();
  }

  //古い写真と新しい写真を差し替えるアニメーション
  Future<void> _replace(Photo oldPhoto, Photo newPhoto) async {
    try {
      setState(() {
        _oldPhoto = oldPhoto;
        _newPhoto = newPhoto;
      });
      //_replaceController.forward():
      //( アニメーションを開始するためのメソッドです。
      //  このメソッドは、アニメーションが「進行方向」に再生されることを意味します
      //  アニメーションが完了すると次の処理が実行されますが、途中でキャンセルされることもあります。
      //  ここで .orCancel が登場します。 )
      //( .orCancel は、AnimationController の forward() メソッドや reverse() メソッドに対して使える追加のオプションです。
      //  この .orCancel を付けることで、アニメーションがキャンセルされた場合
      //  (例えば、ウィジェットが破棄される、もしくは dispose メソッドが呼ばれてアニメーションが停止されるなど)、
      //  例外 TickerCanceled が発生します。)
      await _replaceController.forward().orCancel;
      setState(() {
        _oldPhoto = newPhoto;
        _newPhoto = null; //新しい写真のアニメーションが完了したことを示します。
        //0.0 にリセットすることで、アニメーションの位置を初期化し、次回のアニメーションに備えます。
        _replaceController.value = 0.0;
        _selectController.value = 0.0;
      });
    } on TickerCanceled {
      //アニメーションがキャンセルされた場合の処理(未使用)
    }
  }

  //選択状態のアニメーションを実行
  void _select() {
    if (widget.selected) {
      _selectController.forward(); //アニメーションを前進させる
    } else {
      _selectController.reverse();  //アニメーションを後退させる
    }
  }

  //このコードは、PhotoItem ウィジェットの build メソッドで、
  //写真を表示しつつ選択・差し替えのアニメーションを実行するための実装です。
  //写真の位置、スケール、チェックマークの表示、透明度などをアニメーションさせています。
  //( Stack ウィジェットを使用して、複数のウィジェット(写真やチェックマーク)を重ねて表示しています。
  //  Positioned.fill や ClipRect を使ってレイアウトを調整し、アニメーションを組み合わせています )
  @override
  Widget build(BuildContext context) {
    //Stack:
    //Stack ウィジェットは、複数の子ウィジェットを重ねるために使います。
    //( ここでは、2つの Positioned.fill ウィジェットを使って、
    //  古い写真と新しい写真をそれぞれ表示・アニメーションさせています )
    return Stack(
      children: <Widget>[
        //最初の Positioned.fill は、古い写真のアニメーションやチェックマーク、その他の装飾を含んでいます。
        Positioned.fill(
          //ClipRect:
          //( 子ウィジェットを矩形(四角形)でクリップするために使用されます。
          //  アニメーションの動作範囲を制限するために利用しています )
          child: ClipRect(
            //SlideTransition:
            //( _replaceOldPhotoAnimation を使って、古い写真がスライドするアニメーションを実現しています。
            //  SlideTransition の position プロパティに _replaceOldPhotoAnimation を設定し、
            //  写真が動く位置を制御します )
            child: SlideTransition(
              position: _replaceOldPhotoAnimation,
              child: Material(
                color: widget.color,
                //タップ可能領域
                child: InkWell(
                  onTap: widget.onTap,
                  //ScaleTransition:
                  //( _stackScaleAnimation を使って、写真のスケール(拡大・縮小)アニメーションを実行します。
                  //  選択時に縮小される動きが実現されます )
                  child: ScaleTransition(
                    scale: _stackScaleAnimation,
                    child: Stack(
                      children: <Widget>[
                        PositionedTransition( //画像の位置アニメーション
                          rect: _imagePositionAnimation,
                          child: Image.asset(
                            _oldPhoto.asset,
                            fit: BoxFit.cover,
                          ),
                        ),
                        Positioned(
                          top: 0.0,
                          left: 0.0,
                          child: FadeTransition(
                            opacity: _checkSelectedOpacityAnimation,
                            child: FadeTransition(
                              opacity: _removeCheckAnimation,
                              child: ScaleTransition(
                                alignment: Alignment.topLeft,
                                scale: _checkScaleAnimation,
                                child: const PhotoCheck(),
                              ),
                            ),
                          ),
                        ),
                        PositionedTransition(
                          rect: _imagePositionAnimation,
                          child: Container(
                            margin: const EdgeInsets.all(8),
                            alignment: Alignment.topRight,
                            child: Text(
                              widget.photo.id.toString(),
                              style: const TextStyle(color: Colors.green),
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
        //新しい写真が差し替えられるときのアニメーション
        Positioned.fill(
          child: ClipRect(
            child: SlideTransition(
              position: _replaceNewPhotoAnimation,
              child: _newPhoto == null
                  ? null
                  : Image.asset(
                      _newPhoto!.asset,
                      fit: BoxFit.cover,
                    ),
            ),
          ),
        ),
      ],
    );
  }
}

//メインのイメージデモウィジェット
class ImagesDemo extends StatefulWidget {
  const ImagesDemo({super.key});

  @override
  State<ImagesDemo> createState() => _ImagesDemoState();
}

class _ImagesDemoState extends State<ImagesDemo>
    with SingleTickerProviderStateMixin {
  static const double _photoBlockHeight = 576.0;

  int? _selectedPhotoIndex;

  void _selectPhoto(int photoIndex) {
    setState(() {
      _selectedPhotoIndex =
          photoIndex == _selectedPhotoIndex ? null : photoIndex;
    });
  }

  void _removeSelectedPhoto() {
    if (_selectedPhotoIndex == null) return;
    setState(() {
      allPhotos.removeAt(_selectedPhotoIndex!);
      _selectedPhotoIndex = null;
    });
  }

  //指定されたフレームに基づいて、写真ブロックを構築
  //( このコードは、指定されたフレームに基づいて写真ブロックを構築する _buildPhotoBlock メソッドです。
  //  photoBlockFrames で定義されたレイアウトに従って、複数の行と列に写真アイテムを並べます。
  //  このメソッドは、PhotosDemo ウィジェットの一部で、各写真ブロックがどのように表示されるかを構成しています )
  //blockFrameCount:
  //( 各写真ブロックに含まれる写真の数です )
  // このメソッドでは、各写真ブロックのレイアウトを photoBlockFrames に基づいて構築しています。
  // 行ごとにフレーム設定を適用し、それぞれの行に写真アイテムを追加することで、柔軟なレイアウトを実現しています。
  // 写真は選択可能で、タップすると選択状態が切り替わる仕組みになっています。
  Widget _buildPhotoBlock(
      BuildContext context, int blockIndex, int blockFrameCount) {
    //rows の初期化:
    //( 写真を行ごとに格納する Row ウィジェットのリストです。
    //  最終的にこの rows が Column に追加されて、ブロック全体が完成します )
    final List<Widget> rows = [];

    //startPhotoIndex は、現在のブロック内の最初の写真のインデックスです。
    //( blockIndex と blockFrameCount を使って計算されます )
    var startPhotoIndex = blockIndex * blockFrameCount;

    final photoColor = Colors.grey[500]!;
    
    //photoBlockFrames で定義された各行に対してループを行います。
    //( photoBlockFrames は写真ブロックのレイアウトフレームを定義しており、
    //  行ごとに異なるフレーム構成を持つことができます )
    for (int rowIndex = 0; rowIndex < photoBlockFrames.length; rowIndex += 1) {
      final List<Widget> rowChildren = [];
      //rowLength は、現在の行に含まれるフレームの数です。
      final int rowLength = photoBlockFrames[rowIndex].length;

      //各行内のフレームに対してループを行います。
      //( 各フレームは PhotoFrame で定義された幅と高さを持っています )
      for (var frameIndex = 0; frameIndex < rowLength; frameIndex += 1) {
        final frame = photoBlockFrames[rowIndex][frameIndex];
        final photoIndex = startPhotoIndex + frameIndex;
        //rowChildren:
        //( 現在の行に配置する写真アイテム(PhotoItem ウィジェット)のリストです )
        rowChildren.add(
          //Expanded ウィジェットを使って、フレームの幅に応じた可変サイズのアイテムを追加します。
          Expanded(
            //flex プロパティに frame.width * 100 を整数で設定し、写真の相対幅を指定します。
            flex: (frame.width * 100).toInt(),
            child: Container(
              padding: const EdgeInsets.all(4),
              //Container の height プロパティには、フレームの高さと _photoBlockHeight を掛けた値が指定され、
              //写真の相対高さが決まります。
              height: frame.height * _photoBlockHeight,
              //PhotoItem ウィジェットが Container 内に配置され、写真アイテムを表示します。
              child: PhotoItem(
                photo: allPhotos[photoIndex],
                color: photoColor,
                //selected パラメータは、_selectedPhotoIndex に基づいて選択状態を設定します。
                selected: photoIndex == _selectedPhotoIndex,
                //onTap コールバックで、_selectPhoto メソッドを呼び出し、写真がタップされたときに選択状態を変更します。
                onTap: () {
                  _selectPhoto(photoIndex);
                },
              ),
            ),
          ),
        );
      }
      //Row の追加:
      //( rowChildren リストに含まれる PhotoItem ウィジェット群を Row に追加し、その行全体を rows に追加します )
      rows.add(Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: rowChildren,
      ));
      //rowLength は、現在の行に含まれるフレームの数です。
      //( startPhotoIndex を更新し、次の行に進むための準備をします )
      startPhotoIndex += rowLength;
    }

    //写真アイテムを含む Column ウィジェットを返します。
    //( 最終的に、rows を含む Column ウィジェットを返します。これが各写真ブロックの表示レイアウトになります )
    return Column(children: rows);
  }

  //このコードは、ImagesDemo ウィジェットの build メソッドで、
  //写真のリストをスクロール可能なリストとして表示する画面のレイアウトを構築しています。
  //( アニメーションの速度を調整し、ListView.builder を使って、写真のブロックを縦方向に並べて表示します )
  @override
  Widget build(BuildContext context) {

    //timeDilation はアニメーションの速度を調整するための設定です。
    //( この場合、アニメーションが通常の20倍遅くなり、スローモーションのようにゆっくり動作するようになります。
    //  これはアニメーションの詳細を確認したいときに役立ちます。デバッグ用途として使われることが多いです )
    timeDilation = 20.0; //アニメーションの速度を20倍に遅くする

    //各写真ブロック内の写真の数を計算
    //( .map((l) => l.length) で各行の写真の数を取得し、
    //  それを .reduce((s, n) => s + n) で合計しています )
    final int photoBlockFrameCount =
        photoBlockFrames.map((l) => l.length).reduce((s, n) => s + n);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Images Demo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: _removeSelectedPhoto,
          ),
        ],
      ),
      //SizedBox.expand を使って、ListView.builder によってスクロール可能なリストを作成し、
      //画面全体に表示します。
      body: SizedBox.expand(
        //ListView.builder: スクロール可能なリストを動的に生成するウィジェットです。
        //( 大量のデータを表示する際にメモリ効率が良く、
        //  必要に応じてリストアイテムを再利用する仕組みを持っています )
        child: ListView.builder(
          padding: const EdgeInsets.all(4), //リストの周囲に4ピクセルの余白を設定します。
          itemExtent: _photoBlockHeight,  //各リスト項目の高さ
          itemCount: (allPhotos.length / photoBlockFrameCount).floor(),
          itemBuilder: (context, blockIndex) {
            return _buildPhotoBlock(context, blockIndex, photoBlockFrameCount);
          },
        ),
      ),
    );
  }
}

コメントを残す