【Flutter】アコーディオンメニューを実装する

こんにちは、株式会社Pentagonでアプリを開発している小寺です。
今回はアコーディオンメニューと呼ばれる項目をタップすることで隠れている詳細画面を表示するメニューを実装していきます。

【こんな人に読んで欲しい】
・Flutterを用いてアコーディオンメニューを実装したい方
【この記事を読むメリット】
・アコーディオンの実装方法が理解できる
・実際の動きが確認できる
【結論】
結論からすると、アコーディオンはUIも良くスッキリとした見栄えになります。また、アコーディオンを実装する場合は配列の実装も重要になるため、技術力の向上にもつながると思います。

目次

アコーディオンを実装するために必要なwidget

アコーディオンを実装するためにはExpansionTileクラスを使用します。
アコーディオンを開くとExpansionTile内にあるchidrenにセットしたWidgetが表示されます。
また、アコーディオンを開いた際の挙動を設定する場合は onExpansionChanged内に実装することで指定できます。

導入したパッケージ

今アコーディオンについて必要なパッケージは特にありませんが、状態管理を行なっているため以下のパッケージを利用しています。

hooks_riverpod: ^1.0.3
flutter_hooks: ^0.18.2+1

上記をpubspec.yaml内に記載した後、Terminalでfvm flutter pub getを実行します。

全体のコード

import 'package:acordion/area.dart';
import 'package:acordion/color.dart';
import 'package:acordion/prefecture.dart';
import 'package:acordion/service.dart';
import 'package:acordion/space.dart';
import 'package:acordion/style.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class Acordion extends HookConsumerWidget {
  const Acordion({super.key});

  static const _tilePadding = 16.0;

  static const secondaryBoldTextStyle = TextStyle(
    fontWeight: FontWeight.w900,
    fontSize: 15,
    color: colorSecondaryMain,
  );

  static const grayBoldTextStyle = TextStyle(
    fontWeight: FontWeight.w900,
    fontSize: 15,
    color: Color(0xFFC9C9C9),
  );

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final areas = ref.watch(areasProvider);
    final prefectureService = ref.watch(prefectureServiceProvider.notifier);

    final expandedAreaNotifier = useState(List.generate(8, (index) => false));

    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
      ),
      body: SafeArea(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 17),
              child: Text(
                '都道府県',
                style: bigBoldTextStyle,
              ),
            ),
            Expanded(
              child: ListView(
                children: areas.map((area) {
                  return areaCard(
                    context,
                    ref,
                    areas,
                    area,
                    expandedAreaNotifier,
                    prefectureService,
                  );
                }).toList(),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget areaCard(
    BuildContext context,
    WidgetRef ref,
    List<Area> areas,
    Area area,
    ValueNotifier<List<bool>> expandedAreaNotifier,
    PrefectureService prefectureService,
  ) {
    return DecoratedBox(
      decoration: BoxDecoration(
        border: Border(
          top: areas.first == area
              ? BorderSide.none
              : const BorderSide(color: colorGrayEF),
          bottom: areas.last == area
              ? const BorderSide(color: colorGrayEF)
              : BorderSide.none,
        ),
      ),
      child: Theme(
        data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
        child: ExpansionTile(
          onExpansionChanged: (value) {
            Scrollable.ensureVisible(context);
            final list = List<bool>.from(expandedAreaNotifier.value);
            list[area.id] = value;
            expandedAreaNotifier.value = list;
          },
          tilePadding: const EdgeInsets.all(_tilePadding),
          title: Text(
            '${area.name} (${area.prefectures.length})',
            style: boldTextStyle,
          ),
          trailing: AnimatedRotation(
            turns: expandedAreaNotifier.value[area.id] ? .5 : 0,
            duration: const Duration(milliseconds: 200),
            child: CircleAvatar(
              backgroundColor: const Color(0xFF000000).withOpacity(0.03),
              child: const Icon(
                Icons.keyboard_arrow_down_outlined,
                color: colorSecondaryMain,
              ),
            ),
          ),
          children: area.prefectures.map((prefecture) {
            return _listItem(ref, prefecture);
          }).toList(),
        ),
      ),
    );
  }

  Widget _listItem(WidgetRef ref, Prefecture prefecture) {
    return GestureDetector(
      onTap: () {},
      child: Container(
        height: 64,
        padding: const EdgeInsets.only(
          left: 42,
          right: 16,
        ),
        decoration: const BoxDecoration(
          border: Border(
            top: BorderSide(color: colorGrayEF),
          ),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Row(
              children: [
                spaceW12,
                SizedBox(
                  width: 60,
                  child: prefecture.prefectureName(
                    prefecture,
                    boldTextStyle,
                    textAlign: TextAlign.start,
                  ),
                ),
              ],
            ),
            spaceW8,
          ],
        ),
      ),
    );
  }
}

