Main featured image

FlutterのTabBarとTabBarViewの要素を動的に追加する

Flutter
Dart

Flutter の Widget である TabBar と TabBarView の要素を動的に追加してみようと思います。

TabBar 追加画面から TabBar 名を新規登録して、TabBar ではランダム画像が表示される簡易的なアプリを実装します。

完成後はこのような動きになります。

構成としては簡易的な MVVM です。

今回は DB や API など外部データ取得やビジネスロジックが無いので、View と VM のみ実装しています。

今回、動的に TabBar の要素を追加する部分のみ掲載するので、全てのソースコードを見たい方は Github にあるので参照ください。

環境
  • macOS Big Sur 11.1
  • Android Studio 4.1.2
  • Flutter 1.22.6
  • Dart 2.10.5
TabBar 新規作成する View を実装する
  • lib/create_landscape_view.dart
class CreateLandscapeView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Landscape作成'),
      ),
      body: LandScapeForm(),
    );
  }
}

class LandScapeForm extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final viewModel = useProvider(landscapeViewModelProvider);
    return Container(
      padding: const EdgeInsets.all(48),
      child: TextField(
        maxLength: 16,
        maxLengthEnforced: true,
        decoration: const InputDecoration(
          hintText: '風景の名前を入力してください',
          labelText: '風景の名前',
        ),
        onSubmitted: (String name) {
          viewModel.createLandscape(name);
          Navigator.pop(context);
        },
      ),
    );
  }
}

TextField が一つあるシンプルな画面です。

ソフトウェアキーボードの Enter をトリガーに実行される onSubmitted で 作成処理を実装します。

        onSubmitted: (String name) {
          viewModel.createLandscape(name);
          Navigator.pop(context);
        },

viewModel.createLandscape メソッドを呼んでいます。

viewModel の createLandscape はこのように実装しています。

  • lib/landscape_view_model.dart
class LandscapeViewModel extends StateNotifier<LandscapeList> {
  LandscapeViewModel() : super(const LandscapeList());

  void createLandscape(String name) {
    final currentList = state.landscapes;
    final id = currentList.length + 1;
    final imageUrl = 'https://source.unsplash.com/random/200x200?sig=$id';
    state = state.copyWith(
      landscapes: [
        ...currentList,
        Landscape(id: id, name: name, imageUrl: imageUrl)
      ],
    );
  }
}

StateNotifier<LandscapeList> で指定している LandscapeList は TabBar に表示するデータの配列を保持するクラスです。

Landscape オブジェクトの imageUrl にはランダム画像を取得する URL を代入しています。

state.landscapes で配列を取得して、Landscape オブジェクトを配列に追加しています。

この LandscapeList の配列を View 側で取得して、配列の状態に変更があれば Widget の再ビルドが走ります。

LandscapeList は Landscape オブジェクトを保持しています。

  • lib/landscape.dart
@freezed
abstract class Landscape with _$Landscape {
  const factory Landscape({
    @required final int id,
    @required final String name,
    @required final String imageUrl,
  }) = _Landscape;
}

@freezed
abstract class LandscapeList with _$LandscapeList {
  const factory LandscapeList({
    @Default(<Landscape>[]) final List<Landscape> landscapes,
  }) = _LandscapeList;
}

ここでは Freezed を利用して、オブジェクトを immutable(不変)にしています。

LandscapeList と Landscape オブジェクトは immutable な為、ViewModel 側で state.copyWith を使用してオブジェクトを直接上書きせずコピーを新たに生成して state に代入しています。

    state = state.copyWith(
      landscapes: [
        ...currentList,
        Landscape(id: id, name: name, imageUrl: imageUrl)
      ],
    );

StateNotifier や Freezed に関しては以前の記事で紹介してますので参照ください。

前置きが長くなってしまいましたが、次は実際に LandscapeList を取得して TabBar を表示する View を作成します。

TabBar を表示する View を実装する
  • lib/landscape_tab_bar_view.dart
class Const {
  static const routeNameCreateLandscape = '/create-landscape';
}

class LandscapeTabBarView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(primaryColor: Colors.white),
      routes: <String, WidgetBuilder>{
        Const.routeNameCreateLandscape: (BuildContext context) =>
            CreateLandscapeView()
      },
      home: BuildDefaultTabController(),
    );
  }
}

class BuildDefaultTabController extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final _landscapes =
        useProvider(landscapeViewModelProvider.state).landscapes;
    return DefaultTabController(
        length: _landscapes.length,
        child: Scaffold(
          appBar: AppBar(
            elevation: 0,
            title: const Text('Landscapes'),
            actions: [
              IconButton(
                icon: const Icon(Icons.add),
                onPressed: () => Navigator.pushNamed(
                    context, Const.routeNameCreateLandscape),
              ),
            ],
            bottom: TabBar(
              tabs: _landscapes
                  .map((Landscape landscape) => Tab(text: landscape.name))
                  .toList(),
              indicatorColor: Colors.black,
            ),
          ),
          body: TabBarView(
            children: _landscapes
                .map((Landscape landscape) =>
                    TabPage(imageUrl: landscape.imageUrl))
                .toList(),
          ),
        ));
  }
}

