10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

kurogoma939のひとりアドベントカレンダーAdvent Calendar 2024

Day 19

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

Last updated at Posted at 2024-12-18

背景

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

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

使用package

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

flutter pub add flutter_hooks gap

実装例

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

サンプル
Videotogif (1).gif

上記のような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を検討しましょう!

10
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?