📑

【Flutter】ブックマーク一覧とリスト一覧の連動と順序を変える

2024/05/24に公開

こんにちは問題解決先行型エンジニアの yutosan です。
備忘録を含めた記事を書くことにしました。
なんの保証もしませんがどなたかの一助になれば幸いです。

全ソースコードはこちらになります。
https://github.com/github-yutosan/flutter_reorderable_list_view

環境

  • windows 11 , vscode
  • flutter 3.19.6

パッケージ

  • flutter_riverpod
  • logger
  • riverpod_annotation
  • freezed
  • cached_network_image
  • riverpod_generator
  • build_runner
  • freezed_annotation
  • json_serializable

今回の花形ウィジェット

  • ReorderableListView.builder
  • ListView.builder
  • Dismissible

主な動作

  • リスト一覧ページにあるブックマークボタンを押すことでブックマーク一覧ページにリストする
  • リスト一覧のブックマークアイコンが反転する
  • ブックマークページからブックマークを外すとリスト一覧のブックマークアイコンが未選択にもどる
  • ブックマーク一覧からブックマークの並び替えができる
  • ブックマーク一覧からブックマークを削除できる

大事なこと

riverpod_generator と freezed 用

riverpod_generatorを使っていますので、このコマンドを忘れずにREADMEに書いています。

flutter pub run build_runner build --delete-conflicting-outputs

動作イメージ

簡単な説明(前準備)

main.dart
void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

ProviderScopeでriverpodの準備をしています。
この時点ではHomePage()は空っぽのステートレスウィジェットでも構いません。

model/list_item.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'list_item.freezed.dart';
part 'list_item.g.dart';


abstract class ListItem with _$ListItem {
  const factory ListItem({
    required int id,
    required String name,
    (false) bool isBookmarked,
  }) = _ListItem;

  factory ListItem.fromJson(Map<String, dynamic> json) => _$ListItemFromJson(json);
}

ListItemの型をfreezedで作ります。最初id無しでぼんやり作っていたのですが、ユニークなidがあったほうが、扱いやすくなります。ListItemは一覧でもブックマークでも使います。ここは設計に合わせて変えてもよいと思いますが、状況に合わせて変えてください。

provider/list_item.dart
import 'package:logger/logger.dart';
import 'package:flutter_reorder_list_view/model/list_item.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'list_item.g.dart';

Logger logger = Logger();

(keepAlive: true)
class ListData extends _$ListData {
  Future<List<ListItem>> build() async {
    logger.i('fetchListItems');
    return init();
  }

  Future<List<ListItem>> init() async {
    logger.i('init');
    return [
      ListItem(id: 1, name: 'りんご', isBookmarked: false),
      ListItem(id: 2, name: 'みかん', isBookmarked: false),
      ListItem(id: 3, name: 'バナナ', isBookmarked: false),
      ListItem(id: 4, name: 'パイナップル', isBookmarked: false),
      ListItem(id: 5, name: 'いちご', isBookmarked: false),
      ListItem(id: 6, name: 'ぶどう', isBookmarked: false),
      ListItem(id: 7, name: 'メロン', isBookmarked: false),
      ListItem(id: 8, name: 'スイカ', isBookmarked: false),
      ListItem(id: 9, name: 'さくらんぼ', isBookmarked: false),
      ListItem(id: 10, name: 'もも', isBookmarked: false),
      ListItem(id: 11, name: 'グレープフルーツ', isBookmarked: false),
      ListItem(id: 12, name: 'キウイ', isBookmarked: false),
      ListItem(id: 13, name: 'パイナップル2', isBookmarked: false),
      ListItem(id: 14, name: 'いちご2', isBookmarked: false),
      ListItem(id: 15, name: 'ぶどう2', isBookmarked: false),
      ListItem(id: 16, name: 'メロン2', isBookmarked: false),
      ListItem(id: 17, name: 'スイカ2', isBookmarked: false),
      ListItem(id: 18, name: 'さくらんぼ2', isBookmarked: false),
      ListItem(id: 19, name: 'もも2', isBookmarked: false),
      ListItem(id: 20, name: 'グレープフルーツ2', isBookmarked: false),
    ];
  }

  //BookmarkのOn/Offを切り替える
  void updateBookmark(int id) {
    logger.i('updateBookmark');
    final item = state.value;
    if (item != null) {
      final index = item.indexWhere((element) => element.id == id);
      item[index] = item[index].copyWith(isBookmarked: !item[index].isBookmarked);
      state = AsyncValue.data(item);
    } else {
      logger.e('state.value is null');
    }
  }
}

