はじめに
この記事はand factory.inc Advent Calendar 2021 12日目の記事です。
昨日は @ta_nuki さんの「docker-compose v2.2.1でlinksオプションが効かず躓いた話」でした。
and factory iOSエンジニアのy-okuderaです!
今回は、FlutterでBottomNavigationBarItemタップ時にリストの一番上までスクロールさせてみます。
YouTubeなど色々なアプリである便利機能ですね!
作るもの
今回は、タブタップ時の処理を実装するのが主となるので、リストの中身は単純にしています
Android | iOS |
---|---|
開発環境
今回は、以下の環境で開発を進めていきます。
name | version |
---|---|
Android Studio | Android Studio Arctic Fox |
Flutter Version Management(FVM) | 2.2.5 |
Flutter SDK | 2.5.0 |
また、使用しているパッケージは、以下の通りです。
...
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やhooks_riverpodを利用していますが、それらの利用方法の詳細については触れません。
※ auto_routeについても同様です。
実装
以下の実装をしていきます
- タブタップ時にアクティブなタブだったら、各画面のViewModelにStreamでタブの種類を流す
- 画面側は、アクティブなタブがタップされたことが流れてきたら、scrollControllerでアニメーションしながらトップにスクロールする
タブの種類をenumで定義
ユーザータブとアイテムタブの2つを用意するので、以下のようなenumを定義します。
enum BottomTab {
user,
item,
}
Repositoryを実装する
タップされたタブを各画面のViewModelにbroadcastするためのRepositoryを実装します。
今回は、テストコードについては触れませんが、あとからテストを書けるように抽象クラスを定義しておきます
Streamに値を書き込むvoid functionと、その値を流すためのStreamControllerを定義します。
abstract class TabRepository {
StreamController<BottomTab> get tappedActiveTab;
void writeActiveTabToStream({
required BottomTab bottomTab,
});
}
定義したインターフェースを利用するクラスを実装していきます。
StreamControllerは各タブで複数回Listenする想定なので、broadcastを使用します。
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にタブの種類を書き込みます。
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を実装していきます
ViewModelでは、TabRepositoryのStreamControllerのStreamをタブの種類でfilterします。
ここで、filterしておくことで、画面側で分岐しなくて済みます
この画面では、TabRepository.tappedActiveTab.stream
がBottomTab.user
のときのみ処理をしたいので、そのようにfilterします。
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でリストを表示してみます。
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を追加します。
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でアニメーションしながらトップにスクロールさせます。
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にPushしてあります
Flutterに関してはまだまだ勉強中なので、もっと良い実装方法など情報ありましたらコメントで教えていただけると幸いです
明日のAdvent Calendarの記事もお楽しみに!