Closed10

動的なTabViewを作る

Ryo24Ryo24

動作例

スワイプでタブ移動。
フローティングボタン押すとタブ追加。

コード

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<String> data = ['Page 0', 'Page 1', 'Page 2'];
  int initPosition = 1;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: CustomTabView(
          initPosition: initPosition,
          itemCount: data.length,
          tabBuilder: (context, index) => Tab(text: data[index]),
          pageBuilder: (context, index) => Center(child: Text(data[index])),
          onPositionChange: (index){
            print('current position: $index');
            initPosition = index;
          },
          onScroll: (position) => print('$position'),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            data.add('Page ${data.length}');
          });
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

class CustomTabView extends StatefulWidget {
  final int? itemCount;
  final IndexedWidgetBuilder? tabBuilder;
  final IndexedWidgetBuilder? pageBuilder;
  final Widget? stub;
  final ValueChanged<int>? onPositionChange;
  final ValueChanged<double>? onScroll;
  final int? initPosition;


  CustomTabView({this.itemCount, this.tabBuilder, this.pageBuilder, this.stub,
    this.onPositionChange, this.onScroll, this.initPosition});

  @override
  _CustomTabsState createState() => _CustomTabsState();
}

class _CustomTabsState extends State<CustomTabView> with TickerProviderStateMixin {
  late TabController controller;
  late int _currentCount;
  late int _currentPosition;

  @override
  void initState() {
    _currentPosition = widget.initPosition!;
    controller = TabController(
      length: widget.itemCount!,
      vsync: this,
      initialIndex: _currentPosition,
    );
    controller.addListener(onPositionChange);
    controller.animation!.addListener(onScroll);
    _currentCount = widget.itemCount!;
    super.initState();
  }

  @override
  void didUpdateWidget(CustomTabView oldWidget) {
    if (_currentCount != widget.itemCount) {
      controller.animation!.removeListener(onScroll);
      controller.removeListener(onPositionChange);
      controller.dispose();

      if (widget.initPosition != null) {
        _currentPosition = widget.initPosition!;
      }

      if (_currentPosition > widget.itemCount! - 1) {
        _currentPosition = widget.itemCount! - 1;
        _currentPosition = _currentPosition < 0 ? 0 :
        _currentPosition;
        if (widget.onPositionChange is ValueChanged<int>) {
          WidgetsBinding.instance!.addPostFrameCallback((_){
            if(mounted) {
              widget.onPositionChange!(_currentPosition);
            }
          });
        }
      }

      _currentCount = widget.itemCount!;
      setState(() {
        controller = TabController(
          length: widget.itemCount!,
          vsync: this,
          initialIndex: _currentPosition,
        );
        controller.addListener(onPositionChange);
        controller.animation!.addListener(onScroll);
      });
    } else if (widget.initPosition != null) {
      controller.animateTo(widget.initPosition!);
    }

    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    controller.animation!.removeListener(onScroll);
    controller.removeListener(onPositionChange);
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (widget.itemCount! < 1) return widget.stub ?? Container();

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: <Widget>[
        Container(
          alignment: Alignment.center,
          child: TabBar(
            isScrollable: true,
            controller: controller,
            labelColor: Theme.of(context).primaryColor,
            unselectedLabelColor: Theme.of(context).hintColor,
            indicator: BoxDecoration(
              border: Border(
                bottom: BorderSide(
                  color: Theme.of(context).primaryColor,
                  width: 2,
                ),
              ),
            ),
            tabs: List.generate(
              widget.itemCount!,
                  (index) => widget.tabBuilder!(context, index),
            ),
          ),
        ),
        Expanded(
          child: TabBarView(
            controller: controller,
            children: List.generate(
              widget.itemCount!,
                  (index) => widget.pageBuilder!(context, index),
            ),
          ),
        ),
      ],
    );
  }

  onPositionChange() {
    if (!controller.indexIsChanging) {
      _currentPosition = controller.index;
      if (widget.onPositionChange is ValueChanged<int>) {
        widget.onPositionChange!(_currentPosition);
      }
    }
  }

  onScroll() {
    if (widget.onScroll is ValueChanged<double>) {
      widget.onScroll!(controller.animation!.value);
    }
  }
}
Ryo24Ryo24

_MyHomePageState

