FlutterでアクティブなBottomNavigationBarItemがタップされたらトップまでスクロールさせてみる

# はじめに
この記事はand factory.inc Advent Calendar 2021 12日目の記事です。
昨日は @ta_nuki さんの「[docker-compose v2.2.1でlinksオプションが効かず躓いた話](https://qiita.com/ta_nuki/items/7830c6ab6d8b3c4327cc)」でした。

and factory iOSエンジニアのy-okuderaです！

今回は、FlutterでBottomNavigationBarItemタップ時にリストの一番上までスクロールさせてみます。
YouTubeなど色々なアプリである便利機能ですね！

# 作るもの
今回は、タブタップ時の処理を実装するのが主となるので、リストの中身は単純にしています:slight_smile:

|Android|iOS|
|:--:|:--:|
|![android.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/f5bbb8f0-70e4-9876-4ac3-636ca4efc6b6.gif)|![ios.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/8029027a-2bd6-73a6-8893-68e35838f139.gif)|

# 開発環境

今回は、以下の環境で開発を進めていきます。

|name|version|
|:--:|:--:|
|Android Studio|Android Studio Arctic Fox | 2020.3.1 Patch 2|
|Flutter Version Management(FVM)|2.2.5|
|Flutter SDK|2.5.0|

また、使用しているパッケージは、以下の通りです。

```yml:pubspec.yaml
...

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.2

  flutter_hooks: ^0.18.1
  hooks_riverpod: ^1.0.2
  auto_route: ^3.1.3

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^1.0.0
  build_runner: ^2.0.4
  auto_route_generator: ^3.1.0
...

```

※ [flutter_hooks](https://pub.dev/packages/flutter_hooks)や[hooks_riverpod](https://pub.dev/packages/hooks_riverpod)を利用していますが、それらの利用方法の詳細については触れません。:bow:
※ [auto_route](https://pub.dev/packages/auto_route)についても同様です。:bow:

# 実装
以下の実装をしていきます:point_up:

- タブタップ時にアクティブなタブだったら、各画面のViewModelにStreamでタブの種類を流す
- 画面側は、アクティブなタブがタップされたことが流れてきたら、scrollControllerでアニメーションしながらトップにスクロールする


## タブの種類をenumで定義
ユーザータブとアイテムタブの2つを用意するので、以下のようなenumを定義します。

```dart:bottom_tab.dart
enum BottomTab {
  user,
  item,
}
```

## Repositoryを実装する
タップされたタブを各画面のViewModelにbroadcastするためのRepositoryを実装します。

今回は、テストコードについては触れませんが、あとからテストを書けるように抽象クラスを定義しておきます:upside_down:
Streamに値を書き込むvoid functionと、その値を流すためのStreamControllerを定義します。

```dart:tab_repository.dart
abstract class TabRepository {
  StreamController<BottomTab> get tappedActiveTab;

  void writeActiveTabToStream({
    required BottomTab bottomTab,
  });
}
```
定義したインターフェースを利用するクラスを実装していきます。

StreamControllerは各タブで複数回Listenする想定なので、broadcastを使用します。

https://api.dart.dev/stable/2.14.4/dart-async/StreamController/StreamController.broadcast.html

```dart:tab_repository.dart
final Provider<TabRepository> tabRepositoryProvider =
    Provider((ref) => TabRepositoryImpl());

class TabRepositoryImpl implements TabRepository {
  TabRepositoryImpl();

  final _tappedActiveTab = StreamController<BottomTab>.broadcast();

  @override
  StreamController<BottomTab> get tappedActiveTab => _tappedActiveTab;

  @override
  void writeActiveTabToStream({
    required BottomTab bottomTab,
  }) {
    _tappedActiveTab.sink.add(bottomTab);
  }
}
```

## TabViewModelを実装する
TabViewModelは、タブのタップイベントを受け取ります。
現在アクティブなタブのインデックスと、タップされたタブのインデックスが同じ場合のみRepositoryにタブの種類を書き込みます。

```dart:tab_view_model.dart
final tabViewModelProvider = ChangeNotifierProvider.autoDispose(
    (ref) => TabViewModel(ref.read(tabRepositoryProvider)));

class TabViewModel extends ChangeNotifier {
  TabViewModel(this._tabRepository);

  final TabRepository _tabRepository;

  void onTapTab({
    required int currentIndex,
    required int tappedIndex,
  }) {
    final bottomTab = BottomTab.values[tappedIndex];

    if (currentIndex == tappedIndex) {
      debugPrint("on tap ActiveTab");
      _tabRepository.writeActiveTabToStream(bottomTab: bottomTab);
    }
  }
}
```

## 画面側でStreamを購読して、トップまでスクロールする
qiita_user_screen.dartとqiita_user_view_model.dartを実装していきます:man_tone1:

ViewModelでは、TabRepositoryのStreamControllerのStreamをタブの種類でfilterします。
ここで、filterしておくことで、画面側で分岐しなくて済みます:muscle:
この画面では、`TabRepository.tappedActiveTab.stream`が`BottomTab.user`のときのみ処理をしたいので、そのようにfilterします。

```dart:qiita_user_view_model.dart
final qiitaUserViewModelProvider = ChangeNotifierProvider(
    (ref) => QiitaUserViewModel(ref.read(tabRepositoryProvider)));

class QiitaUserViewModel extends ChangeNotifier {
  QiitaUserViewModel(this._tabRepository);

  final TabRepository _tabRepository;

  Stream<BottomTab> get tappedActiveTab => _tabRepository.tappedActiveTab.stream
      .where((bottomTab) => bottomTab == BottomTab.user);
}
```

画面側を実装していきます。
まずは、ListViewでリストを表示してみます。

```dart:qiita_user_screen.dart(1)
class QiitaUserScreen extends HookConsumerWidget {
  const QiitaUserScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("User"),
      ),
      body: ListView.builder(
        itemCount: 100,
        itemBuilder: (BuildContext context, int index) {
          return Container(
            height: 50,
            width: double.infinity,
            color: index % 2 == 0 ? Colors.white : Colors.grey,
            child: Center(
              child: Text(
                "$index",
                textAlign: TextAlign.center,
              ),
            ),
          );
        },
      ),
    );
  }
}
```

何も制御していないので当然ですが、この時点では、タブをタップしても、特に何も起こりません。

次にスクロールをプログラムから制御するために、ScrollControllerを追加します。

https://api.flutter.dev/flutter/widgets/ScrollController-class.html

```dart:qiita_user_screen.dart(2)
class QiitaUserScreen extends HookConsumerWidget {
  const QiitaUserScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final scrollController = ScrollController(); // 追加

    return Scaffold(
      appBar: AppBar(
        title: const Text("User"),
      ),
      body: ListView.builder(
        controller: scrollController,// 追加
        itemCount: 100,
        itemBuilder: (BuildContext context, int index) {
          ・・・
        },
      ),
    );
  }
}
```

最後にViewModelのStreamをListenして、ScrollControllerでアニメーションしながらトップにスクロールさせます。

```dart:qiita_user_screen.dart(3)
class QiitaUserScreen extends HookConsumerWidget {
  const QiitaUserScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.read(qiitaUserViewModelProvider);
    final scrollController = ScrollController();

    useEffect(() {
      final subscription = viewModel.tappedActiveTab.listen((bottomTab) {
        debugPrint("tappedActiveTab: $bottomTab");

        // Stateが更新されるので、addPostFrameCallbackで対処
        SchedulerBinding.instance?.addPostFrameCallback((_) {
          // Scroll to top
          scrollController.animateTo(
            scrollController.position.minScrollExtent,
            duration: const Duration(milliseconds: 400),
            curve: Curves.fastOutSlowIn,
          );
        });
      });
      return subscription.cancel;
    }, [viewModel.tappedActiveTab]);

    return Scaffold(
      ・・・
    );
  }
}
```

# さいごに
今回実装してみたソースコードは、GitHub [y-okudera/flutter_bottom_navigation](https://github.com/y-okudera/flutter_bottom_navigation)にPushしてあります:santa:

Flutterに関してはまだまだ勉強中なので、もっと良い実装方法など情報ありましたらコメントで教えていただけると幸いです:bow:

明日の[Advent Calendar](https://qiita.com/advent-calendar/2021/andfactory)の記事もお楽しみに！