taketiyo.log

Web Engineering 🛠 & Body Building 💪

【Flutter】アニメーションするタブUIを作成する【Dart】

Programming

  / / /

この記事では、アニメーションしながら画面が切り替わるタブUIの実装方法を解説します。
完成イメージは次の通りです。作成したタブUIをネストさせても動作するように実装してきます。
 

 

目次

 

利用側の理想コード

作成したタブUIは下記の様なコードで利用出来るようにします。

例)

TabUI(
  /// タブ部分に表示したいWidgetを配列で指定
  tabs: <Widget>[
    Icon(Icons.filter_1, size: 20),
    Icon(Icons.filter_2, size: 20),
    Icon(Icons.filter_3, size: 20),
    Icon(Icons.filter_4, size: 20),
  ],
  /// それぞれのタブに対応したコンテンツを配列で指定
  children: <Widget>[
    Text('Tab1 Contents'),
    Text('Tab2 Contents'),
    Text('Tab3 Contents'),
    Text('Tab4 Contents'),
  ]
);

 

実装方針

タブUIは、タブ部分、コンテンツ部分と分けて実装していきます。
 

 
まずはタブ部分のUIから実装してきます。
 

タブ部分の実装

タブ部分のUIを更に細かく分解すると次のようになります。
 

 
初めにタブUIを囲うコンテナを定義します。

class TabUI extends StatefulWidget
{
  final List<Widget> tabs;
  final List<Widget> children;

  TabUI({
    Key key,
    @required this.tabs,
    @required this.children,
  }): super(key: key);

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

class _TabUIState extends State<TabUI>
{
  @override
  Widget build(BuildContext context)
  {
    return _buildChild();
  }

  Widget _buildChild()
  {
    return Stack(
      children: <Widget>[
      ],
    );
  }
}

 
各タブのボタン、及びインジケーターUIをコンテナ上に重ねていくイメージで配置するため、Stackウィジェットを利用します。
次に、各タブのボタン部分を配置します。

class _TabUIState extends State<TabUI>
{

  ...

  Widget _buildChild()
  {
    return Stack(
      children: <Widget>[
          // Menus
        _buildMenus(),
      ],
    );
  }

  Widget _buildMenus()
  {
    return Positioned(
      top: 0,
      left: 0,
      right: 0,
      child: Container(
        height: 50,
        width: double.infinity,
        child: IntrinsicHeight(
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: _buildTabs(),
          ),
        ),
      ),
    );
  }

  List<Widget> _buildTabs()
  {
    final tabs = <Widget>[];

    widget.tabs.asMap().forEach((index, tab) {
      tabs.add(
        Expanded(
          child: GestureDetector(
            child: Container(
              color: Colors.transparent,
              child: tab,
            ),
            onTap: () {
              // TODO: 後ほどボタンをタップした際の挙動を実装します。
            },
          ),
        )
      );
    });

    return tabs;
  }
}

 
■プレビュー

 
次に各タブの非アクティブ時のインジケーターUIを配置します。

class _TabUIState extends State<TabUI>
{

  ...

  Widget _buildChild()
  {
    return Stack(
      children: <Widget>[
        // Menus
        _buildMenus(),
        // Menu indicators
        _buildInactiveIndicator(),
      ],
    );
  }

  ...

  Widget _buildInactiveIndicator()
  {
    final List<Widget> indicators = [];

    widget.tabs.forEach((_) {
      indicators.add(
        Expanded(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 2),
            child: Container(
              height: 3,
              decoration: BoxDecoration(
                color: Colors.black38,
                borderRadius: BorderRadius.circular(2.5),
              ),
            )
          ),
        ),
      );
    });

    return Positioned(
      top: 46,
      left: 0,
      right: 0,
      child: Row(
        children: indicators,
      ),
    );
  }
}

 
■プレビュー

 
次に、現在選択されているタブを示すためのアクティブなインジケーターを配置します。

