🐕

【Flutter】画面の一部を画面遷移させる

2024/01/10に公開

はじめに

タイトルの通り、画面の一部を画面遷移させたくて調べたことをまとめました。
今回は、以下のように2分割した画面をそれぞれ画面遷移できるようにします。
(右下に表示されるボタンの色とアイコンをそれぞれの画面で設定しています。)

また、実現方法として

の2つを紹介します。見た目はどちらもあまり変わらないと思います。
パフォーマンス面等で違いがありそうですが、深いところまでは調べていませんので悪しからず。。。

(Flutterバージョン)

> flutter --version
Flutter 3.16.5 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 78666c8dc5 (3 weeks ago) • 2023-12-19 16:14:14 -0800
Engine • revision 3f3e560236
Tools • Dart 3.2.3 • DevTools 2.28.4

画面構成

ざっくりこんな感じです。

基本画面の実装

各画面の実装を見たい場合は以下もしくはGitHubリポジトリを確認してください。

各画面の実装
lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const 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: 'ボタン作成'),
    );
  }
}

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  Color buttonColor = Colors.blue;
  Icon buttonIcon = const Icon(Icons.add);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Expanded(
              child: xxxx   // TODO:色選択の画面遷移を管理するWidget
            ),
            Expanded(
              child: xxxx   // TODO:アイコン選択の画面遷移を管理するWidget
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        backgroundColor: buttonColor,
        child: buttonIcon,
      ),
    );
  }
}
  • 色選択画面
lib/color_select_page.dart
import 'package:flutter/material.dart';

class ColorSelectPage extends StatelessWidget {
  const ColorSelectPage({super.key, required this.onSelect});

  // ListTileタップ時に、タップした色と名前を通知するコールバック
  final void Function(Color color, String colorName) onSelect;

  static const _selectableColors = {
    "red": Colors.red,
    "orange": Colors.orange,
    "yellow": Colors.yellow,
    "lightGreen": Colors.lightGreen,
    "green": Colors.green,
    "blue": Colors.blue,
    "purple": Colors.purple,
  };

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.onInverseSurface,
        title: const Text('色選択'),
      ),
      body: Center(
        child: ListView(
          children: [
            for (final color in _selectableColors.entries)
	    // 各色のListTile
            ListTile(
              title: Text(color.key),
              trailing: const Icon(Icons.arrow_forward_ios),
              onTap: () => onSelect(color.value, color.key),
            )
          ],
        ),
      ),
    );
  }
}
  • 色詳細画面
lib/color_select_page.dart
import 'package:flutter/material.dart';

class ColorDetailPage extends StatelessWidget {
  const ColorDetailPage({
    super.key,
    required this.onSelect,
    required this.color,
    required this.colorName,
  });

