| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | /// @docImport 'page_storage.dart'; |
| 6 | /// @docImport 'scroll_controller.dart'; |
| 7 | /// @docImport 'scroll_view.dart'; |
| 8 | /// @docImport 'scrollable.dart'; |
| 9 | /// @docImport 'viewport.dart'; |
| 10 | library; |
| 11 | |
| 12 | import 'dart:math' as math; |
| 13 | |
| 14 | import 'package:flutter/gestures.dart'; |
| 15 | import 'package:flutter/physics.dart'; |
| 16 | import 'package:flutter/rendering.dart'; |
| 17 | |
| 18 | import 'basic.dart'; |
| 19 | import 'framework.dart'; |
| 20 | import 'scroll_activity.dart'; |
| 21 | import 'scroll_context.dart'; |
| 22 | import 'scroll_notification.dart'; |
| 23 | import 'scroll_physics.dart'; |
| 24 | import 'scroll_position.dart'; |
| 25 | |
| 26 | /// A scroll position that manages scroll activities for a single |
| 27 | /// [ScrollContext]. |
| 28 | /// |
| 29 | /// This class is a concrete subclass of [ScrollPosition] logic that handles a |
| 30 | /// single [ScrollContext], such as a [Scrollable]. An instance of this class |
| 31 | /// manages [ScrollActivity] instances, which change what content is visible in |
| 32 | /// the [Scrollable]'s [Viewport]. |
| 33 | /// |
| 34 | /// {@macro flutter.widgets.scrollPosition.listening} |
| 35 | /// |
| 36 | /// See also: |
| 37 | /// |
| 38 | /// * [ScrollPosition], which defines the underlying model for a position |
| 39 | /// within a [Scrollable] but is agnostic as to how that position is |
| 40 | /// changed. |
| 41 | /// * [ScrollView] and its subclasses such as [ListView], which use |
| 42 | /// [ScrollPositionWithSingleContext] to manage their scroll position. |
| 43 | /// * [ScrollController], which can manipulate one or more [ScrollPosition]s, |
| 44 | /// and which uses [ScrollPositionWithSingleContext] as its default class for |
| 45 | /// scroll positions. |
| 46 | class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollActivityDelegate { |
| 47 | /// Create a [ScrollPosition] object that manages its behavior using |
| 48 | /// [ScrollActivity] objects. |
| 49 | /// |
| 50 | /// The `initialPixels` argument can be null, but in that case it is |
| 51 | /// imperative that the value be set, using [correctPixels], as soon as |
| 52 | /// [applyNewDimensions] is invoked, before calling the inherited |
| 53 | /// implementation of that method. |
| 54 | /// |
| 55 | /// If [keepScrollOffset] is true (the default), the current scroll offset is |
| 56 | /// saved with [PageStorage] and restored it if this scroll position's scrollable |
| 57 | /// is recreated. |
| 58 | ScrollPositionWithSingleContext({ |
| 59 | required super.physics, |
| 60 | required super.context, |
| 61 | double? initialPixels = 0.0, |
| 62 | super.keepScrollOffset, |
| 63 | super.oldPosition, |
| 64 | super.debugLabel, |
| 65 | }) { |
| 66 | // If oldPosition is not null, the superclass will first call absorb(), |
| 67 | // which may set _pixels and _activity. |
| 68 | if (!hasPixels && initialPixels != null) { |
| 69 | correctPixels(initialPixels); |
| 70 | } |
| 71 | if (activity == null) { |
| 72 | goIdle(); |
| 73 | } |
| 74 | assert(activity != null); |
| 75 | } |
| 76 | |
| 77 | /// Velocity from a previous activity temporarily held by [hold] to potentially |
| 78 | /// transfer to a next activity. |
| 79 | double _heldPreviousVelocity = 0.0; |
| 80 | |
| 81 | @override |
| 82 | AxisDirection get axisDirection => context.axisDirection; |
| 83 | |
| 84 | @override |
| 85 | double setPixels(double newPixels) { |
| 86 | assert(activity!.isScrolling); |
| 87 | return super.setPixels(newPixels); |
| 88 | } |
| 89 | |
| 90 | @override |
| 91 | void absorb(ScrollPosition other) { |
| 92 | super.absorb(other); |
| 93 | if (other is! ScrollPositionWithSingleContext) { |
| 94 | goIdle(); |
| 95 | return; |
| 96 | } |
| 97 | activity!.updateDelegate(this); |
| 98 | _userScrollDirection = other._userScrollDirection; |
| 99 | assert(_currentDrag == null); |
| 100 | if (other._currentDrag != null) { |
| 101 | _currentDrag = other._currentDrag; |
| 102 | _currentDrag!.updateDelegate(this); |
| 103 | other._currentDrag = null; |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | @override |
| 108 | void applyNewDimensions() { |
| 109 | super.applyNewDimensions(); |
| 110 | context.setCanDrag(physics.shouldAcceptUserOffset(this)); |
| 111 | } |
| 112 | |
| 113 | @override |
| 114 | void beginActivity(ScrollActivity? newActivity) { |
| 115 | _heldPreviousVelocity = 0.0; |
| 116 | if (newActivity == null) { |
| 117 | return; |
| 118 | } |
| 119 | assert(newActivity.delegate == this); |
| 120 | super.beginActivity(newActivity); |
| 121 | _currentDrag?.dispose(); |
| 122 | _currentDrag = null; |
| 123 | if (!activity!.isScrolling) { |
| 124 | updateUserScrollDirection(ScrollDirection.idle); |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | @override |
| 129 | void applyUserOffset(double delta) { |
| 130 | updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse); |
| 131 | setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta)); |
| 132 | } |
| 133 | |
| 134 | @override |
| 135 | void goIdle() { |
| 136 | beginActivity(IdleScrollActivity(this)); |
| 137 | } |
| 138 | |
| 139 | /// Start a physics-driven simulation that settles the [pixels] position, |
| 140 | /// starting at a particular velocity. |
| 141 | /// |
| 142 | /// This method defers to [ScrollPhysics.createBallisticSimulation], which |
| 143 | /// typically provides a bounce simulation when the current position is out of |
| 144 | /// bounds and a friction simulation when the position is in bounds but has a |
| 145 | /// non-zero velocity. |
| 146 | /// |
| 147 | /// The velocity should be in logical pixels per second. |
| 148 | @override |
| 149 | void goBallistic(double velocity) { |
| 150 | assert(hasPixels); |
| 151 | final Simulation? simulation = physics.createBallisticSimulation(this, velocity); |
| 152 | if (simulation != null) { |
| 153 | beginActivity(BallisticScrollActivity(this, simulation, context.vsync, shouldIgnorePointer)); |
| 154 | } else { |
| 155 | goIdle(); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | @override |
| 160 | ScrollDirection get userScrollDirection => _userScrollDirection; |
| 161 | ScrollDirection _userScrollDirection = ScrollDirection.idle; |
| 162 | |
| 163 | /// Set [userScrollDirection] to the given value. |
| 164 | /// |
| 165 | /// If this changes the value, then a [UserScrollNotification] is dispatched. |
| 166 | @protected |
| 167 | @visibleForTesting |
| 168 | void updateUserScrollDirection(ScrollDirection value) { |
| 169 | if (userScrollDirection == value) { |
| 170 | return; |
| 171 | } |
| 172 | _userScrollDirection = value; |
| 173 | didUpdateScrollDirection(value); |
| 174 | } |
| 175 | |
| 176 | @override |
| 177 | Future<void> animateTo(double to, {required Duration duration, required Curve curve}) { |
| 178 | if (nearEqual(to, pixels, physics.toleranceFor(this).distance)) { |
| 179 | // Skip the animation, go straight to the position as we are already close. |
| 180 | jumpTo(to); |
| 181 | return Future<void>.value(); |
| 182 | } |
| 183 | |
| 184 | final DrivenScrollActivity activity = DrivenScrollActivity( |
| 185 | this, |
| 186 | from: pixels, |
| 187 | to: to, |
| 188 | duration: duration, |
| 189 | curve: curve, |
| 190 | vsync: context.vsync, |
| 191 | ); |
| 192 | beginActivity(activity); |
| 193 | return activity.done; |
| 194 | } |
| 195 | |
| 196 | @override |
| 197 | void jumpTo(double value) { |
| 198 | goIdle(); |
| 199 | if (pixels != value) { |
| 200 | final double oldPixels = pixels; |
| 201 | forcePixels(value); |
| 202 | didStartScroll(); |
| 203 | didUpdateScrollPositionBy(pixels - oldPixels); |
| 204 | didEndScroll(); |
| 205 | } |
| 206 | goBallistic(0.0); |
| 207 | } |
| 208 | |
| 209 | @override |
| 210 | void pointerScroll(double delta) { |
| 211 | // If an update is made to pointer scrolling here, consider if the same |
| 212 | // (or similar) change should be made in |
| 213 | // _NestedScrollCoordinator.pointerScroll. |
| 214 | if (delta == 0.0) { |
| 215 | goBallistic(0.0); |
| 216 | return; |
| 217 | } |
| 218 | |
| 219 | final double targetPixels = math.min( |
| 220 | math.max(pixels + delta, minScrollExtent), |
| 221 | maxScrollExtent, |
| 222 | ); |
| 223 | if (targetPixels != pixels) { |
| 224 | goIdle(); |
| 225 | updateUserScrollDirection(-delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse); |
| 226 | final double oldPixels = pixels; |
| 227 | // Set the notifier before calling force pixels. |
| 228 | // This is set to false again after going ballistic below. |
| 229 | isScrollingNotifier.value = true; |
| 230 | forcePixels(targetPixels); |
| 231 | didStartScroll(); |
| 232 | didUpdateScrollPositionBy(pixels - oldPixels); |
| 233 | didEndScroll(); |
| 234 | goBallistic(0.0); |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // flutter_ignore: deprecation_syntax, https://github.com/flutter/flutter/issues/44609 |
| 239 | @Deprecated('This will lead to bugs.' ) |
| 240 | @override |
| 241 | void jumpToWithoutSettling(double value) { |
| 242 | goIdle(); |
| 243 | if (pixels != value) { |
| 244 | final double oldPixels = pixels; |
| 245 | forcePixels(value); |
| 246 | didStartScroll(); |
| 247 | didUpdateScrollPositionBy(pixels - oldPixels); |
| 248 | didEndScroll(); |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | @override |
| 253 | ScrollHoldController hold(VoidCallback holdCancelCallback) { |
| 254 | final double previousVelocity = activity!.velocity; |
| 255 | final HoldScrollActivity holdActivity = HoldScrollActivity( |
| 256 | delegate: this, |
| 257 | onHoldCanceled: holdCancelCallback, |
| 258 | ); |
| 259 | beginActivity(holdActivity); |
| 260 | _heldPreviousVelocity = previousVelocity; |
| 261 | return holdActivity; |
| 262 | } |
| 263 | |
| 264 | ScrollDragController? _currentDrag; |
| 265 | |
| 266 | @override |
| 267 | Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { |
| 268 | final ScrollDragController drag = ScrollDragController( |
| 269 | delegate: this, |
| 270 | details: details, |
| 271 | onDragCanceled: dragCancelCallback, |
| 272 | carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity), |
| 273 | motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold, |
| 274 | ); |
| 275 | beginActivity(DragScrollActivity(this, drag)); |
| 276 | assert(_currentDrag == null); |
| 277 | _currentDrag = drag; |
| 278 | return drag; |
| 279 | } |
| 280 | |
| 281 | @override |
| 282 | void dispose() { |
| 283 | _currentDrag?.dispose(); |
| 284 | _currentDrag = null; |
| 285 | super.dispose(); |
| 286 | } |
| 287 | |
| 288 | @override |
| 289 | void debugFillDescription(List<String> description) { |
| 290 | super.debugFillDescription(description); |
| 291 | description.add(' ${context.runtimeType}' ); |
| 292 | description.add(' $physics' ); |
| 293 | description.add(' $activity' ); |
| 294 | description.add(' $userScrollDirection' ); |
| 295 | } |
| 296 | } |
| 297 | |