リスト一覧用のプロバイダです。こんな書き方でいいのかなぁ。。
ポイントは @Riverpod(keepAlive: true) です。
画面切り替えでプロバイダが初期化されないようにしておく必要があります。
init()でリストの初期化を行っています。
updateBookmark(int)でブックマークのON/OFFを切り替えます。
このメソッドはブックマークページでも利用します。

provider/bookmark.dart
import 'package:logger/logger.dart';
import 'package:flutter_reorder_list_view/model/list_item.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'bookmark.g.dart';

Logger logger = Logger();

(keepAlive: true)
class Bookmark extends _$Bookmark {
  Future<List<ListItem>> build() async {
    return [];
  }

  void toggleBookmark(ListItem item, bool isBookmarked) {
    logger.i('toggleBookmark');
    if (isBookmarked) {
      add(item);
    } else {
      remove(item);
    }
  }

  //Bookmarkの追加
  void add(ListItem item) {
    logger.i('add');
    final items = state.value;
    if (items != null) {
      items.add(item);
      state = AsyncValue.data(items);
    } else {
      state = AsyncValue.data([item]);
    }
  }

  //Bookmarkの削除
  void remove(ListItem item) {
    logger.i('remove');
    final items = state.value;
    if (items != null) {
      items.removeWhere((element) => element.id == item.id);
      state = AsyncValue.data(items);
    } else {
      logger.e('state.value is null');
    }
  }

  //Bookmarkの並び替え
  void indexReorder(int oldIndex, int newIndex) {
    logger.i('indexReorder');
    final items = state.value;
    final item = items!.removeAt(oldIndex); //消して(removeAtは消した値を返す)
    items.insert(newIndex, item); //入れる
    state = AsyncValue.data(items);
  }
}

初期値は空っぽです。webAPIなどで連携する際はbuild()で初期読み込みは必要です。
toggleBookmark()は、リスト一覧で利用します。ブックマークをした際にブックマークのON/OFFを管理します。

add(), remove()はブックマークの追加と削除で利用します。
indexReorder()は、ブックマーク一覧で順序を切り替える処理です。oldIndex,newIndexは後述するReorderableListViewウィジェットから取得できます。

こちらも@Riverpod(keepAlive: true)で、ページ移動してもプロバイダを破棄せずに利用可能な状態にしています。基本画面内で完結するプロバイダの場合は@riverpodでautoDisposeすべきですが、リストとブックマークはお互い連動しているものであるため、保持させます。

画面の実装

pages/home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_reorder_list_view/pages/bookmark_page.dart';
import 'package:flutter_reorder_list_view/pages/list_page.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late int currentIndex;

  List<BottomNavigationBarItem> getPage() {
    return [
      BottomNavigationBarItem(
        icon: Icon(Icons.home),
        label: 'Home',
      ),
      BottomNavigationBarItem(
        icon: Icon(Icons.bookmark),
        label: 'Bookmark',
      ),
    ];
  }

  static List<Widget> pageList = [
    const ListPage(),
    const BookMarkPage(),
  ];

  
  void initState() {
    super.initState();
    currentIndex = 0;
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        items: getPage(),
        currentIndex: currentIndex,
        onTap: (index) {
          setState(() {
            currentIndex = index;
          });
        },
      ),
      body: pageList[currentIndex],
    );
  }
}

HomePage()です。BottomNavigationBarの準備をします。つまりフッターメニューです。
特に珍しいことはしていません。

pages/list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_reorder_list_view/provider/bookmark.dart';
import 'package:flutter_reorder_list_view/provider/list_item.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ListPage extends ConsumerWidget {
  const ListPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final listData = ref.watch(listDataProvider);
    ref.watch(bookmarkProvider); //初期化用に呼び出し(build)

    return Scaffold(
      appBar: AppBar(
        title: const Text('一覧画面'),
      ),
      body: listData.when(
        data: (data) {
          return Column(
            children: [
              Expanded(
                child: ListView.builder(
                  itemCount: data.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      leading: GestureDetector(
                        child: Icon(data[index].isBookmarked ? Icons.bookmark : Icons.bookmark_border),
                        onTap: () {
                          ref.read(listDataProvider.notifier).updateBookmark(data[index].id);
                          ref.read(bookmarkProvider.notifier).toggleBookmark(data[index], data[index].isBookmarked);
                        },
                      ),
                      title: Text(data[index].name),
                    );
                  },
                ),
              ),
            ],
          );
        },
        loading: () => const Center(
          child: CircularProgressIndicator(),
        ),
        error: (error, stackTrace) => Center(
          child: Text('Error: $error'),
        ),
      ),
    );
  }
}

