Flutterでアニメーション付きのタブをつくってみた
以下のGIF画像のようなタブのカスタムウィジェットをつくってみました。 Flutterにも標準のタブのウィジェットはありますが、デザインをカスタマイズしたいときに自分で作る必要があると思います。その場合、この記事が参考になるはずです。
まとめ
アニメーション付きタブのカスタムウィジェットを作るときのポイントは以下の通りです。
- 選択したタブを中央に持ってくるには、ScrollControllerの
animateTo()
を使う。 - 選択状態を表すマーカー(GIFの白い角丸の長方形の部分)を移動するには、
AnimatedPositioned
が使える。- 移動先の位置を指定する際に、RenderBoxの
localToGlobal(point, {ancestor})
が使える。ancestorで指定したRenderBoxとの相対位置を取得できる。
- 移動先の位置を指定する際に、RenderBoxの
動作イメージ
選択状態を表すマーカー(白い角丸の長方形の部分)が選択したタブまで移動します。また、選択したタブは中央に移動します。
バージョン
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()を生成しています。また、初回の選択状態を設定しています。
- 見慣れない人のために...
..
はCascading Notationです。参考: https://dart.dev/guides/language/language-tour#cascade-notation
- 見慣れない人のために...
- (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←タブのタイトルたちを表示
- Stack
- SingleChildScrollView
- (1) GlobalKeyを使って、各WidgetのRenderBoxを取得します。
- (2) AnimatedPositionedを使って、マーカーをアニメーション付きで表示します。
- (3) タブのタップで呼ばれる_select()では、(2)と同じような感じで相対位置を取得して、選択されたタブが中央にスクロールするようにします。ScrollControllerのanimateTo()を使います。
- (4) Widgetのbuildが完了したら、initialSelectedIndexで指定されたタブを選択状態にします。
- (5) TabHeaderViewControllerのselect()を使うことで、このカスタムウィジェットの外からもタブを選択できるようになっています。例えば、PageViewで使われることを想定しています。
以上です。読んでいただきありがとうございます!