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

こんにちは、 id:numanuma08 です 🍛

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

api.flutter.dev

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

スクロール位置の保存と復帰

スクロール位置の保存と復帰処理は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がセットされているかは関係なく実行されます。

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

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.keepScrollPositionfalseに設定してもいいかもしれませんね。