class _TabUIState extends State<TabUI>
{
  /// アクティブインジケーターの画面左側からのオフセット
  double _indicatorOffset = 0;
  /// アクティブインジケーターの幅
  double _indicatorWidth = 0;

  void _calculateIndicatorMeta()
  {
    final size = MediaQuery.of(context).size;

    _indicatorWidth = size.width / widget.tabs.length;
  }

  @override
  Widget build(BuildContext context)
  {
    /// `build()`が呼ばれた際に、アクティブインジケーターの幅をタブの個数から動的に算出します。
    _calculateIndicatorMeta();

    return _buildChild();
  }

  Widget _buildChild()
  {
    return Stack(
      children: <Widget>[
        // Menus
        _buildMenus(),
        // Menu indicators
        _buildInactiveIndicator(),
        _buildActiveIndicator(),
      ],
    );
  }

  ...

  Widget _buildActiveIndicator()
  {
    final double horizontalPadding = 2;

    return Positioned(
      top: 46,
      left: _indicatorOffset,
      child: Padding(
        padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
        child: Container(
          height: 3,
          width: _indicatorWidth - (horizontalPadding * 2),
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(2.5),
            boxShadow: [
              BoxShadow(
                color: Colors.black12,
                blurRadius: 2,
                spreadRadius: 0,
                offset: const Offset(0, 1)
              ),
            ]
          ),
        ),
      ),
    );
  }

}

 
■プレビュー

 
次はアクティブインジケーターのアニメーション処理を実装します。
AnimationControllerを利用し_indicatorOffsetの値を変化させることによってアニメーションを表現します。
Stateに対してTickerProviderStateMixinを忘れずに適用します。

class _TabUIState extends State<TabUI> with TickerProviderStateMixin
{
  double _indicatorOffset = 0;
  double _indicatorWidth = 0;

  /// 現在選択されているタブのインデックス
  int _currentTabIndex = 0;
  /// アクティブインジケーターのアニメーション開始地点
  double _indicatorStartPosition = 0;
  /// アクティブインジケーターのアニメーション終了地点
  double _indicatorEndPosition = 0;
  /// アクティブインジケーターのアニメーションコントローラー
  AnimationController _indicatorAnimationController;
  /// アクティブインジケーターのアニメーションカーブ
  Animation<double> _indicatorAnimation;

  @override
  void initState()
  {
    /// `AnimationController`を初期化します。
    _indicatorAnimationController = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this
    )
      /// `AnimationController.forward()`がコールされた際に実行されるリスナーを設定します。
      ..addListener(() => setState(() {
        /// 設定されたアクティブインジケーターのアニメーション終了地点、開始地点から、移動距離を求めます。
        final distance = _indicatorEndPosition - _indicatorStartPosition;
        /// `Animation.value`は`AnimationController`の現在の時間位置(t)に対応するアニメーションカーブの値(x)です。
        /// 上記で算出した移動距離とアニメーションカーブを掛け合わせることで、なめらかにオフセットを変化させます。
        /// 参考: https://api.flutter.dev/flutter/animation/Curves/fastOutSlowIn-constant.html
        _indicatorOffset = _indicatorStartPosition + distance * _indicatorAnimation.value;
      }));

    /// `AnimationController`の状態変化に伴うアニメーションカーブを作成します。
    _indicatorAnimation = Tween<double>(begin: 0.0, end: 1.0)
      .animate(CurvedAnimation(
        parent: _indicatorAnimationController,
        curve: Curves.fastOutSlowIn
      ));

    _changeTabIndex(0);

