😺

ダイアログ上にListを表示したときに選択位置までスクロールしたい

2023/12/30に公開

記事投稿遅れてしました💦が、Flutter大学 アドベントカレンダー2023 22日目の記事です。

今回は業務で行った機能開発の中で困ったものを備忘録と残そうと思い記事にすることにしました。
どんな実装だったかというと、alertDialogの中にラジオボタンがリストで表示されていて、リストの中から選択してOKボタンで確定し一旦閉じます。もう一度alertDialogを開くと選択した値が見えている範囲のリストの先頭にくるというものです。(ただし、キャンセルボタンを押した時はラジオボタンを変更する前の選択値に戻ります)下の動画のような動きが今回のゴールになります。

実装する時に以下の点に悩みました。実装する時のポイントになると思ったので説明していきます。

  • 選択値を見える位置までスクロールする方法
  • 選択値のスコープ

ダイアログ上にListを表示する

まずは事前準備としてダイアログ上にListを表示できるようにしておきます。
ダイアログ上のListはListViewでも、ListWidgetでもいいのでscrollableなListを作っておきます。
今回はList Widgetで以下のように作ってみました。

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
    // 最終的に決定した時の選択値になる
  int target = 0;
  // 一時的な選択値を保持する
  int tmpTarget = 0;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => showMyDialog(context),
              child: const Text('Show My Dialog'),
            ),
          ],
        ),
      ),
    );
  }

  void showMyDialog(BuildContext context) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return AlertDialog(
          content: StatefulBuilder(builder: (context, setState) {
            return SizedBox(
              height: 550,
              child: Column(
                children: [
                  Expanded(
                    child: SingleChildScrollView(
                      child: Column(
                        children: _myRadioList(
                          tmpTarget,
                          setState,
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 10),
                  SizedBox(
                    height: 50,
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceAround,
                      children: [
                        ElevatedButton(
                          onPressed: () {
			    // 最終的な選択値を一時的な選択値に戻す
                            tmpTarget = target;
                            Navigator.pop(context);
                          },
                          child: const Text('キャンセル'),
                        ),
                        ElevatedButton(
                          onPressed: () {
			    // 一時的な選択値を最終の選択値にする
                            target = tmpTarget;
                            Navigator.pop(context);
                          },
                          child: const Text('OK'),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            );
          }),
        );
      },
    );
  }

  /// 100個まで要素があるラジオボタンのリスト(0~99)
  List<Widget> _myRadioList(
    int currentValue,
    StateSetter setState,
  ) {
    return List.generate(100, (index) {
      /// 選択されたラジオボタン付リストアイテムは目印をつける
      final title =
          index == currentValue ? '$index <<< This is TargetIndex' : '$index';
      return RadioListTile<int>(
        title: Text(title),
        value: index,
        groupValue: currentValue,
        onChanged: (int? value) {
          if (value != null) {
            setState(() {
              tmpTarget = value;
            });
          }
        },
      );
    });
  }
}

ポイントを絞って説明します。

  • AlertDialog内で状態変更をしたい場合、showDialogのドキュメントにもあるようにStatefulBuilderまたはStatefulWidgetを使うことが推奨されています。今回はStatefulBuilderを使用しました。

Use a StatefulBuilder or a custom StatefulWidget if the dialog needs to update dynamically.

https://api.flutter.dev/flutter/material/showDialog.html

  • OKボタンとキャンセルボタンを押した時の動作は以下のようにしてます。
OKボタン押下: 一時的な選択値を最終の選択値にして、アラートを閉じる
キャンセルボタン押下: 最終的な選択値を一時的な選択値に戻して、アラートを閉じる

この時点での動作は以下の動画のようになります。

現時点で選択値の変更はできますが、ダイアログを再度開いた時にリストの順番で表示されてしまい、例えば17を選択したら(0始まりなので)18番目のリストアイテムが選択状態にはなりますが、見える範囲にないためスクロールして探すことになります。

選択値を見える位置までスクロールする方法

選択値を見える位置までスクロールするためには、
WidgetsBinding.instance.addPostFrameCallbackScrollable.ensureVisibleを使います。選択したリストアイテムに目印をつけるためにGlobaKeyを指定します。

まずは修正したコードを確認してみましょう。

void showMyDialog(BuildContext context) {
+   final anchorKey = GlobalKey();
+   // ダイアログが開いたときに、アンカーアイテムを表示する
+   WidgetsBinding.instance.addPostFrameCallback((_) {
+     Scrollable.ensureVisible(anchorKey.currentContext!);
+   });
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return AlertDialog(
          content: StatefulBuilder(builder: (context, setState) {
            return SizedBox(
              height: 550,
              child: Column(
                children: [
                  Expanded(
                    child: SingleChildScrollView(
                      child: Column(
                        children: _myRadioList(
                          tmpTarget,
+                         anchorKey,
                          setState,
                        ),
                      ),
                    ),
                  ),
                  ...省略...
                ],
              ),
            );
          }),
        );
      },
    );
  }
  
  /// 100個まで要素があるラジオボタンのリスト(0~99)
  List<Widget> _myRadioList(
    int currentValue,
+   GlobalKey anchorKey,
    StateSetter setState,
  ) {
    return List.generate(100, (index) {
      /// 選択されたラジオボタン付リストアイテムは目印をつける
      final title =
          index == currentValue ? '$index <<< This is TargetIndex' : '$index';
      return RadioListTile<int>(
+	key: index == currentValue ? anchorKey : null,
        title: Text(title),
        value: index,
        groupValue: currentValue,
        onChanged: (int? value) {
          if (value != null) {
            setState(() {
              tmpTarget = value;
            });
          }
        },
      );
    });
  }
  • WidgetsBinding.instance.addPostFrameCallbackを使ってWidgetのBuildが完了した直後に登録したコールバックを実行させることができます。
void showMyDialog(BuildContext context) {
+   final anchorKey = GlobalKey();
+   // ダイアログが開いたときに、アンカーアイテムを表示する
+   WidgetsBinding.instance.addPostFrameCallback((_) {
+     Scrollable.ensureVisible(anchorKey.currentContext!);
+   });
...

https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html

_myRadioListでリストの番号と選択値の番号が一致していたらGlobalKey(anchorKeyとしている)を割り当てるようにしています。

  List<Widget> _myRadioList(
    int currentValue,
+   GlobalKey anchorKey,
    StateSetter setState,
  ) {
  ...
+	key: index == currentValue ? anchorKey : null,
    ...
  • Scrollable.ensureVisibleは特定のウィジェットをビューポート(現在画面に表示されている領域)内にスクロールして表示させることができます。特定のウィジェットを識別するためにGlobalKeyを割り当てます。

https://api.flutter.dev/flutter/widgets/Scrollable/ensureVisible.html

選択値のスコープを考えて修正する

以上で選択値を見える範囲にスクロールできるようにはなりましたが、StatefulWidgetとStatefulBuilderで実装しているため、選択値(tmpTargettargetと置いた変数)のスコープがStatefulWidget内にスコープが限定されてしまい管理がしにくいです。アラート以外のコンポーネントを置きたいこともあるので状態管理が複雑になりがちになると思います。そこでRiverpodに置き換えてみました。

tmptarget->temporaryTargetProvider
target->targetProvider

のように置き換えることでグローバルに扱えるようになるため、showDialogを別ファイルに置いたり、mixinのメソッドにしたりなど独立させることができます。

Riverpodに置き換えた時のコードは以下のようになります。

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

final numberListProvider = Provider(
  (ref) => List.generate(100, (index) => index),
);

// 最終的に決定した時の選択値になる
final targetProvider = StateProvider<int>((ref) => 0);
// 一時的に選択値を保持する
final temporaryTargetProvider = StateProvider<int>((ref) => 0);

void showMyDialog(BuildContext context) {
  final anchorKey = GlobalKey();
  // ダイアログが開いたときに、アンカーアイテムを表示する
  WidgetsBinding.instance.addPostFrameCallback((_) {
    Scrollable.ensureVisible(anchorKey.currentContext!);
  });
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (BuildContext context) {
      return Consumer(
        builder: (context, ref, _) {
          return AlertDialog(
            content: SizedBox(
              height: 550,
              child: Column(
                children: [
                  Expanded(
                    child: SingleChildScrollView(
                      child: Column(
                        children: _myRadioList(ref, anchorKey),
                      ),
                    ),
                  ),
                  const SizedBox(height: 10),
                  SizedBox(
                    height: 50,
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceAround,
                      children: [
                        ElevatedButton(
                          onPressed: () {
                            // キャンセルしたらダイアログを開いた時の選択値に戻す
                            ref.read(temporaryTargetProvider.notifier).state =
                                ref.read(targetProvider);
                            Navigator.pop(context);
                          },
                          child: const Text('キャンセル'),
                        ),
                        ElevatedButton(
                          onPressed: () {
                            final tempTarget =
                                ref.read(temporaryTargetProvider);
                            ref.read(targetProvider.notifier).state =
                                tempTarget;
                            Navigator.pop(context);
                          },
                          child: const Text('OK'),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      );
    },
  );
}

List<Widget> _myRadioList(
  WidgetRef ref,
  GlobalKey anchorKey,
) {
  final numbers = ref.read(numberListProvider);
  final target = ref.watch(temporaryTargetProvider);

  final List<Widget> numberRadioList = [];
  for (final number in numbers) {
    final title =
        number == target ? '$number <<< This is TargetIndex' : '$number';
    numberRadioList.add(
      RadioListTile<int>(
        key: number == target ? anchorKey : null,
        title: Text(title),
        value: number,
        groupValue: target,
        onChanged: (int? value) {
          if (value != null) {
            ref.read(temporaryTargetProvider.notifier).state = value;
          }
        },
      ),
    );
  }
  return numberRadioList;
}
  • Riverpodを使っているので、StatefulBuilderの代わりにConsumerを使いました。
void showMyDialog(BuildContext context) {
  ...
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (BuildContext context) {
+      return Consumer(
+        builder: (context, ref, _) {
          return AlertDialog(
            content: SizedBox(
              height: 550,
              child: Column(
  • _myRadioList内でList.generate(100, (index) => index)を使ってましたが、Providerとして外に出してみました。実際にはリストでもらってきた値の中から選択することが多いかと思うのでこの方が扱いやすいと思います。
final numberListProvider = Provider(
  (ref) => List.generate(100, (index) => index),
);
  • その他ラジオボタンを選択した時、OKボタンを押した時、キャンセルを押した時の処理をRiverpodに置き換えてます。今回は簡単のためStateProviderにしてみましたが、もっと複雑な状態管理をする場合はStateNotifierProviderやChangeNotifierProviderを使ってもいいかもしれません。

今回実装したサンプルコードをGithubで公開していますので参考にしてください。
https://github.com/king-kazu39/SampleListOnDialog

最後に

アラートダイアログ内のリストを表示したときに選択値を見える位置に表示する方法を紹介しました。StatefulWidgetとStatefulBuilderを使っ他サンプルも書いてみましたが、実際の業務ではRiverpodを使うことが多いと思うので誰かの参考になると嬉しいです。

参考

https://api.flutter.dev/flutter/material/showDialog.html

https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html

https://api.flutter.dev/flutter/widgets/Scrollable/ensureVisible.html

Discussion