タブ切り替え時でもListViewのスクロール位置を保持したい

flutterでタブを含むUIを実装しているとき、タブ中に配置したScrollViewやListViewのスクロール位置がタブを変更したタイミングでリセットされる現象は有名な現象だと思います。少しググるだけで解決策は出てきますが、最もスマートかつflutterが公式に推奨している方法はPageStorageを使う方法です。



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



実装方法も簡単でScrollViewやListViewのコンストラクターで一意の`PageStorageKey`を与えるだけです。この記事ではなぜ`PageStorageKey`を設定するとスクロール位置が保持されるのかflutterのソースコードを調査して明らかにします。



<!-- more -->



## スクロール位置の保存と復帰
スクロール位置の保存と復帰処理はScrollPosition内の`saveScrollOffset`および`restoreScrollOffset`に実装されています。

https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/scroll_position.dart#L397-L400
https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/scroll_position.dart#L418-L425

`saveScrollOffset`はスクロール終了時に呼ばれる`didEndScroll`から呼び出され、`restoreScrollOffset`はScrollPositionのコンストラクターから呼び出されます。スクロール終了時位置の保存、復帰処理はScrollControllerのプロパティ`keepScrollOffset`にのみ依存していて、PageStorageKeyがセットされているかは関係なく実行されます。

ちなみにスクロール終了を通知する`didEndScroll`は`beginActivity`というメソッドから呼ばれています。

https://github.com/flutter/flutter/blob/60a8b333b02336194b1dd04f03d788c4c3d75b9c/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart#L110-L120

`beginActivity`はScrollableがScrollPositionを参照する際に利用している`ScrollPositionWithSingleContext`内部でスクロール開始時、終了時に呼び出されています。

### ここまでのまとめ

スクロール位置の保存と復帰はScrollPositionが行っていて、位置の保存をスクロール終了時で、復帰はWidgetが生成されたときに行っているとわかりました。次はPageStorageを調査します。

## PageStorageが何をやっているのか

PageStorageは内部で持っている`PageStorageBucket`にキーバリュー形式を使って様々なデータを保存しています。

https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/page_storage.dart#L257
https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/page_storage.dart#L78

PageStorageBucketに保存する`writeState`とデータを取得する`readState`はデータのキー設定がオプショナルパラメーターです。

https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/page_storage.dart#L88-L97
https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/page_storage.dart#L107-L115

このキーがPageStorageKeyなのですが、未指定時はPageStorageBucketの`_allKeys`メソッドを使って現在のWidgetに設定されているkeyをチェックします。

PageStorageは`of`メソッドを使って子Widgetからアクセス可能です。

https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/page_storage.dart#L268-L271

そして、PageStorageはWidgetツリーのルートへ自動で位置されるため開発者が配置しなくても問題ありません。

https://github.com/flutter/flutter/blob/63062a64432cce03315d6b5196fda7912866eb37/packages/flutter/lib/src/widgets/page_storage.dart#L128-L129

ScrollPositionは`PageStorage.of`でPageStorageを参照していたため、プログラマーは`PageStorageKey`を設定するだけでスクロール位置の保存と復帰を実現できます。

## まとめと考察

ScrollPositionとPageStorageのコードを調査してスクロール位置の保存や復帰が行われる仕組みを調査しました。その結果、次のことがわかりました。

- Widgetを作ったときにスクロール位置の復帰が試みられる
- スクロールを終了したときにスクロール位置の保存が行われる
- PageStorageはWidgetツリーのルートへ自動で配置され、利用される

非常に便利で強力な仕組みである反面、考えられる注意事項として次のものが挙げられます

- 独自のPageStorageが親Widgetにあったとき、意図しない動作になるかもしれない
- PageStorage.ofが使う`findAncestorWidgetOfExactType`は計算量がO(N)なので、Widgetツリーの肥大化によって処理が重たくなるかもしれない

特に2番目の`findAncestorWidgetOfExactType`はPageStorageKeyを指定して**いなくても**実行されるため、無駄な処理となるかもしれません。スクロール位置を保持しないときは明示的に`ScrollController.keepScrollPosition`を`false`に設定してもいいかもしれませんね。