実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

画面下のナビゲーションメニュー・Bottom Navigation BarとPageView

はじめに

足元が寒いと冷えますよね。ということで今回はBottom Navigation BarとPageViewについて見ていきたいと思います。これでアプリの足場を固めていきましょう。

Bottom Navigation Barはアプリの下に配置されるスマホでお馴染みの、画面切り替えによく使われるメニューです。今手元でぱっと見ただけでもYoutube、Gmail、Twitter、LINEのどのアプリにもこのナビゲーションが使われています。

PageViewはページをめくる効果を簡単に実現できる仕掛けです。横3画面を行ったり来たりするUIのアプリを簡単に実現できます。作りや考え方はListViewと非常に似ています。ListViewが1画面に複数のウィジェットをまとめて配置する機構であるのに対して、PageViewは1つの画面にページの一部だけを大写しにするような効果とも言えます。

PageViewの使い方を凝っていくと、無限にカードをめくっていくような効果を簡単に実現することができます。そういうUIも人気ですよね。






ボトムナビゲーション

まず今回つくるサンプルアプリの挙動から見ていきたいと思います。基本的に画面下にあるメニューと、これに連動したページの切替に焦点を当てます。

なので各画面自体は簡易なコンテナの表示のみとしています。この各コンテナに色々なUIを載せていくと立派なアプリになりますね。

スクリーンショット

サンプルコードを起動すると最初にこの画面になります。画面下にあるのがナビゲーションバーです。


f:id:linkedsort:20211127102304p:plain:w350

真ん中の「Clock」のところをタップすると、画面が遷移します。


f:id:linkedsort:20211127102327p:plain:w350

右下の「Secret」をタップすると、さらに画面遷移します。期待通りの動きですね。


f:id:linkedsort:20211127102351p:plain:w350

今度は下のナビゲーションバーではなく、画面を横にスワイプして戻ってみます。自分の環境だとChromeブラウザではこのスワイプジェスチャーを検出してくれず(操作が間違っている?)、ここではAndroidエミュレーションを使っています。


f:id:linkedsort:20211127102424p:plain:w350

画面スワイプでページを戻しましたが、連動して下のメニューも「Clock」の方に戻っています。


f:id:linkedsort:20211127102327p:plain:w350

今回はこの動きを作っていきます。


サンプルコード

ではサンプルコードを示していきます。少し長いようですが肝心なのは上半分です。下の半分は3つの画面を生成している部分ですので今回は読み飛ばしてOKです。

状態管理には簡単ポンのGetXを使っています。導入の仕方は下記をご参照ください:


flutter.gakumon.jp

以下がサンプルコードです:

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

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

class Controller extends GetxController {
  //(1) 選択されたタブの番号
  var selected = 0.obs;
}

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

  @override
  Widget build(BuildContext context) {
    //(2) PageViewとBottomBarを連動させるための準備
    final PageController pager = PageController();
    var state = Get.put(Controller());

    return MaterialApp(
        debugShowCheckedModeBanner: false,
        home: Scaffold(
          appBar: AppBar(
            title: const Text("Bottom Menu"),
          ),
          //(3) ページ切替機構
          body: PageView(
            controller: pager,
            children: const <Widget>[
              Home(),
              Clock(),
              Secret(),
            ],
            onPageChanged: (int i) {
              state.selected.value = i;
            },
          ),
          //(4) 下のナビゲーションバー
          bottomNavigationBar: Obx(() => BottomNavigationBar(
                items: const [
                  BottomNavigationBarItem(
                      icon: Icon(Icons.home), label: 'Home'),
                  BottomNavigationBarItem(
                      icon: Icon(Icons.access_alarm_outlined), label: 'Clock'),
                  BottomNavigationBarItem(
                      icon: Icon(Icons.vpn_key), label: 'Secret'),
                ],
                currentIndex: state.selected.value,
                onTap: (int i) {
                  state.selected.value = i;
                  pager.jumpToPage(i);
                },
              )),
        ));
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      child: const Text('Home',
          style: TextStyle(color: Colors.white, fontSize: 32.0)),
      alignment: Alignment.center,
      decoration: const BoxDecoration(
        gradient: LinearGradient(
            begin: Alignment.topRight,
            end: Alignment.bottomLeft,
            colors: [Colors.blueAccent, Colors.white]),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      child: const Text('Clock',
          style: TextStyle(color: Colors.white, fontSize: 32.0)),
      alignment: Alignment.center,
      decoration: const BoxDecoration(
        gradient: LinearGradient(
            begin: Alignment.topRight,
            end: Alignment.bottomLeft,
            colors: [Colors.green, Colors.white]),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      child: const Text('Secret',
          style: TextStyle(color: Colors.white, fontSize: 32.0)),
      alignment: Alignment.center,
      decoration: const BoxDecoration(
        gradient: LinearGradient(
            begin: Alignment.topRight,
            end: Alignment.bottomLeft,
            colors: [Colors.purpleAccent, Colors.white]),
      ),
    );
  }
}

コードのポイント

サンプルコード中の番号を付したポイントについて解説していきます。

(1) GetXコントローラ
class Controller extends GetxController {
  //(1) 選択されたタブの番号
  var selected = 0.obs;
}

GetXによる状態管理のためのコントローラをGetxControllerを継承して作っていきます。この点が不明であれば下記をご参照下さい:


flutter.gakumon.jp

ここでは画面下のメニューの左から何番目(一番左が0)が選択されている状態かを管理します。

(2) PageViewに必要なコントローラを用意
    //(2) PageViewとBottomBarを連動させるための準備
    final PageController pager = PageController();

PageViewは画面をスワイプしてページを切り替えられるUIを提供します。

