まつちよの日記

プログラミングに関する知見や、思ったことを書き残します。

Flutterでアニメーション付きのタブをつくってみた

以下のGIF画像のようなタブのカスタムウィジェットをつくってみました。 Flutterにも標準のタブのウィジェットはありますが、デザインをカスタマイズしたいときに自分で作る必要があると思います。その場合、この記事が参考になるはずです。

まとめ

アニメーション付きタブのカスタムウィジェットを作るときのポイントは以下の通りです。

  • 選択したタブを中央に持ってくるには、ScrollControllerのanimateTo()を使う。
  • 選択状態を表すマーカー(GIFの白い角丸の長方形の部分)を移動するには、AnimatedPositionedが使える。
    • 移動先の位置を指定する際に、RenderBoxのlocalToGlobal(point, {ancestor})が使える。ancestorで指定したRenderBoxとの相対位置を取得できる。

動作イメージ

選択状態を表すマーカー(白い角丸の長方形の部分)が選択したタブまで移動します。また、選択したタブは中央に移動します。

f:id:matsuchiyoo:20211222132428g:plain
タブのカスタムウィジェットの動作イメージ

バージョン

Flutterのバージョンは2.5.1です。

利用例

カスタムウィジェットTabHeaderViewの利用例です。 おなじみのFlutterプロジェクトを新規作成したときのサンプルをベースにしています。

main.dart

import 'package:flutter/material.dart';
import 'package:tab_header_view_sample/tab_header_view.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _selectedIndex = 0;
  List<String> _tabTitles = [
    "Tab Title 1",
    "Tab Title 2",
    "Tab Title 3",
    "Tab Title 4",
    "Tab Title 5",
    "Tab Title 6",
    "Tab Title 7",
    "Tab Title 8",
    "Tab Title 9",
  ];
  late TabHeaderViewController _tabHeaderViewController;

  @override
  void initState() {
    super.initState();
    _tabHeaderViewController = TabHeaderViewController() // (1)
      ..initialSelectedIndex = 0;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
        elevation: 0,
      ),
      body: Container(
        child: Column( // (2)
          verticalDirection: VerticalDirection.up, // (3)
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Expanded(
              child: Container(
                color: Colors.white,
                child: Center(
                  child: Text(_tabTitles[_selectedIndex]),
                ),
              ),
            ),
            Material(
              elevation: 4,
              child: TabHeaderView( // (4) 今回作ったカスタムウィジェット
                titles: _tabTitles,
                controller: _tabHeaderViewController,
                onTabSelect: (index) {
                  print("***** index: $index");
                  setState(() {
                    _selectedIndex = index;
                  });
                },
                backgroundColor: Theme.of(context).primaryColor,
                tintColor: Colors.white,
              ),
            ),
          ],
        ),
      )
    );
  }
}

利用例のポイント

  • (1)では、TabHeaderViewに設定するTabHeaderViewController()を生成しています。また、初回の選択状態を設定しています。
  • (2)では、Columnでタブヘッダーとコンテンツを表示しています。
    • AppBarのbottomを使うと同じように表示できるのですが、bottomに設定するにはPreferredSizeWidgetをimplementsする必要があり、ここではシンプルさのため、Columnで表示してしまっています。
  • (3)では、タブヘッダーに影をつけるため、VerticalDirection.upにして、下から上にWidgetが表示されるようにしています。 参考:https://github.com/flutter/flutter/issues/12206
  • (4) ここで、今回つくったカスタムウィジェットを使っています。

ソース

tab_header_view.dart

import 'dart:math';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:tab_header_view_sample/list_extension.dart';

class TabHeaderView extends StatefulWidget {
  final List<String> titles;
  final TabHeaderViewController controller;
  final void Function(int index) onTabSelect;
  final Color? backgroundColor;
  final Color? tintColor;
  final Duration duration;
  final Curve curve;
  final EdgeInsetsGeometry scrollContentInsets;
  final EdgeInsetsGeometry tabTitleInsets;
  final double interItemSpace;

  TabHeaderView({
    required this.titles,
    required this.controller,
    required this.onTabSelect,
    this.tintColor,
    this.backgroundColor,
    this.duration = const Duration(milliseconds: 250),
    this.curve = Curves.easeInOut,
    this.scrollContentInsets = const EdgeInsets.fromLTRB(16, 10, 16, 10),
    this.tabTitleInsets = const EdgeInsets.fromLTRB(6, 2, 6, 2),
    this.interItemSpace = 6,
  });

  @override
  State<TabHeaderView> createState() => _TabHeaderViewState();
}

class _TabHeaderViewState extends State<TabHeaderView> {
  final _scrollController = ScrollController();
  final GlobalKey _scrollViewKey = GlobalKey();
  final GlobalKey _scrollContentKey = GlobalKey();
  final GlobalKey _stackKey = GlobalKey();
  late List<GlobalKey> _tabKeys;
  int _selectedIndex = -1;