  final void Function() onSelect;
  final Color color;
  final String colorName;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.onInverseSurface,
        title: const Text('色詳細'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
	    // 実際に色を表示
            Expanded(
              flex: 2,
              child: Container(color: color,),
            ),
	    // 色名
            Expanded(
              flex: 1,
              child: Center(
                child: Text(colorName),
              ),
            ),
	    // ボタン
            Expanded(
              flex: 1,
              child: Center(
                child: TextButton(
                  onPressed: onSelect,
                  child: const Text('Set'),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
  • アイコン選択画面
lib/color_select_page.dart
import 'package:flutter/material.dart';

class IconSelectPage extends StatelessWidget {
  const IconSelectPage({super.key, required this.onSelect});

  // ListTileタップ時に、タップしたアイコンと名前を通知するコールバック
  final void Function(Icon icon, String iconName) onSelect;

  static const _selectableIcons = {
    "add": Icon(Icons.add),
    "edit": Icon(Icons.edit),
    "settings": Icon(Icons.settings),
    "zoom_in": Icon(Icons.zoom_in),
    "zoom_out": Icon(Icons.zoom_out),
    "soccer": Icon(Icons.sports_soccer),
  };

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.onInverseSurface,
        title: const Text('アイコン選択'),
      ),
      body: Center(
        child: ListView(
          children: [
            for (final icon in _selectableIcons.entries)
	    // 各アイコンのListTile
            ListTile(
              title: Text(icon.key),
              trailing: const Icon(Icons.arrow_forward_ios),
              onTap: () => onSelect(icon.value, icon.key),
            )
          ],
        ),
      ),
    );
  }
}
  • アイコン詳細画面
lib/color_select_page.dart
import 'package:flutter/material.dart';

class IconDetailPage extends StatelessWidget {
  const IconDetailPage({
    super.key,
    required this.onSelect,
    required this.icon,
    required this.iconName,
  });

  final void Function() onSelect;
  final Icon icon;
  final String iconName;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.onInverseSurface,
        title: const Text('アイコン詳細'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
	    // アイコン拡大表示
            Expanded(
              flex: 2,
              child: FittedBox(
                child: Icon(
                  icon.icon,
                  size: 100,
                ),
              ),
            ),
	    // アイコン名
            Expanded(
              flex: 1,
              child: Center(
                child: Text(iconName),
              ),
            ),
	    // ボタン
            Expanded(
              flex: 1,
              child: Center(
                child: TextButton(
                  onPressed: onSelect,
                  child: const Text('Set'),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

自作した画面(ColorSelectPageColorDetailPageIconSelectPageIconDetailPage)を管理するNavigatorをそれぞれ作成し、MyHomePageColumn(のExpand)のchildに指定してあげます。

lib/color_page_navigator.dart
class ColorPageNavigator extends StatelessWidget {
  /* ... */
  
  Widget build(BuildContext context) {
    return Navigator(
      onGenerateRoute: ((settings) {
        return PageRouteBuilder(
          pageBuilder: (context, animation, secondaryAnimation) {
            return ColorSelectPage(
	      /* ... */
            );
          }
        );
      }),
    );
  }
}
lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      /* ... */
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Expanded(
+              child: ColorPageNavigator(
+                /* ... */
+              ),
  /* ... */
}

あとはSelectPageListTileが押されたらDetailPageをpush、DetailPageのボタンが押されたらpopしてあげれば良いです。
(※以下、onSelectは自作)

lib/color_page_navigator.dart
class ColorPageNavigator extends StatelessWidget {
  /* ... */
+  // 色詳細ページをpushする
+  void _pushDetailPage(BuildContext context, Color color, String colorName) {
+    Navigator.push(
+      context,
+      PageRouteBuilder(
+        pageBuilder: (context, animation, secondaryAnimation) {
+          return ColorDetailPage(
+            color: color,
+            colorName: colorName,
+            // 色詳細ページでボタンが押されたらpopするようにする
+            onSelect: () {
+              Navigator.pop(context);
+            }
+          );
+        },
+      ),
+    );
+  }

  
  Widget build(BuildContext context) {
    return Navigator(
      onGenerateRoute: ((settings) {
        return PageRouteBuilder(
          pageBuilder: (context, animation, secondaryAnimation) {
            return ColorSelectPage(
+	      // 色選択画面のListTileが押されたら_pushDetailPageが呼ばれるようにする
+              onSelect: (color, colorName) => _pushDetailPage(context, color, colorName)
            );
          }
        );
      }),
    );
  }
}

仕組みは簡単ですが、NavigatoronGenerateRoute引数でMaterialPageRouteを返してしまうと画面全体を覆うRouteを返すことになってしまって目的に沿わないので注意です。

AnimatedSwitcherを用いる

AnimatedSwitcherは、childに指定したWidgetが変更される際のアニメーションを指定することができます。
setState()を呼んでWidgetを変更することでいい感じにアニメーションしてくれます。
これにより、見た目上はあたかも画面遷移しているように見えます。

lib/color_page_switcher.dart
import 'package:flutter/material.dart';
import 'package:navigator_part_of_screen/color_detail_page.dart';
import 'package:navigator_part_of_screen/color_select_page.dart';

enum ColorPage {
  select,
  detail,
}

// Widgetを変更することで画面遷移のように見せる。そのため、StatefulWidgetを使う
class ColorPageSwitcher extends StatefulWidget {
  const ColorPageSwitcher({super.key, required this.onSelect});

  final void Function(Color color) onSelect;

  
  State<ColorPageSwitcher> createState() => _ColorPageSwitcherState();
}

class _ColorPageSwitcherState extends State<ColorPageSwitcher> {
  ColorPage _currentPage = ColorPage.select;
  Color _color = Colors.blue;
  String _colorName = 'blue';

  
  Widget build(BuildContext context) {
    Widget page;

    switch (_currentPage) {
      case ColorPage.select:
        page = ColorSelectPage(
	  // ListTileをタップしたとき_currentPageを変更する
          onSelect: (color, colorName) => setState(() {
            _color = color;
            _colorName = colorName;
            _currentPage = ColorPage.detail;
          }),
        );
        break;
      case ColorPage.detail:
        page = ColorDetailPage(
	  // ボタンを押したとき_currentPageを変更する
          onSelect: () {
            widget.onSelect(_color);
            setState(() {
              _currentPage = ColorPage.select;
            });
          },
          color: _color,
          colorName: _colorName,
        );
        break;
    }

    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 500),
      transitionBuilder: (Widget child, Animation<double> animation) {
        const Offset begin = Offset(1.0, 0.0);
        const Offset end = Offset.zero;
        final Animatable<Offset> tween = Tween(begin: begin, end: end)
            .chain(CurveTween(curve: Curves.easeInOut));
        final Animation<Offset> offsetAnimation = animation.drive(tween);
        return SlideTransition(
          position: offsetAnimation,
          child: child,
        );
      },
      child: page,
    );
  }
}

Widgetを変更しているので、NavigatorのようにWidgetをスタックさせているわけではありません。
メモリ上にWidgetをたくさん溜めることが無くなるのでこちらで実装する方が良いのかな...?

なお、ここでは書いていませんが戻るためのボタンは自分で作る必要があります。

まとめ

NavigatorAnimatedSwitcherを用いて画面の一部を画面遷移させる(ように見せる)ことができます。
作る際は各画面遷移で矛盾・不都合が起こらないように注意ください。

参考文献

https://stackoverflow.com/questions/50986350/flutter-navigation-for-part-of-screen
https://stackoverflow.com/questions/56724079/how-to-add-fade-in-out-on-state-transition

Discussion