fix: persist search page scroll offset between rebuilds (#22733)

fix: persist search scroll between rebuilds

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2025-10-08 20:30:51 +05:30 committed by GitHub
parent 6f3cb4f1bb
commit b3342323de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 37 additions and 16 deletions

View file

@ -3,27 +3,30 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult { class SearchResult {
final List<BaseAsset> assets; final List<BaseAsset> assets;
final double scrollOffset;
final int? nextPage; final int? nextPage;
const SearchResult({required this.assets, this.nextPage}); const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
int get totalAssets => assets.length; SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
return SearchResult(
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage}) { assets: assets ?? this.assets,
return SearchResult(assets: assets ?? this.assets, nextPage: nextPage ?? this.nextPage); nextPage: nextPage ?? this.nextPage,
scrollOffset: scrollOffset ?? this.scrollOffset,
);
} }
@override @override
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)'; String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
@override @override
bool operator ==(covariant SearchResult other) { bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals; final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage; return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
} }
@override @override
int get hashCode => assets.hashCode ^ nextPage.hashCode; int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
} }

View file

@ -203,7 +203,7 @@ class TimelineService {
Future<void> dispose() async { Future<void> dispose() async {
await _bucketSubscription?.cancel(); await _bucketSubscription?.cancel();
_bucketSubscription = null; _bucketSubscription = null;
_buffer.clear(); _buffer = [];
_bufferOffset = 0; _bufferOffset = 0;
} }
} }

View file

@ -599,9 +599,9 @@ class _SearchResultGrid extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final searchResult = ref.watch(paginatedSearchProvider); final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
if (searchResult.totalAssets == 0) { if (assets.isEmpty) {
return const _SearchEmptyContent(); return const _SearchEmptyContent();
} }
@ -615,6 +615,7 @@ class _SearchResultGrid extends ConsumerWidget {
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) { if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd(); onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
} }
return true; return true;
@ -623,17 +624,18 @@ class _SearchResultGrid extends ConsumerWidget {
child: ProviderScope( child: ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(searchResult.assets); final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
], ],
child: Timeline( child: Timeline(
key: ValueKey(searchResult.totalAssets), key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none, groupBy: GroupAssetsBy.none,
appBar: null, appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false, snapToMonth: false,
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
), ),
), ),
), ),

View file

@ -24,12 +24,20 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
return false; return false;
} }
state = SearchResult(assets: [...state.assets, ...result.assets], nextPage: result.nextPage); state = SearchResult(
assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
scrollOffset: state.scrollOffset,
);
return true; return true;
} }
void setScrollOffset(double offset) {
state = state.copyWith(scrollOffset: offset);
}
clear() { clear() {
state = const SearchResult(assets: [], nextPage: 1); state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
} }
} }

View file

@ -40,6 +40,7 @@ class Timeline extends StatelessWidget {
this.groupBy, this.groupBy,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@ -51,6 +52,7 @@ class Timeline extends StatelessWidget {
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -78,6 +80,7 @@ class Timeline extends StatelessWidget {
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber, withScrubber: withScrubber,
snapToMonth: snapToMonth, snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
), ),
), ),
), ),
@ -93,6 +96,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@ -101,6 +105,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@ -124,7 +129,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController(onAttach: _restoreScalePosition); _scrollController = ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreScalePosition,
);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);