PageControllerはこの挙動を制御してスワイプ時の画面切り替えアニメーションの動きなどを細かく作り込んでいくときに使える仕掛けです。

ここでは画面下のメニューが押されたときに連動してページを切り替えるために必要ですのでこれを用意しています。

(3) PageViewの設定
  //(3) ページ切替機構
  body: PageView(
    controller: pager,
    children: const <Widget>[
      Home(),
      Clock(),
      Secret(),
    ],
    onPageChanged: (int i) {
      state.selected.value = i;
   },
 ),

ページの切替の設定を記載しています。

controllerは(2)で作ったものをそのまま設定しているだけです。これによって画面下のメニューと連動させます。(4)のところでこのpager値を通じてページを切り替えるコードが出てきます

childrenにウィジェットのリストを与えています。これが各画面のそれぞれの構成になっています。通常1ページが3つのパートに分かれていて、部分的に表示されるという理解でOKです。非常に単純ですね。無限に頁をめくれるようなものの場合はここで一気に生成するのではなく、ListViewと同様、名前付きコンストラクタのbuilderを使います。これについては次回以降詳しくみていきます。

onPageChangedはスワイプ操作でページが切り替わったときに呼び出されるコールバックを指定します。ここでは選択されているページの番号をPageViewからの操作に合わせて更新しています。これに連動して、画面下のナビゲーションバーを更新するためのものです。

(4) 画面したのナビゲーション
  //(4) 下のナビゲーションバー
  bottomNavigationBar: Obx(() => BottomNavigationBar(
        items: const [
           BottomNavigationBarItem(
              icon: Icon(Icons.home), label: 'Home'),
           BottomNavigationBarItem(
              icon: Icon(Icons.access_alarm_outlined), label: 'Clock'),
           BottomNavigationBarItem(
              icon: Icon(Icons.vpn_key), label: 'Secret'),
        ],
        currentIndex: state.selected.value,
        onTap: (int i) {
          state.selected.value = i;
          pager.jumpToPage(i);
        },
    )),

こちらが画面したのナビゲーションバーのコードになります。

itemsはメニューの見た目を作る部分です。各メニュー項目ごとにBottomNavigationBarItemを作っていきます。多少ごちゃごちゃしていますが、見たら使い方は伝わりますよね。

currentIndexは現在選択されているメニューの番号で、ここでアプリ全体の状態管理の数字を当てはめています。

onTapはメニューがタップされたときの処理です。このタップに連動してページを遷移させるためPageControllerのpagerにページジャンプの指定をしています。

そしてPageViewのスワイプ操作によって選択中のメニューの番号が変わった場合に、これに連動してナビゲーションバーを再描画するため、BottomNavigationBar全体をObx()で囲んでいることに注意して下さい。これがGetX状態管理でお馴染みの書式ですね。

以上がナビゲーションバーとページ遷移のポイントです。


余談:おしゃれなナビゲーションバー

前節までが公式で用意されているものでしたが、Pub.devではサードパーティ製の様々なナビゲーションバーが用意されています。現状一番人気なのが:


pub.dev

こちらです。これを用いると、下のメニューバーのデザインが少し変わります:

f:id:linkedsort:20211127113804p:plain:w350

ちょっと自己主張が激しくなりましたね。

使い方は公式のものとほとんど同様です。パッケージの依存性をpubspec.yamlに記載してflutter pub getした後、上記サンプルコードを下記のように変更するだけです:

まず冒頭でインポート:

import 'package:convex_bottom_bar/convex_bottom_bar.dart';

そしてScaffoldのbottomNavigationBarのパラメタを下記のように替えます:

  bottomNavigationBar: ConvexAppBar(
     items: const [
       TabItem(icon: Icons.home, title: 'Home'),
       TabItem(icon: Icons.access_alarm_outlined, title: 'Clock'),
       TabItem(icon: Icons.vpn_key, title: 'Secret'),
      ],
      initialActiveIndex: state.selected.value,
      onTap: (int i) {
          state.selected.value = i;
          pager.jumpToPage(i);
      },
   ),    

名前が少しずつ違うのと、各項目の設定がスッキリするくらいでロジックに違いがないですね。

ただ一点、現状のバージョン3.0.0では、どうも動かしてみた限りPageView側でスワイプした動きに連動してメニューの方の選択状態が切り替わってくれません

ぐいっとPageView側の操作でスワイプしてみます:


f:id:linkedsort:20211127114440p:plain:w350

隣のページに行きましたが、下のメニューがHomeのままですね:


f:id:linkedsort:20211127114511p:plain:w350

これはナビゲーションバー側にリライトの指令が伝わっていないわけではありません(ラベルを書き換えるなどで確認できます)。

どうも公式の方が「currentIndex(現在のインデックス)」としているのに対して「initialActiveIndex(初期のアクティブなインデックス)」としている部分がポイントの様ですね。初期状態でしか選択メニューを設定できなさそうです。

もし連動させる方法がありましたら教えてほしいのですが、現状見る限りこのバージョンではできなさそうです。これでも構わないという場合は、こちらのパッケージの使用を検討されてみても良いかもしれません。

また、そもそもPageView側でスワイプして遷移させる部分を止めておけばこの不整合の問題は起こりません。考えてみるとスワイプで遷移させるアプリはそう多くはなく、止めてしまえば問題は起こりませんね。こちらのナビゲーションバーはそういう設計思想のもとに作られているかもしれません。






おわりに

寒い冬に備えて、足元をしっかり固めるボトムナビゲーションバーについて述べてみました。とてもスッキリして単純な作りですので使いやすいですよね。

しっかりアプリの足元を固めてサクサクUIを作っていきましょう。

f:id:linkedsort:20211127121531j:plain




今週のお題「あったか~い」