この記事では、アニメーションしながら画面が切り替わるタブ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の作成方法でした。
何かの参考になれば幸いです。