一覧画面です。ConsumerWidget()で画面を作ります。
こちらは順序も変えませんので、ListView.builderで一覧画面をつくります。癖でColumn->Expanded->ListView.builderの構成です。これで組んでおけばとりあえず画面領域に収まらない問題も起きませんし、ウィジェットの追加も容易かなと思っています。今回はリスト全体でインジケータを出したいので、ScaffoldのbodyにlistData.when()に直接書いています。

ref.watch(bookmarkProvider);をbuild直下で呼び出していますが、bookmarkProviderを初期化するために呼び出すだけ呼び出して何もしません。こういう実装が正しいのかはわかりませんが、これを呼び出さずにref.read(bookmarkProvider.notifier)を使うとnullですよエラーが1回だけ起きてしまうため、これで回避しています。

次にListTileベースのUIで、GestureDetectorでタップ処理ができるように書きます。Iconが切り替わるように一覧データのdata[index]からブックマークの状態を見て表示切替を記述します。
onTap()でリスト画面とブックマーク画面それぞれを管理しているプロバイダの状態を保存更新します。

pages/bookmark_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_reorder_list_view/provider/bookmark.dart';
import 'package:flutter_reorder_list_view/provider/list_item.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class BookMarkPage extends ConsumerWidget {
  const BookMarkPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final bookmark = ref.watch(bookmarkProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ブックマーク画面'),
      ),
      body: Column(
        children: [
          Expanded(
            child: bookmark.when(
              data: (data) {
                return ReorderableListView.builder(
                  itemCount: data.length,
                  itemBuilder: (context, index) {
                    return Dismissible(
                      key: ValueKey(data[index].name),
                      onDismissed: (direction) {
                        final name = data[index].name;
                        ref.read(listDataProvider.notifier).updateBookmark(data[index].id); //先にBookmarkの状態を変更
                        ref.read(bookmarkProvider.notifier).remove(data[index]); //Bookmarkから削除
                        ScaffoldMessenger.of(context).showSnackBar(
                          SnackBar(
                            content: Text('${name}をブックマークから削除しました'),
                            duration: const Duration(seconds: 1),
                          ),
                        );
                      },
                      background: Container(
                        color: Colors.red,
                        child: const Icon(Icons.delete),
                      ),
                      child: ListTile(
                        key: ValueKey(data[index].name),
                        title: Text(data[index].name),
                      ),
                    );
                  },
                  onReorder: (oldIndex, newIndex) {
                    if (oldIndex < newIndex) {
                      newIndex -= 1;
                    }
                    ref.read(bookmarkProvider.notifier).indexReorder(oldIndex, newIndex);
                  },
                );
              },
              loading: () => const Center(
                child: CircularProgressIndicator(),
              ),
              error: (error, stackTrace) => Center(
                child: Text('Error: $error'),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

ブックマーク画面に来るときはlistDataProviderは初期化されている前提ですので、buildの下でwatchの初期化呼び出しはしていません。

リスト画面と同様にColumn->Expanded->ReorderableListViewの階層です。
ReorderableListViewはドラッグで順序を切り替えることができる標準実装のListViewです。
その下にあるDismissibleウィジェットは左右にスライドでアイテムを消すことができるウィジェットで組み合わせでListTileを消すために入れています。(順序を変えるだけの要件の場合はDismissibleは不要です)

ReorderableListViewは順序を変えることができるため、Dismissible、ListTileにそれぞれkeyを必ず設定します。今回はListItemのidを使ってユニーク化しています。

アイテムをスライドで削除処理が行われるとonDismissedイベントが発生します。
先にリスト一覧を管理するlistDataProviderを呼び出してブックマークフラグを反転させて、bookmarkProviderでブックマークのアイテムを削除します。この処理が逆転するとdata[index]の状態が消失してしまうので、listDataProviderのフラグ反転処理が失敗します。

次にScaffoldMessenger.of(context).showSnackBarでブックマークを削除した通知を画面下部に表示させます。表示秒数は1秒に設定しています。

ReorderableListViewのイベントonReorderは、アイテム長押しからのドラックで順序を切り替えた後に発火します。こちらで、移動前(oldIndex)と移動後(newIndex)を取得できます。

移動前より移動後が大きい場合、その間のものが上に上がり新しいインデックスがずれるため、-1することで調整しています。その後、bookmarkProviderのステートにあるデータを置き換える処理で順番を変えます。

これで、ブックマークの変更とリスト一覧の状態をそれぞれ更新できるので、ブックマークの管理ができると思います。

まとめ

あまり状態管理を理解しきれていなくてなかなかうまい説明ができませんが、画面に合わせた状態をしっかり持って、それらに関連する状態変更を行うことでどこの画面でも遠くの画面の状態を変更することができました。これが正しい実装、理想の実装であるのかは疑問ですが、同じような悩みを持つ方の何かの助けになれば幸いです。

Discussion