From fef42a2cc92a9496332b11ea44e90dc428cde8bf Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Thu, 2 Jul 2026 07:36:52 -0700 Subject: [PATCH] Switch to using Flutter recommendDeferredLoading --- .../widgets/timeline/fixed/segment.model.dart | 4 +- .../widgets/timeline/timeline.state.dart | 20 +++--- .../widgets/timeline/timeline.widget.dart | 68 +++++-------------- 3 files changed, 29 insertions(+), 63 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index d99f67f91d..0531282351 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -106,7 +106,7 @@ class _FixedSegmentRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final shouldDeferAssetLoading = ref.watch(timelineStateProvider.select((s) => s.isFastScrolling)); + final recommendDeferredLoading = ref.watch(timelineStateProvider.select((s) => s.recommendDeferredLoading)); final timelineService = ref.read(timelineServiceProvider); final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); @@ -119,7 +119,7 @@ class _FixedSegmentRow extends ConsumerWidget { ); } - if (shouldDeferAssetLoading) { + if (recommendDeferredLoading) { return _buildPlaceholder(context); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index eb5ed85ddb..6d804ade71 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -54,24 +54,24 @@ class TimelineState { /// Indicates whether the timeline is scrolling beyond some configured "high" speed, /// such as when programmatically scrolling to the top or a really fast user fling - final bool isFastScrolling; + final bool recommendDeferredLoading; - const TimelineState({this.isScrolling = false, this.isFastScrolling = false}); + const TimelineState({this.isScrolling = false, this.recommendDeferredLoading = false}); - bool get isInteracting => isScrolling || isFastScrolling; + bool get isInteracting => isScrolling || recommendDeferredLoading; @override bool operator ==(covariant TimelineState other) { - return isScrolling == other.isScrolling && isFastScrolling == other.isFastScrolling; + return isScrolling == other.isScrolling && recommendDeferredLoading == other.recommendDeferredLoading; } @override - int get hashCode => isScrolling.hashCode ^ isFastScrolling.hashCode; + int get hashCode => isScrolling.hashCode ^ recommendDeferredLoading.hashCode; - TimelineState copyWith({bool? isScrubbing, bool? isScrolling, bool? isFastScrolling}) { + TimelineState copyWith({bool? isScrubbing, bool? isScrolling, bool? recommendDeferredLoading}) { return TimelineState( isScrolling: isScrolling ?? this.isScrolling, - isFastScrolling: isFastScrolling ?? this.isFastScrolling, + recommendDeferredLoading: recommendDeferredLoading ?? this.recommendDeferredLoading, ); } } @@ -81,12 +81,12 @@ class TimelineStateNotifier extends Notifier { state = state.copyWith(isScrolling: isScrolling); } - void setFastScrolling(bool isFastScrolling) { - state = state.copyWith(isFastScrolling: isFastScrolling); + void setRecommendDeferredLoading(bool recommendDeferredLoading) { + state = state.copyWith(recommendDeferredLoading: recommendDeferredLoading); } @override - TimelineState build() => const TimelineState(isScrolling: false, isFastScrolling: false); + TimelineState build() => const TimelineState(isScrolling: false, recommendDeferredLoading: false); } // This provider watches the buckets from the timeline service & args and serves the segments. diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 5e068df8b1..c30552557b 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -150,9 +150,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { double _baseScaleFactor = 3.0; int? _restoreAssetIndex; - final Stopwatch _scrollClock = Stopwatch()..start(); - double _lastScrollPixelOffset = 0; - Duration? _lastScrollTimestamp; + static const _fastScrollDebounceDuration = Duration(milliseconds: 100); + Timer? _fastScrollDebounceTimer; @override void initState() { @@ -246,65 +245,32 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void dispose() { + _fastScrollDebounceTimer?.cancel(); _scrollController.dispose(); _eventSubscription?.cancel(); super.dispose(); } - /// Calculate the velocity of a timeline scroll event and track if it exceeds some threshold + /// Track whether the timeline is moving fast enough to defer per-row asset loading bool _onScrollVelocityNotification(ScrollNotification notification) { - // Only consider the primary timeline ScrollView (no nested views) - if (notification.depth != 0) { + // Only consider the primary timeline ScrollView (no nested views) and update events + if (notification.depth != 0 || notification is! ScrollUpdateNotification) { return false; } - if (notification is ScrollStartNotification) { - _lastScrollTimestamp = null; + // Use Flutter's built in fast velocity tracking + if (_scrollController.position.recommendDeferredLoading(context)) { + ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(true); - return false; - } else if (notification is ScrollEndNotification) { - _lastScrollTimestamp = null; - ref.read(timelineStateProvider.notifier).setFastScrolling(false); - - return false; - } else if (notification is! ScrollUpdateNotification) { - // Assert we have a `ScrollUpdateNotification` - return false; + // We cannot rely on scroll end events, as the timeline scrubber jumps from position + // to position, resulting in large spikes in velocity followed by low velocity + // Instead, cancel fast scrolling after a timeout + _fastScrollDebounceTimer?.cancel(); + _fastScrollDebounceTimer = Timer( + _fastScrollDebounceDuration, + () => ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(false), + ); } - - final now = _scrollClock.elapsed; - final lastTimestamp = _lastScrollTimestamp; - final lastPixelOffset = _lastScrollPixelOffset; - - _lastScrollTimestamp = now; - _lastScrollPixelOffset = notification.metrics.pixels; - - if (lastTimestamp == null) { - // First tick of scroll - return false; - } - - final deltaSeconds = (now - lastTimestamp).inMicroseconds / Duration.microsecondsPerSecond; - if (deltaSeconds <= 0) { - return false; - } - - final velocity = (_lastScrollPixelOffset - lastPixelOffset).abs() / deltaSeconds; - - // Use same threshold calculation as Flutter's `ScrollPhysics.recommendDeferredLoading`, adding hysteresis - // Pixels per second must exceed the length of the longest view dimension to start fast scrolling - // and it must drop below 50% of that dimension to stop fast scrolling - final enterThreshold = View.of(context).physicalSize.longestSide; - - final notifier = ref.read(timelineStateProvider.notifier); - final isFastScrolling = ref.read(timelineStateProvider).isFastScrolling; - - if (!isFastScrolling && velocity > enterThreshold) { - notifier.setFastScrolling(true); - } else if (isFastScrolling && velocity < enterThreshold / 2) { - notifier.setFastScrolling(false); - } - return false; }