_MyHomePageState
List<String> data = ['Page 0', 'Page 1', 'Page 2'];  // Tabの管理
int initPosition = 1; // 選択しているタブの要素


CustomTabView(
   initPosition: initPosition,
   itemCount: data.length,
   tabBuilder: (context, index) => Tab(text: data[index]),
   pageBuilder: (context, index) => Center(child: Text(data[index])),
   onPositionChange: (index){
      print('current position: $index');
      initPosition = index;
   },
   onScroll: (position) => print('$position'),
),

CustomTabView

CustomTabView
class CustomTabView extends StatefulWidget {
  final int? initPosition; // 現在選択しているタブの番号(要素)
  final int? itemCount; // タブの総数
  final IndexedWidgetBuilder? tabBuilder; // 「Tab()」Widgetを作成する
  final IndexedWidgetBuilder? pageBuilder; // 「TabBarView()」Widgetを作成する
  final Widget? stub; // タブがゼロ(ない)の時に表示するWidget
  final ValueChanged<int>? onPositionChange; // どのタブ(要素)に移動したか通知する
  final ValueChanged<double>? onScroll; // スクロール量を検知



  CustomTabView({this.initPosition, this.itemCount, this.tabBuilder, this.pageBuilder, this.stub,
    this.onPositionChange, this.onScroll});

  @override
  _CustomTabsState createState() => _CustomTabsState();
}
  • IndexedWidgetBuilder : インデックスを持つ特定のWidgetを作成する関数のシグネチャ。

https://api.flutter.dev/flutter/widgets/IndexedWidgetBuilder.html

  • ValueChanged : voidの関数を実行し、結果を通知する。

https://api.flutter.dev/flutter/foundation/ValueChanged.html

Ryo24Ryo24

_CustomTabsStateを読む

late TabController controller; // TabのControllerを作成
late int _currentCount; // Tabの総数
late int _currentPosition; // 現在選択しているTab

@override
void initState() {
   _currentPosition = widget.initPosition!; // 現在選択している番号(タブ)を代入
   // TabControllerを定義
   controller = TabController(
      length: widget.itemCount!, // Controllerで管理するタブの総数
      vsync: this, // フレーム関連のトリガー
       initialIndex: _currentPosition, // 初期タブを設定
    );
    controller.addListener(onPositionChange); // オブジェクトの変更時、呼び出されるクロージャーを定義
    controller.animation!.addListener(onScroll); // アニメーション実行時、呼び出されるクロージャーを定義
    _currentCount = widget.itemCount!; // dataのlengthを代入
    super.initState();
 });
}
  • addListener method : オブジェクトの変更時、呼び出されるクロージャーを定義。

https://api.flutter.dev/flutter/foundation/ChangeNotifier/addListener.html

  • animation property : アニメーションの数値を返す。範囲は0.0 ~ length-1.0。

https://api.flutter.dev/flutter/material/TabController/animation.html

Ryo24Ryo24

indexIsChanging property

https://api.flutter.dev/flutter/material/TabController/indexIsChanging.html

このプロパティは、TabBarを選択し画面遷移中ならTrue / それ以外はFalseを返すプロパティです。

詳細

TabControllerは、TabBarをタップし遷移するアニメーションをanimateTo method で実施している。つまり、animateTo method が実行中はTrue / それ以外はFalseを返している。

上記のGifのようにTabBarを選択し、色が変色&遷移アニメーション中はTrueを返す。それ以外はFalseを返す。

Ryo24Ryo24

addListenerで呼び出されるクロージャー

onPositionChange()

onPositionChange()
/// Tab変更時呼び出され、Tabを管理する配列の値を変更する
void onPositionChange() {
   if (controller.indexIsChanging) return; // TabBarを選択し、アニメーション中は処理を拒否する

   // 遷移が完了している
   _currentPosition = controller.index;
   widget.onPositionChange!(_currentPosition); // 現在選択しているTabを更新する
}

onScroll()

onScroll()
/// スクロール時、移動量を反映する
void onScroll() {
   widget.onScroll!(controller.animation!.value);
}
Ryo24Ryo24

WidgetsBinding.instance!.addPostFrameCallbac((_) => );

build完了直後にコールバックするらしい。
https://qiita.com/Rwf-9DH3/items/b681c68b06e70b02ae8d

詳細