    super.initState();
  }

  @override
  void dispose()
  {
    _indicatorAnimationController.dispose();

    super.dispose();
  }

  void _changeTabIndex(int index)
  {
    /// アクティブインジケーターのアニメーション開始地点を計算します。
    _indicatorStartPosition = _currentTabIndex * _indicatorWidth;
    /// 現在選択されているタブのインデックスを保存します。
    _currentTabIndex = index;
    /// アクティブインジケーターのアニメーション終了地点を計算します。
    _indicatorEndPosition = _currentTabIndex * _indicatorWidth;
    /// アニメーションを初期フレームから再生します。
    _indicatorAnimationController.forward(from: 0.0);
  }

  List<Widget> _buildTabs()
  {
    final tabs = <Widget>[];

    widget.tabs.asMap().forEach((index, tab) {
      tabs.add(
        Expanded(
          child: GestureDetector(
            child: Container(
              color: Colors.transparent,
              child: tab,
            ),
            onTap: () {
              /// タブがタップされた際にタブ切り替処理を実行します。
              _changeTabIndex(index);
            },
          ),
        )
      );
    });

    return tabs;
  }

  ...

}

 

 
これでタブ部分のUIの実装は完了です。
 

コンテンツ部分の実装

次はコンテンツ部分を実装していきます。
 

 
タブ部分のUIと比べるとUI同士の重なりが無いため、表示するだけであれば非常にシンプルな実装となりますが、今回はアニメーションの方向を考慮して実装していきます。
現在のタブより右側のタブへ移動した際は、既存のコンテンツが左へスライドアウト、新規のコンテンツが右からスライドイン、
現在のタブより左側のタブへ移動した際は、既存のコンテンツが右へスライドアウト、新規のコンテンツが左からスライドイン、

具体的には次の様なイメージです。
 

 
実装は下記の通りです。

/// コンテンツが移動しうる方向の列挙型を定義しておきます。
enum ChangeBodyDirection {
  Left,
  Right,
}

class _TabUIState extends State<TabUI> with TickerProviderStateMixin
{

  ...

  /// コンテンツ部分のアニメーションコントローラー
  AnimationController _tabBodyAnimationController;
  /// コンテンツ部分のアニメーションカーブ
  Animation<double> _tabBodyAnimation;
  /// コンテンツのアニメーション開始地点
  Offset _tabBodyOffsetStart;
  /// コンテンツのアニメーション終了地点
  Offset _tabBodyOffsetEnd;
  /// アニメーション中のコンテンツの位置
  Offset _tabBodyOffset = const Offset(0, 0);
  /// コンテンツ部分に表示するWidget
  Widget _tabBody;

  @override
  void initState()
  {
    _indicatorAnimationController = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this
    )
      ..addListener(() => setState(() {
        final distance = _indicatorEndPosition - _indicatorStartPosition;
        _indicatorOffset = _indicatorStartPosition + distance * _indicatorAnimation.value;
      }));

    _indicatorAnimation = Tween<double>(begin: 0.0, end: 1.0)
      .animate(CurvedAnimation(
        parent: _indicatorAnimationController,
        curve: Curves.fastOutSlowIn
      ));

    /// コンテンツ部分のアニメーションコントローラーを初期化
    _tabBodyAnimationController = AnimationController(
        duration: const Duration(milliseconds: 300),
        vsync: this
    )
      /// `AnimationController.forward()`がコールされた際に実行されるリスナーを設定します。
      ..addListener(() => setState(() {
        /// タブUIの時と同様、設定された開始地点、終了地点から
        /// コンテンツ部分のオフセットをなめらかに変化させます。
        _tabBodyOffset = Offset.lerp(
          _tabBodyOffsetStart,
          _tabBodyOffsetEnd,
          _tabBodyAnimation.value
        );
      }));

    /// `AnimationController`の状態変化に伴うアニメーションカーブを作成します。
    _tabBodyAnimation = Tween<double>(begin: 0.0, end: 1.0)
      .animate(CurvedAnimation(
        parent: _tabBodyAnimationController,
        curve: Curves.fastOutSlowIn
      ));

    _changeTabIndex(0);