実装した画像

今回47都道府県を8地方区分で分けたものを実装しました。


コードを噛み砕いて説明する

Expanded(
  child: ListView(
    children: areas.map((area) {
      return areaCard(
         context,
         ref,
         areas,
         area,
         expandedAreaNotifier,
         prefectureService,
       );
     }).toList(),
),

上記では8地方に分けたエリアがareasで分類されており配列mapを用いてareasに値がある間areaCardを表示する処理をしています。

Widget areaCard(
    BuildContext context,
    WidgetRef ref,
    List<Area> areas,
    Area area,
    ValueNotifier<List<bool>> expandedAreaNotifier,
    PrefectureService prefectureService,
  ) {
    return DecoratedBox(
      decoration: BoxDecoration(
        border: Border(
          top: areas.first == area
              ? BorderSide.none
              : const BorderSide(color: colorGrayEF),
          bottom: areas.last == area
              ? const BorderSide(color: colorGrayEF)
              : BorderSide.none,
        ),
      ),
      child: Theme(
        data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
        child: ExpansionTile(
          onExpansionChanged: (value) {
            Scrollable.ensureVisible(context);
            final list = List<bool>.from(expandedAreaNotifier.value);
            list[area.id] = value;
            expandedAreaNotifier.value = list;
          },
          tilePadding: const EdgeInsets.all(_tilePadding),
          title: Text(
            '${area.name} (${area.prefectures.length})',
            style: boldTextStyle,
          ),
          trailing: AnimatedRotation(
            turns: expandedAreaNotifier.value[area.id] ? .5 : 0,
            duration: const Duration(milliseconds: 200),
            child: CircleAvatar(
              backgroundColor: const Color(0xFF000000).withOpacity(0.03),
              child: const Icon(
                Icons.keyboard_arrow_down_outlined,
                color: colorSecondaryMain,
              ),
            ),
          ),
          children: area.prefectures.map((prefecture) {
            return _listItem(ref, prefecture);
          }).toList(),
        ),
      ),
    );
  }

WidgetのareaCard内では今回のmainであるアコーディオンを実装しています。アコーディオンの実装にはexpansionTileを用いて実装しています。
expansionTile内のchildrenではエリアの中にある都道府県がprefecturesにリストで作成されているため配列mapを用いてprefectureがある間_listItemを表示するよう処理しています。
trailingにAnimatedRotationを用いてタイルを開いた時に動きを実装していますが、デフォルトで指定されているため特に色やicon、動きの変更がなければデフォルトでの実装をすることも可能です。

Widget _listItem(WidgetRef ref, Prefecture prefecture) {
    return GestureDetector(
      onTap: () {},
      child: Container(
        height: 64,
        padding: const EdgeInsets.only(
          left: 42,
          right: 16,
        ),
        decoration: const BoxDecoration(
          border: Border(
            top: BorderSide(color: colorGrayEF),
          ),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Row(
              children: [
                spaceW12,
                SizedBox(
                  width: 60,
                  child: prefecture.prefectureName(
                    prefecture,
                    boldTextStyle,
                    textAlign: TextAlign.start,
                  ),
                ),
              ],
            ),
            spaceW8,
          ],
        ),
      ),
    );
  }
}

Widget _listItem内にはアコーディオンが開いた際に表示される都道府県の処理が実装されています。

アコーディオンの動き

まとめ

アコーディオンメニューは配列をメインに使用するため配列の知識が身につくと思います。また、UIが見やすいためあらゆる場面で実装できそうな印象です。

おまけ

Flutterに関する知識を深めたい方には、『Flutterの特徴・メリット・デメリットを徹底解説』という記事がおすすめです。

この記事では、Flutter アプリ開発の基本から、Flutter とは何か、そして実際のFlutter アプリ 事例を通じて、その将来性やメリット、デメリットまで詳しく解説しています。
Flutterを使ったアプリ開発に興味がある方、またはその潜在的な可能性を理解したい方にとって、必見の内容となっています。

ぜひ一度ご覧ください。

採用情報はこちら
目次