WidgetsBinding mixin

WidgetのレイヤーとFlutterエンジンを紐づける。(ドキュメントの内容を和訳)

https://api.flutter.dev/flutter/widgets/WidgetsBinding-mixin.html

https://qiita.com/kurun_pan/items/04f34a47cc8cee0fe542
この記事よりWidgetsBindingを一言で表すなら、ライフサイクルの取得

instance property

https://api.flutter.dev/flutter/widgets/WidgetsBinding/instance.html

現在のWidgetsBindingを取得するプロパティ。

addPostFrameCallback method

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

フレームの終わり頃、コールバックを実行するメソッド。

まとめ

アプリのライフサイクルに介入し、フレームの描画終了を通知する。

Ryo24Ryo24

didUpdateWidget method

https://api.flutter.dev/flutter/widgets/State/didUpdateWidget.html
StatefulWidgetで親Widgetの構成が変更される度呼び出される。(ツリー追加時にも実行される。)

プログラム

didUpdateWidget()
/// Widgetの構成が変更時、呼び出される。
@override
void didUpdateWidget(CustomTabView oldWidget) {
   super.didUpdateWidget(oldWidget);

   if (_currentCount != widget.itemCount) {
      // 新規にタブが追加された時に実行
      controller.removeListener(onPositionChange); // オブジェクト変更時、呼び出されるクロージャーを削除
      controller.animation!.removeListener(onScroll); // アニメーション実行時、呼び出されるクロージャーを削除
      controller.dispose(); // コントローラーを削除

      _currentPosition = widget.initPosition; // 現在選択しているタブ

      // Tab数が0の時に実行する
      if (_currentPosition > widget.itemCount - 1) {
         _currentPosition = widget.itemCount - 1; // (Tabを管理する)配列の要素-1を代入
         _currentPosition = _currentPosition < 0 ? 0 : _currentPosition; // _currentPositionが0以下の時、0を代入。1以上の時、現状維持。
         if (widget.onPositionChange is ValueChanged<int>) {
            // UI描画後、実行する。(ライフサイクルに介入し、フレーム描画後の処理を定義する)
           WidgetsBinding.instance!.addPostFrameCallback((_) {
              // Stateオブジェクトがツリーに存在する場合、親Widgetに現在選択しているTabを通知する
              if(mounted) widget.onPositionChange!(_currentPosition);
           });
        }
     }

     _currentCount = widget.itemCount; // タブの総数を更新する

     // TabControllerを作り直す
     setState(() {
        controller = TabController(
        length: widget.itemCount,
        vsync: this,
        initialIndex: _currentPosition,
     );
      controller.addListener(onPositionChange);
      controller.animation!.addListener(onScroll);
    });
  } else {
   // 初期化処理(ツリー追加時に実行)
   controller.animateTo(widget.initPosition); // initPositionの数値の要素のタブに移動する
  }
}
Ryo24Ryo24

build

build()
@override
  Widget build(BuildContext context) {
    if (widget.itemCount < 1) return widget.stub ?? Container();

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: <Widget>[
        Container(
          alignment: Alignment.center,
          child: TabBar(
            isScrollable: true,
            controller: controller,
            labelColor: Theme.of(context).primaryColor, // 選択時のラベルの色
            unselectedLabelColor: Theme.of(context).hintColor, // 非選択時のラベルの色
            indicator: BoxDecoration( // インジケータ(Containerのプロパティで装飾)を定義する
              border: Border( // 枠線を定義する
                bottom: BorderSide( // 下側の境界線
                  color: Theme.of(context).primaryColor,
                  width: 2,
                ),
              ),
            ),
            tabs: List.generate(
              widget.itemCount, // リストの長さ
                  (index) => widget.tabBuilder(context, index), // 要素の総数分実行する
            ),
          ),
        ),
        Expanded( // 横幅を埋める
          child: TabBarView(
            controller: controller,
            children: List.generate(
              widget.itemCount,
                  (index) => widget.pageBuilder(context, index),
            ),
          ),
        ),
      ],
    );
  }

https://api.flutter.dev/flutter/painting/BoxDecoration-class.html
https://api.flutter.dev/flutter/painting/BorderSide-class.html
https://api.flutter.dev/flutter/dart-core/List/List.generate.html

このスクラップは2021/11/27にクローズされました