    super.initState();
  }

  @override
  void dispose()
  {
    _indicatorAnimationController.dispose();
    _tabBodyAnimationController.dispose();

    super.dispose();
  }

  void _changeTabIndex(int index)
  {
    /// 移動した方向を判定しアニメーションへ反映させます。
    final ChangeBodyDirection direction = _currentTabIndex <= index ? ChangeBodyDirection.Right : ChangeBodyDirection.Left;

    _indicatorStartPosition = _currentTabIndex * _indicatorWidth;
    _currentTabIndex = index;
    _indicatorEndPosition = _currentTabIndex * _indicatorWidth;
    _indicatorAnimationController.forward(from: 0.0);

    /// コンテンツ部分を変更します。
    _changeBody(direction);
  }

  void _changeBody(ChangeBodyDirection direction)
  {
    _tabBodyOffsetEnd = Offset(0, 0);

    switch (direction) {
      case ChangeBodyDirection.Right:
        _tabBodyOffsetStart = Offset(-100, 0);
        break;
      case ChangeBodyDirection.Left:
        _tabBodyOffsetStart = Offset(100, 0);
        break;
    }

    /// スライドアウトアニメーションを再生します。
    _tabBodyAnimationController.reverse(from: 1.0).then((_) {

      switch (direction) {
        case ChangeBodyDirection.Right:
          _tabBodyOffsetStart = Offset(100, 0);
          break;
        case ChangeBodyDirection.Left:
          _tabBodyOffsetStart = Offset(-100, 0);
          break;
      }

      /// スライドインアニメーションを再生します。
      _tabBodyAnimationController.forward(from: 0.0);

      setState(() {
        /// コンテンツの内容を新しいものに差し替えます。
        _tabBody = widget.children[_currentTabIndex];
      });
    });
  }

  ...

  Widget _buildChild()
  {
    return Expanded(
      child: AnimatedView(
        animationController: widget.parentAnimationController,
        animation: widget.parentAnimation,
        child: Stack(
          children: <Widget>[
            // Menus
            _buildMenus(),
            // Menu indicators
            _buildInactiveIndicator(),
            _buildActiveIndicator(),
            // Body
            _buildBody(),
          ],
        ),
      ),
    );
  }

  ...

  Widget _buildBody()
  {
    return Positioned(
      top: 50,
      left: 0,
      right: 0,
      bottom: 0,
      child: FadeTransition(
        opacity: _tabBodyAnimation,
        child: Transform(
          transform: Matrix4.translationValues(_tabBodyOffset.dx, _tabBodyOffset.dy, 0.0),
          child: Padding(
            padding: const EdgeInsets.only(top: 16),
            child: Container(
              color: Colors.transparent,
              child: _tabBody,
            ),
          ),
        ),
      ),
    );
  }
}

 
これでコンテンツ部分の実装も完了です。下記の様なUIが完成します。
 

 

タブUIをネストさせる

作成したタブUIは次の様にネストさせることも可能です。

TabUI(
  tabs: [
    Icon(Icons.filter_1, size: 20),
    Icon(Icons.filter_2, size: 20),
  ],
  children: <Widget>[
    TabUI(
      key: GlobalKey(),
      tabs: [
        Icon(Icons.filter_1, size: 20),
        Icon(Icons.filter_2, size: 20),
        Icon(Icons.filter_3, size: 20),
        Icon(Icons.filter_4, size: 20),
      ],
      children: <Widget>[
        Text('Tab1-1 Contents'),
        Text('Tab1-2 Contents'),
        Text('Tab1-3 Contents'),
        Text('Tab1-4 Contents'),
      ]
    ),
    TabUI(
      key: GlobalKey(),
      tabs: [
        Icon(Icons.filter_1, size: 20),
        Icon(Icons.filter_2, size: 20),
        Icon(Icons.filter_3, size: 20),
        Icon(Icons.filter_4, size: 20),
      ],
      children: <Widget>[
        Text('Tab2-1 Contents'),
        Text('Tab2-2 Contents'),
        Text('Tab2-3 Contents'),
        Text('Tab2-4 Contents'),
      ]
    ),
  ]
)

 
■プレビュー

 
以上、アニメーションするタブUIの作成方法でした。
何かの参考になれば幸いです。