🥓

PageView と page_indicator で作る説明画面を Stateful widget と Riverpod で作り比べてみた

2021/02/09に公開

作り比べてみました

TL;DR

最終的な画面

見た目は↑の参考記事と同じです

最終的な lib 配下

使うパッケージ

プロジェクトの準備

作るプロジェクト名は flutter_practice10 にしてます。
flutter create して linter 用の analysis_options.yaml を用意します。

flutter create flutter_practice10
cd flutter_practice10
touch analysis_options.yaml

pubspec.yaml と analysis_options.yaml はこんな感じで。

pubspec.yaml
name: flutter_practice10
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.0
  flutter_page_indicator: ^0.0.3
  flutter_riverpod: ^0.12.3+1

dev_dependencies:
  pedantic_mono: any
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true
analysis_options.yaml
include: package:pedantic_mono/analysis_options.yaml

説明画面 widget を用意する

PageView.children に渡す widget として
この部分を独自 class に切り出して用意しておきます。

詳しい仕組み(?)は方法はこの記事に書きました

mkdir -p lib/views/widgets
touch lib/views/widgets/instruction_widget.dart
lib/views/widgets/instruction_widget.dart
import 'package:flutter/material.dart';

class Instruction extends Column {
  Instruction(
      BuildContext context, String title, String image, String instruction)
      : super(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            SizedBox(
              height: 150,
              child: Center(
                child: Text(
                  title,
                  style: const TextStyle(
                    fontSize: 24,
                    color: Colors.black54,
                  ),
                ),
              ),
            ),
            SizedBox(
              width: 300,
              height: 200,
              child: FlatButton(
                color: Colors.black54,
                onPressed: () {
                  Navigator.pushNamed(context, '/test');
                },
                child: Text(image),
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(
                top: 30,
              ),
              child: SizedBox(
                width: 300,
                child: Text(
                  instruction,
                  style: const TextStyle(
                    fontSize: 14,
                    color: Colors.black54,
                  ),
                ),
              ),
            ),
          ],
        );
}

特に難しいことはやってないです。

  • 縦に並べるので Column を継承
  • mainAxisAlignment: MainAxisAlignment.start, で上寄せ
  • SizedBox で各要素の大きさを指定しつつ並べる
  • 一番下の説明文章は Padding を使って余白を開けている
  • 実際のプロダクトに使うときは中央の灰色塗りつぶしを画像とかにするといいんじゃないでしょうか

Stateful widget でつくる

PageViewでのページ切り替えの管理は
PageController がやってくれるのですが、
page_indicator(ページを表す丸ぽち)とボタンの表示切り替えを管理する
isFinalPage は状態として管理してやる必要があります。

ここの表示切り替えですね。

Stateful widget での実装ではこの isFinalPage の状態管理に setState() を使います。

mkdir -p lib/views/screens
touch lib/views/screens/stateful_instruction_page.dart
stateful_instruction_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_page_indicator/flutter_page_indicator.dart';
import '../widgets/instruction_widget.dart';

class StatefulInstructionPage extends StatefulWidget {
  
  _StatefulInstructionPage createState() => _StatefulInstructionPage();
}

class _StatefulInstructionPage extends State<StatefulInstructionPage> {
  PageController pageController;
  bool isFinalPage = false;

