fix(mobile): prevent iOS status bar scroll to top during transitions (#30717)

This commit is contained in:
Adam Gastineau 2026-08-12 12:57:45 -07:00 committed by GitHub
parent db9e7c20d7
commit b82d480552
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 40 additions and 5 deletions

View file

@ -277,7 +277,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
), ),
routerConfig: router.config( routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder, deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref)], navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()],
), ),
), ),
); );

View file

@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
@ -190,7 +191,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
// may be in a background tab. In either case, `handleStatusBarTap()` still fires // may be in a background tab. In either case, `handleStatusBarTap()` still fires
// Make sure the timeline is the primary route before scrolling to the top // Make sure the timeline is the primary route before scrolling to the top
final routeData = context.findAncestorWidgetOfExactType<RouteDataScope>()?.routeData; final routeData = context.findAncestorWidgetOfExactType<RouteDataScope>()?.routeData;
if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) { // The tap is generated async, so it can arrive after a route pop has started (due to a back button or similar)
// Check if route is alive and not exiting before taking action
final observers = Navigator.maybeOf(context)?.widget.observers ?? const <NavigatorObserver>[];
final isRouteTransitioning = observers.whereType<TransitioningRouteObserver>().any(
(observer) => observer.hasTransitioningRoute,
);
if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true && !isRouteTransitioning) {
_scrollToTop(); _scrollToTop();
} }
} }

View file

@ -30,3 +30,22 @@ class AppNavigationObserver extends AutoRouterObserver {
ref.invalidate(isAssetViewerOpenProvider); ref.invalidate(isAssetViewerOpenProvider);
} }
} }
/// Tracks routes that are undergoing a pop transition
class TransitioningRouteObserver extends NavigatorObserver {
int _transitioningRoutes = 0;
/// Whether a "popping" route is still on screen
bool get hasTransitioningRoute => _transitioningRoutes > 0;
@override
void didPop(Route route, Route? previousRoute) {
if (route is! TransitionRoute) {
return;
}
_transitioningRoutes += 1;
// Transition completed and route disposed
unawaited(route.completed.whenComplete(() => _transitioningRoutes -= 1));
}
}

View file

@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart';
import '../../../fixtures/asset.stub.dart'; import '../../../fixtures/asset.stub.dart';
@ -62,7 +63,9 @@ void main() {
timelineServiceProvider.overrideWithValue(service), timelineServiceProvider.overrideWithValue(service),
appConfigProvider.overrideWithValue(const AppConfig()), appConfigProvider.overrideWithValue(const AppConfig()),
], ],
child: MaterialApp.router(routerConfig: router.config()), child: MaterialApp.router(
routerConfig: router.config(navigatorObservers: () => [TransitioningRouteObserver()]),
),
), ),
); );
// Segment stream resolves // Segment stream resolves
@ -96,9 +99,14 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route'); expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route');
// Once the timeline is visible, taps on the status bar should scroll to the top // The tap is async, so it can arrive while a pushed route is transitioning to popped
await router.maybePop(); unawaited(router.maybePop());
await tester.pump();
await tapStatusBar();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(position.pixels, initialScrolledPosition, reason: 'ignored while the pushed route pops');
// Once the timeline is visible, taps on the status bar should scroll to the top
await tapStatusBar(); await tapStatusBar();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(position.pixels, 0, reason: 'scrolls the foreground timeline'); expect(position.pixels, 0, reason: 'scrolls the foreground timeline');