FlutterでTabBarが使えないUIはPageViewで実装する

## 背景

FlutterでタブUIを実装したい場合、基本的にはTabBar / TabBarViewウィジェットを用いて実装が可能です。

https://docs.flutter.dev/cookbook/design/tabs

しかし、TabBarを細かくカスタムしたい場合実現しにくいUIもあります。
そういった場合は、PageViewを用いて実装することで複雑なタブUIにも対応ができます。

## 使用package

パッケージと言っても特段UI用の物を使うわけではありません。
TabとPageの状態をControllerで管理するためにhooksを用います。

```bash
flutter pub add flutter_hooks gap
```

## 実装例

例えば以下のようなUIはTabBarのみだと苦労します。



| サンプル |
|----|
| ![Videotogif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/511bcb7c-e06c-14a2-bad1-1777e4032fcc.gif) |


上記のようなUIはPageViewなどを用いて以下のように実装ができます。

:::note warn
テーマ設定等スタイルは雑なのであくまでPageViewのサンプルとして扱ってください。
:::


```dart
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を検討しましょう！