  void changePageNumber(int page, int pageLength) {
    setState(() {
      if (page + 1 == pageLength) {
        isFinalPage = true;
      } else {
        isFinalPage = false;
      }
    });
  }

  
  void initState() {
    pageController = new PageController();
    super.initState();
  }

  
  void didUpdateWidget(StatefulInstructionPage oldWidget) {
    super.didUpdateWidget(oldWidget);
  }

  
  Widget build(BuildContext context) {
    final pageList = <Widget>[
      Instruction(
        context,
        'アプリの説明1アプリの説明1',
        '1枚目',
        'アプリの説明1アプリの説明1アプリの説明1アプリの説明1',
      ),
      Instruction(
        context,
        'アプリの説明2アプリの説明2',
        '2枚目',
        'アプリの説明2アプリの説明2アプリの説明2アプリの説明2',
      ),
      Instruction(
        context,
        'アプリの説明3アプリの説明3',
        '3枚目',
        'アプリの説明3アプリの説明3アプリの説明3アプリの説明3',
      ),
      Instruction(
        context,
        'アプリの説明4アプリの説明4',
        '4枚目',
        'アプリの説明4アプリの説明4アプリの説明4アプリの説明4',
      ),
    ];

    return Scaffold(
      appBar: AppBar(
        title: const Text('アプリ説明'),
      ),
      body: Column(
        /* Expanded は Column, Row, Flex のいずれかにラップされなければならない */
        children: <Widget>[
          /* PageViewの切り替え制御を全画面で行うためにExpanded */
          Expanded(
            /* 全画面のPageView, その上にPageIndicator(と最終ページのボタン)を重ねるためにStack */
            child: Stack(
              children: <Widget>[
                PageView(
                  scrollDirection: Axis.horizontal,
                  controller: pageController,
                  children: pageList,
                  onPageChanged: (pageCount) {
                    changePageNumber(pageCount, pageList.length);
                  },
                ),
                Align(
                  alignment: const Alignment(0, 0.25),
                  child: AnimatedOpacity(
                    opacity: isFinalPage ? 0.0 : 1.0,
                    duration: const Duration(microseconds: 500),
                    child: PageIndicator(
                      layout: PageIndicatorLayout.NONE,
                      size: 20,
                      activeSize: 30,
                      controller: pageController,
                      space: 20,
                      count: 4,
                      color: Colors.black,
                      activeColor: Colors.red,
                    ),
                  ),
                ),
                Align(
                  alignment: const Alignment(0, 0.25),
                  child: AnimatedOpacity(
                    opacity: isFinalPage ? 1.0 : 0.0,
                    duration: const Duration(microseconds: 500),
                    child: SizedBox(
                      width: 300,
                      height: 50,
                      child: RaisedButton(
                        color: Colors.blue,
                        onPressed: () {
                          Navigator.of(context).pop();
                        },
                        shape: const RoundedRectangleBorder(
                          borderRadius: BorderRadius.all(
                            Radius.circular(5),
                          ),
                        ),
                        child: const Text(
                          'はじめる',
                          style: TextStyle(
                            fontSize: 20,
                            color: Colors.white,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

コード内にも少しコメントを入れているので大体わかると思います。

  • changePageNumber()isFinalPage の状態を更新
  • pageListPageView.children を事前に定義
  • ExpandedColumn, Row, Flex のいずれかにラップされなければならないので Column でラップ
  • PageView の切り替え制御を全画面で行うために Expanded を使用
  • widget を重ねるために Stack
    • 以下の順で重なってる
      • PageView が一番下
      • その上に 丸ぽち
      • その上に 「はじめる」のボタン
        • 丸ぽちとボタンは実際は isFinalPageAnimatedOpacity で表示を切り替えしてるので重なってるというより表示/非表示の制御
        • 丸ぽちとボタンは Alignment で中央やや下に配置することで PageView 部分の下に表示されるように見せている

Riverpod でつくる

PageViewでのページ切り替えの管理は
PageController がやってくれるのですが、
page_indicator(ページを表す丸ぽち)とボタンの表示切り替えを管理する
isFinalPage は状態として管理してやる必要があるのは Stateful widget での実装と同じです。

Riverpod での実装ではこの isFinalPage の状態管理に
StateProviderConsumer を使います。

touch lib/views/screens/riverpod_instruction_page.dart
lib/views/screens/riverpod_instruction_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_page_indicator/flutter_page_indicator.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/instruction_widget.dart';

final isFinalPageProvider = StateProvider<bool>((ref) => false);

class RiverpodInstructionPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final pageController = new PageController();

    final pageList = <Widget>[
      Instruction(
        context,
        'アプリの説明1アプリの説明1',
        '1枚目',
        'アプリの説明1アプリの説明1アプリの説明1アプリの説明1',
      ),
      Instruction(
        context,
        'アプリの説明2アプリの説明2',
        '2枚目',
        'アプリの説明2アプリの説明2アプリの説明2アプリの説明2',
      ),
      Instruction(
        context,
        'アプリの説明3アプリの説明3',
        '3枚目',
        'アプリの説明3アプリの説明3アプリの説明3アプリの説明3',
      ),
      Instruction(
        context,
        'アプリの説明4アプリの説明4',
        '4枚目',
        'アプリの説明4アプリの説明4アプリの説明4アプリの説明4',
      ),
    ];

    return Consumer(
      builder: (context, watch, child) {
        final isFinalPage = watch(isFinalPageProvider);

        return Scaffold(
          appBar: AppBar(
            title: const Text('Riverpod版アプリ説明'),
          ),
          body: Column(
            /* Expanded は Column, Row, Flex のいずれかにラップされなければならない */
            children: <Widget>[
              /* PageViewの切り替え制御を全画面で行うためにExpanded */
              Expanded(
                /* 全画面のPageView, その上にPageIndicator(と最終ページのボタン)を重ねるためにStack */
                child: Stack(
                  children: <Widget>[
                    PageView(
                      scrollDirection: Axis.horizontal,
                      controller: pageController,
                      children: pageList,
                      onPageChanged: (pageCount) {
                        if (pageCount + 1 == pageList.length) {
                          isFinalPage.state = true;
                        } else {
                          isFinalPage.state = false;
                        }
                      },
                    ),
                    Align(
                      alignment: const Alignment(0, 0.25),
                      child: AnimatedOpacity(
                        opacity: isFinalPage.state ? 0.0 : 1.0,
                        duration: const Duration(microseconds: 500),
                        child: PageIndicator(
                          layout: PageIndicatorLayout.NONE,
                          size: 20,
                          activeSize: 30,
                          controller: pageController,
                          space: 20,
                          count: 4,
                          color: Colors.black,
                          activeColor: Colors.red,
                        ),
                      ),
                    ),
                    Align(
                      alignment: const Alignment(0, 0.25),
                      child: AnimatedOpacity(
                        opacity: isFinalPage.state ? 1.0 : 0.0,
                        duration: const Duration(microseconds: 500),
                        child: SizedBox(
                          width: 300,
                          height: 50,
                          child: RaisedButton(
                            color: Colors.blue,
                            onPressed: () {
                              Navigator.of(context).pop();
                            },
                            shape: const RoundedRectangleBorder(
                              borderRadius: BorderRadius.all(
                                Radius.circular(5),
                              ),
                            ),
                            child: const Text(
                              'はじめる',
                              style: TextStyle(
                                fontSize: 20,
                                color: Colors.white,
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

widget の構成はほぼ Stateful widget の実装と同じです。

  • 状態管理を StateProviderConsumer でやるのでベースは StatelessWidget
  • final isFinalPageProvider = StateProvider<bool>((ref) => false); で bool値の StateProvider を用意
  • Consumer 配下の final isFinalPage = watch(isFinalPageProvider);StateProvider を監視
  • isFinalPage.state を set/get することで状態を変更/反映

アプリとして両方呼び出して試せるように仕上げ

PageView と page_indicator で作る説明画面を Stateful widget と Riverpod で作り比べてみる話としてはこれで終わりなのですが、
せっかくなのでアプリとして両方呼び出して試せるようにしてみましょう。

MyApp
 ┗ 呼び出し元(RootPage)
   ┣ StatefulInstructionPage(「はじめる」でRootPageに戻る)
   ┗ RiverpodInstructionPage(「はじめる」でRootPageに戻る)

こんな感じのイメージです。
MyApp の MaterialApp.routes でルート定義をしておいて、
Navigator.of(context).pushNamed(<呼び出すルート名>); で遷移、
Navigator.of(context).pop(); で呼び出し元に戻ります。

呼び出し元の RootPage はこんな感じに。

touch lib/views/screens/root_page.dart
lib/views/screens/root_page.dart
import 'package:flutter/material.dart';

class RootPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Root Page'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            RaisedButton(
              child: const Text('StatefulWidget Instruction Page'),
              onPressed: () {
                Navigator.of(context).pushNamed('stateful');
              },
            ),
            RaisedButton(
              child: const Text('Riverpod Instruction Page'),
              onPressed: () {
                Navigator.of(context).pushNamed('riverpod');
              },
            ),
          ],
        ),
      ),
    );
  }
}

最後に main.dart でルーティング定義をしてやって終わりです。
ProviderScope() でラップするのを忘れないように。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_practice10/views/screens/root_page.dart';
import 'package:flutter_practice10/views/screens/stateful_instruction_page.dart';
import 'package:flutter_practice10/views/screens/riverpod_instruction_page.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

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

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(
        title: 'PageView Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        initialRoute: '/',
        routes: {
          '/': (_) => RootPage(),
          'stateful': (_) => StatefulInstructionPage(),
          'riverpod': (_) => RiverpodInstructionPage(),
        },
      ),
    );
  }
}

以上です。おつかれさまでした。

まとめ

実装にそんなに大差出ないですね。
この程度の単純な状態管理なら StatefulWidget でやった方がいいわ! と考えるか
プロジェクトとしての状態管理手法が色々あると煩雑になるから
Riverpod で全部状態管理するように作るわ! と考えるかは
実装の方針次第かなと思います。

今回のリポジトリはこちらです。
https://github.com/JUNKI555/flutter_practice10

Discussion