背景
FlutterでタブUIを実装したい場合、基本的にはTabBar / TabBarViewウィジェットを用いて実装が可能です。
しかし、TabBarを細かくカスタムしたい場合実現しにくいUIもあります。
そういった場合は、PageViewを用いて実装することで複雑なタブUIにも対応ができます。
使用package
パッケージと言っても特段UI用の物を使うわけではありません。
TabとPageの状態をControllerで管理するためにhooksを用います。
flutter pub add flutter_hooks gap
実装例
例えば以下のようなUIはTabBarのみだと苦労します。
サンプル |
---|
上記のようなUIはPageViewなどを用いて以下のように実装ができます。
テーマ設定等スタイルは雑なのであくまでPageViewのサンプルとして扱ってください。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
void main() {
runApp(const MyApp());
}
/// ダミーのタブリスト
final tabs = ['Menu1', 'Menu2', 'Menu3', 'Menu4'];
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sample Tabs App',
theme: ThemeData(
primarySwatch: Colors.red,
canvasColor: Colors.white,
),
home: const DemoPage(),
);
}
}
/// ダミーのスライバーリストアイテム
class SliverListItem extends StatelessWidget {
const SliverListItem({super.key});
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return ListTile(
title: Text('Item $index'),
);
},
childCount: 20,
),
);
}
}
/// タブアイテム表示用
class _TabItem extends StatelessWidget {
const _TabItem({
required this.isSelected,
required this.tabLabel,
});
final bool isSelected;
final String tabLabel;
@override
Widget build(BuildContext context) {
return Text(
tabLabel,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.black : Colors.white,
),
);
}
}
class DemoPage extends HookWidget {
const DemoPage({super.key});
@override
Widget build(BuildContext context) {
// 選択状態のタブ
final selectedIndex = useState(0);
// pageに関する管理
final pageController = usePageController();
// タブ・ページの横スクロールの管理
final tabScrollController = useScrollController();
// タブ切替中に別アクションをされないように制御
final isSwitchingTab = useState(false);
/// タブをタップした際のアクション
Future<void> onTabSelected(int index) async {
isSwitchingTab.value = true;
final currentTabIndex = selectedIndex.value;
selectedIndex.value = index;
await Future.wait([
pageController.animateToPage(
index,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
),
tabScrollController.animateTo(
currentTabIndex < index ? index * 40.0 : -(index * 40.0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
]);
isSwitchingTab.value = false;
}
return Scaffold(
appBar: AppBar(
title: const Text('タブサンプル'),
),
body: DefaultTabController(
length: tabs.length,
child: Column(
children: [
_TabBar(
tabs: tabs,
scrollController: tabScrollController,
selectedIndex: selectedIndex,
onTabSelected: onTabSelected,
),
const Gap(4),
Expanded(
child: PageView(
controller: pageController,
onPageChanged: (index) async {
if (isSwitchingTab.value) {
return;
}
final currentTabIndex = selectedIndex.value;
selectedIndex.value = index;
// unawaited相当:ここでは特に待たなくても問題ないのでawaitを省略
tabScrollController.animateTo(
currentTabIndex < index ? index * 40.0 : -(index * 40.0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
},
children: [
const CustomScrollView(slivers: [SliverListItem()]),
const CustomScrollView(slivers: [SliverListItem()]),
const CustomScrollView(slivers: [SliverListItem()]),
const CustomScrollView(slivers: [SliverListItem()]),
],
),
),
],
),
),
);
}
}
class _TabBar extends StatelessWidget {
const _TabBar({
required this.tabs,
required this.scrollController,
required this.selectedIndex,
required this.onTabSelected,
});
final List<String> tabs;
final ScrollController scrollController;
final ValueNotifier<int> selectedIndex;
final void Function(int) onTabSelected;
static const selectedBorder = Border(
top: BorderSide(
color: Colors.blue,
width: 2,
),
left: BorderSide(
color: Colors.blue,
width: 2,
),
right: BorderSide(
color: Colors.blue,
width: 2,
),
);
static const defaultBorder = Border(
bottom: BorderSide(
color: Colors.blue,
width: 2,
),
);
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(
minHeight: 40,
maxHeight: 50,
),
width: double.infinity,
child: Stack(
children: [
const Positioned(
bottom: 0,
left: 0,
right: 0,
child: SizedBox(
height: 2,
child: Divider(
thickness: 2,
color: Colors.blue,
),
),
),
ListView.builder(
controller: scrollController,
primary: false,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(left: 4, right: 16),
scrollDirection: Axis.horizontal,
itemCount: tabs.length,
itemBuilder: (context, index) {
final tab = tabs[index];
final isSelected = selectedIndex.value == index;
return Align(
alignment: Alignment.bottomCenter,
child: GestureDetector(
onTap: () async => onTabSelected(index),
child: Container(
constraints: BoxConstraints(
minHeight: isSelected ? 36 : 32,
maxHeight: isSelected ? 50 : 44,
),
padding: EdgeInsets.only(
left: 10,
right: 10,
top: 4,
bottom: 2,
),
margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
border: isSelected ? selectedBorder : defaultBorder,
color: isSelected ? Colors.white : Colors.blue,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
child: _TabItem(
isSelected: isSelected,
tabLabel: tab,
),
),
),
);
},
),
],
),
);
}
}
以上です!タブの実装で困った場合はPageViewを検討しましょう!