  @override
  void initState() {
    super.initState();
    _tabKeys = widget.titles.map ((_) => GlobalKey()).toList();
    widget.controller.onSelected = _select;
    WidgetsBinding.instance?.addPostFrameCallback((_) { // (4)
      final int? initialSelectedIndex = widget.controller.initialSelectedIndex;
      if (initialSelectedIndex != null) {
        _select(initialSelectedIndex);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    // (1)
    final GlobalKey? selectedTabKey = _tabKeys.getOrNull(_selectedIndex);
    final RenderBox? selectedTabRenderBox = selectedTabKey?.currentContext?.findRenderObject() as RenderBox?;
    final RenderBox? stackRenderBox = _stackKey.currentContext?.findRenderObject() as RenderBox?;
    return Container(
      key: _scrollViewKey,
      color: widget.backgroundColor ?? theme.primaryColor,
      child: SingleChildScrollView(
        controller: _scrollController,
        scrollDirection: Axis.horizontal,
        child: Container(
          key: _scrollContentKey,
          padding: widget.scrollContentInsets,
          child: Stack( // (1)
            key: _stackKey,
            fit: StackFit.passthrough,
            children: [
              stackRenderBox == null || selectedTabRenderBox == null ? Container() : AnimatedPositioned( // (2)
                width: selectedTabRenderBox.size.width,
                height: selectedTabRenderBox.size.height,
                top: selectedTabRenderBox.localToGlobal(Offset.zero, ancestor: stackRenderBox).dy, // (2-1)
                left: selectedTabRenderBox.localToGlobal(Offset.zero, ancestor: stackRenderBox).dx,
                child: Container(
                  decoration: BoxDecoration(
                    color: widget.tintColor ?? theme.accentColor,
                    borderRadius: BorderRadius.circular(selectedTabRenderBox.size.height / 2),
                  ),
                ),
                duration: widget.duration,
                curve: widget.curve,
              ),
              Row(
                children: widget.titles.asMap().entries.map((entry) {
                  final int index = entry.key;
                  final String title = entry.value;
                  return _buildTab(
                    key: _tabKeys[index],
                    title: title,
                    isSelected: index == _selectedIndex,
                    marginLeft: index == 0 ? 0 : widget.interItemSpace,
                    onTap: () {
                      widget.onTabSelect(index);
                      _select(index);
                    },
                  );
                }).toList(),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildTab({required Key key, required String title, required bool isSelected, required double marginLeft, required void Function() onTap }) {
    final ThemeData theme = Theme.of(context);
    final normalTextColor = widget.tintColor ?? theme.accentColor;
    final selectedTextColor = widget.backgroundColor ?? theme.primaryColor;
    return Container(
      margin: EdgeInsets.only(left: marginLeft),
      child: GestureDetector(
        onTap: onTap,
        child: Container(
          key: key,
          height: 23,
          padding: widget.tabTitleInsets,
          child: Text(
            title,
            style: TextStyle(
              color: isSelected ? selectedTextColor : normalTextColor,
              fontSize: 14,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
    );
  }

  // (3)
  void _select(int index) {
    final GlobalKey? selectedTabKey = _tabKeys.getOrNull(index);
    final RenderBox? selectedTabRenderBox = selectedTabKey?.currentContext?.findRenderObject() as RenderBox?;
    final RenderBox? scrollContentRenderBox = _scrollContentKey.currentContext?.findRenderObject() as RenderBox?;
    final RenderBox? scrollViewRenderBox = _scrollViewKey.currentContext?.findRenderObject() as RenderBox?;
    if (selectedTabRenderBox != null && scrollContentRenderBox != null && scrollViewRenderBox != null) {
      final tabXInScrollContent = selectedTabRenderBox.localToGlobal(Offset.zero, ancestor: scrollContentRenderBox).dx;
      final idealTabXInScrollViewFrame = (scrollViewRenderBox.size.width - selectedTabRenderBox.size.width) / 2;
      final double minOffsetX = 0;
      final maxOffsetX = scrollContentRenderBox.size.width - scrollViewRenderBox.size.width;
      final offsetX = min(maxOffsetX, max(minOffsetX, tabXInScrollContent - idealTabXInScrollViewFrame));
      _scrollController.animateTo(offsetX, duration: widget.duration, curve: widget.curve);
    }
    setState(() {
      _selectedIndex = index;
    });
  }
}

class TabHeaderViewController {

  int? initialSelectedIndex;

  void Function(int index)? onSelected;

  TabHeaderViewController({
    this.initialSelectedIndex,
  });
  
  void select(int index) { // (5)
    onSelected?.call(index);
  }

  void dispose() {
  }
}

↓こちらはListのExtensionです。

list_extension.dart

extension ListExtension<T> on List<T> {
  T? getOrNull(int index) {
    return (index >= 0 && index < this.length) ? this[index] : null;
  }
}

ソースのポイント

  • ざっくりWidgetの構造は以下の通りです。
    • SingleChildScrollView
      • Stack
        • AnimatedPosition←選択状態を表すマーカー(白い角丸の長方形)を表示
        • Row←タブのタイトルたちを表示
  • (1) GlobalKeyを使って、各WidgetのRenderBoxを取得します。
  • (2) AnimatedPositionedを使って、マーカーをアニメーション付きで表示します。
    • (2-1) RenderBoxのlocalToGlobal()で、ancestorで指定したWidgetからの相対位置を取得します。iOSのUIKitでいうところのconvert(_:to:)のような感じです。
  • (3) タブのタップで呼ばれる_select()では、(2)と同じような感じで相対位置を取得して、選択されたタブが中央にスクロールするようにします。ScrollControllerのanimateTo()を使います。
  • (4) Widgetのbuildが完了したら、initialSelectedIndexで指定されたタブを選択状態にします。
  • (5) TabHeaderViewControllerのselect()を使うことで、このカスタムウィジェットの外からもタブを選択できるようになっています。例えば、PageViewで使われることを想定しています。

以上です。読んでいただきありがとうございます!