class TabPage extends StatelessWidget {
  const TabPage({Key key, this.imageUrl}) : super(key: key);
  final String imageUrl;

  @override
  Widget build(BuildContext context) {
    print(imageUrl);
    return Center(
      child: CachedNetworkImage(
        imageUrl: imageUrl,
        progressIndicatorBuilder: (context, url, downloadProgress) =>
            CircularProgressIndicator(value: downloadProgress.progress),
        errorWidget: (context, url, dynamic error) => const Icon(Icons.error),
      ),
    );
  }
}

まず、DefaultTabController を実装します。

DefaultTabController には、ヘッダー部分である AppBar と AppBar の下に配置される TabBar 、そして TabBar 内の要素である TabBarView を実装します。

    return DefaultTabController(
        length: _landscapes.length,
        child: Scaffold(
          appBar: AppBar(
            elevation: 0,
            title: const Text('Landscapes'),
            actions: [
              IconButton(
                icon: const Icon(Icons.add),
                onPressed: () => Navigator.pushNamed(
                    context, Const.routeNameCreateLandscape),
              ),
            ],
            bottom: TabBar(
              isScrollable: true,
              tabs: _landscapes
                  .map((Landscape landscape) => Tab(text: landscape.name))
                  .toList(),
              indicatorColor: Colors.black,
            ),
          ),
          body: TabBarView(
            children: _landscapes
                .map((Landscape landscape) =>
                    TabPage(imageUrl: landscape.imageUrl))
                .toList(),
          ),
        ));

上から順番に設定をみていきます。

まず、TabBar と TabBarView の数を決定する DefaultTabControllerlength を設定します。

    return DefaultTabController(
        length: _landscapes.length,

ここでは配列の length を設定しています。

ListView.builder なんかと同じ設定ですね。

次に TaBar を設定します。

今回は TabBar を動的に追加するので、 isScrollable を true にして要素が多い場合に TabBar を横スクロールできるようにします。

            bottom: TabBar(
              isScrollable: true,
              tabs: _landscapes
                  .map((Landscape landscape) => Tab(text: landscape.name))
                  .toList(),
              indicatorColor: Colors.black,
            ),

tabsTab widget の配列を指定します。

ここでは _landscapes を map で回して新たに Tab Widget 配列を生成しています。

次に、TabBarView を設定します。

          body: TabBarView(
            children: _landscapes
                .map((Landscape landscape) =>
                    TabPage(imageUrl: landscape.imageUrl))
                .toList(),
          ),

ここでは children に自作した TabPage widget の配列を指定しています。

TabPage widget は以下のようになっています。

class TabPage extends StatelessWidget {
  const TabPage({Key key, this.imageUrl}) : super(key: key);
  final String imageUrl;

  @override
  Widget build(BuildContext context) {
    print(imageUrl);
    return Center(
      child: CachedNetworkImage(
        imageUrl: imageUrl,
        progressIndicatorBuilder: (context, url, downloadProgress) =>
            CircularProgressIndicator(value: downloadProgress.progress),
        errorWidget: (context, url, dynamic error) => const Icon(Icons.error),
      ),
    );
  }
}

画面中央に画像を表示するだけのシンプルな Widget です。

蛇足ですが、筆者は画像表示には CachedNetworkImage を利用しています。

Image.network は画像 cache が効かないので、表示の度に都度画像取得が走ります。

CachedNetworkImage は読み込んだ画像を cache してくれる package です。

画像 cache だけでは無く、progressIndicatorBuildererrorWidget でローディング中や画像が取得できなかった時の Widget を表示出来るようになっています。

以上 TabBar と TabBarView の動的に要素を追加する方法でした。

最後に小ネタですが、デフォルト TabBar の下に shadow がつきます。

これを消すには AppBar の elevation を 0 にします。

        child: Scaffold(
          appBar: AppBar(
            elevation: 0,

筆者はフラットな UI が好きなので、いつも elevation には 0、もしくは少ない値を設定しています。

ここはお好みで設定してください。

おわりに

今回、動的に TabBar と TabBarView の要素を追加する部分のみ掲載してますので、アプリの全てのソースコードを見たい方は Github にあるので参照ください。

Written by ZUMA a.k.a. Kazuma. Web/Mobile App developer.  My profile.
Tags
Archives
2021-102021-092021-072021-062021-052021-042021-032021-022021-01
Recent Posts