1169 lines
43 KiB
Dart
1169 lines
43 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
|
|
import 'basic.dart';
|
|
import 'binding.dart';
|
|
import 'framework.dart';
|
|
import 'inherited_notifier.dart';
|
|
import 'layout_builder.dart';
|
|
import 'notification_listener.dart';
|
|
import 'scroll_activity.dart';
|
|
import 'scroll_context.dart';
|
|
import 'scroll_controller.dart';
|
|
import 'scroll_notification.dart';
|
|
import 'scroll_physics.dart';
|
|
import 'scroll_position.dart';
|
|
import 'scroll_position_with_single_context.dart';
|
|
import 'scroll_simulation.dart';
|
|
import 'value_listenable_builder.dart';
|
|
|
|
/// The signature of a method that provides a [BuildContext] and
|
|
/// [ScrollController] for building a widget that may overflow the draggable
|
|
/// [Axis] of the containing [DraggableScrollableSheet].
|
|
///
|
|
/// Users should apply the [scrollController] to a [ScrollView] subclass, such
|
|
/// as a [SingleChildScrollView], [ListView] or [GridView], to have the whole
|
|
/// sheet be draggable.
|
|
typedef ScrollableWidgetBuilder = Widget Function(
|
|
BuildContext context,
|
|
ScrollController scrollController,
|
|
);
|
|
|
|
/// Controls a [DraggableScrollableSheet].
|
|
///
|
|
/// Draggable scrollable controllers are typically stored as member variables in
|
|
/// [State] objects and are reused in each [State.build]. Controllers can only
|
|
/// be used to control one sheet at a time. A controller can be reused with a
|
|
/// new sheet if the previous sheet has been disposed.
|
|
///
|
|
/// The controller's methods cannot be used until after the controller has been
|
|
/// passed into a [DraggableScrollableSheet] and the sheet has run initState.
|
|
///
|
|
/// A [DraggableScrollableController] is a [Listenable]. It notifies its
|
|
/// listeners whenever an attached sheet changes sizes. It does not notify its
|
|
/// listeners when a sheet is first attached or when an attached sheet's
|
|
/// parameters change without affecting the sheet's current size. It does not
|
|
/// fire when [pixels] changes without [size] changing. For example, if the
|
|
/// constraints provided to an attached sheet change.
|
|
class DraggableScrollableController extends ChangeNotifier {
|
|
_DraggableScrollableSheetScrollController? _attachedController;
|
|
final Set<AnimationController> _animationControllers = <AnimationController>{};
|
|
|
|
/// Get the current size (as a fraction of the parent height) of the attached sheet.
|
|
double get size {
|
|
_assertAttached();
|
|
return _attachedController!.extent.currentSize;
|
|
}
|
|
|
|
/// Get the current pixel height of the attached sheet.
|
|
double get pixels {
|
|
_assertAttached();
|
|
return _attachedController!.extent.currentPixels;
|
|
}
|
|
|
|
/// Convert a sheet's size (fractional value of parent container height) to pixels.
|
|
double sizeToPixels(double size) {
|
|
_assertAttached();
|
|
return _attachedController!.extent.sizeToPixels(size);
|
|
}
|
|
|
|
/// Returns Whether any [DraggableScrollableController] objects have attached themselves to the
|
|
/// [DraggableScrollableSheet].
|
|
///
|
|
/// If this is false, then members that interact with the [ScrollPosition],
|
|
/// such as [sizeToPixels], [size], [animateTo], and [jumpTo], must not be
|
|
/// called.
|
|
bool get isAttached => _attachedController != null && _attachedController!.hasClients;
|
|
|
|
/// Convert a sheet's pixel height to size (fractional value of parent container height).
|
|
double pixelsToSize(double pixels) {
|
|
_assertAttached();
|
|
return _attachedController!.extent.pixelsToSize(pixels);
|
|
}
|
|
|
|
/// Animates the attached sheet from its current size to the given [size], a
|
|
/// fractional value of the parent container's height.
|
|
///
|
|
/// Any active sheet animation is canceled. If the sheet's internal scrollable
|
|
/// is currently animating (e.g. responding to a user fling), that animation is
|
|
/// canceled as well.
|
|
///
|
|
/// An animation will be interrupted whenever the user attempts to scroll
|
|
/// manually, whenever another activity is started, or when the sheet hits its
|
|
/// max or min size (e.g. if you animate to 1 but the max size is .8, the
|
|
/// animation will stop playing when it reaches .8).
|
|
///
|
|
/// The duration must not be zero. To jump to a particular value without an
|
|
/// animation, use [jumpTo].
|
|
///
|
|
/// The sheet will not snap after calling [animateTo] even if [DraggableScrollableSheet.snap]
|
|
/// is true. Snapping only occurs after user drags.
|
|
///
|
|
/// When calling [animateTo] in widget tests, `await`ing the returned
|
|
/// [Future] may cause the test to hang and timeout. Instead, use
|
|
/// [WidgetTester.pumpAndSettle].
|
|
Future<void> animateTo(
|
|
double size, {
|
|
required Duration duration,
|
|
required Curve curve,
|
|
}) async {
|
|
_assertAttached();
|
|
assert(size >= 0 && size <= 1);
|
|
assert(duration != Duration.zero);
|
|
final AnimationController animationController = AnimationController.unbounded(
|
|
vsync: _attachedController!.position.context.vsync,
|
|
value: _attachedController!.extent.currentSize,
|
|
);
|
|
_animationControllers.add(animationController);
|
|
_attachedController!.position.goIdle();
|
|
// This disables any snapping until the next user interaction with the sheet.
|
|
_attachedController!.extent.hasDragged = false;
|
|
_attachedController!.extent.hasChanged = true;
|
|
_attachedController!.extent.startActivity(onCanceled: () {
|
|
// Don't stop the controller if it's already finished and may have been disposed.
|
|
if (animationController.isAnimating) {
|
|
animationController.stop();
|
|
}
|
|
});
|
|
animationController.addListener(() {
|
|
_attachedController!.extent.updateSize(
|
|
animationController.value,
|
|
_attachedController!.position.context.notificationContext!,
|
|
);
|
|
if (animationController.value > _attachedController!.extent.maxSize ||
|
|
animationController.value < _attachedController!.extent.minSize) {
|
|
// Animation hit the max or min size, stop animating.
|
|
animationController.stop(canceled: false);
|
|
}
|
|
});
|
|
await animationController.animateTo(size, duration: duration, curve: curve);
|
|
}
|
|
|
|
/// Jumps the attached sheet from its current size to the given [size], a
|
|
/// fractional value of the parent container's height.
|
|
///
|
|
/// If [size] is outside of a the attached sheet's min or max child size,
|
|
/// [jumpTo] will jump the sheet to the nearest valid size instead.
|
|
///
|
|
/// Any active sheet animation is canceled. If the sheet's inner scrollable
|
|
/// is currently animating (e.g. responding to a user fling), that animation is
|
|
/// canceled as well.
|
|
///
|
|
/// The sheet will not snap after calling [jumpTo] even if [DraggableScrollableSheet.snap]
|
|
/// is true. Snapping only occurs after user drags.
|
|
void jumpTo(double size) {
|
|
_assertAttached();
|
|
assert(size >= 0 && size <= 1);
|
|
// Call start activity to interrupt any other playing activities.
|
|
_attachedController!.extent.startActivity(onCanceled: () {});
|
|
_attachedController!.position.goIdle();
|
|
_attachedController!.extent.hasDragged = false;
|
|
_attachedController!.extent.hasChanged = true;
|
|
_attachedController!.extent.updateSize(size, _attachedController!.position.context.notificationContext!);
|
|
}
|
|
|
|
/// Reset the attached sheet to its initial size (see: [DraggableScrollableSheet.initialChildSize]).
|
|
void reset() {
|
|
_assertAttached();
|
|
_attachedController!.reset();
|
|
}
|
|
|
|
void _assertAttached() {
|
|
assert(
|
|
isAttached,
|
|
'DraggableScrollableController is not attached to a sheet. A DraggableScrollableController '
|
|
'must be used in a DraggableScrollableSheet before any of its methods are called.',
|
|
);
|
|
}
|
|
|
|
void _attach(_DraggableScrollableSheetScrollController scrollController) {
|
|
assert(_attachedController == null, 'Draggable scrollable controller is already attached to a sheet.');
|
|
_attachedController = scrollController;
|
|
_attachedController!.extent._currentSize.addListener(notifyListeners);
|
|
_attachedController!.onPositionDetached = _disposeAnimationControllers;
|
|
}
|
|
|
|
void _onExtentReplaced(_DraggableSheetExtent previousExtent) {
|
|
// When the extent has been replaced, the old extent is already disposed and
|
|
// the controller will point to a new extent. We have to add our listener to
|
|
// the new extent.
|
|
_attachedController!.extent._currentSize.addListener(notifyListeners);
|
|
if (previousExtent.currentSize != _attachedController!.extent.currentSize) {
|
|
// The listener won't fire for a change in size between two extent
|
|
// objects so we have to fire it manually here.
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _detach() {
|
|
_attachedController?.extent._currentSize.removeListener(notifyListeners);
|
|
_attachedController = null;
|
|
}
|
|
|
|
void _disposeAnimationControllers() {
|
|
for (final AnimationController animationController in _animationControllers) {
|
|
animationController.dispose();
|
|
}
|
|
_animationControllers.clear();
|
|
}
|
|
}
|
|
|
|
/// A container for a [Scrollable] that responds to drag gestures by resizing
|
|
/// the scrollable until a limit is reached, and then scrolling.
|
|
///
|
|
/// {@youtube 560 315 https://www.youtube.com/watch?v=Hgw819mL_78}
|
|
///
|
|
/// This widget can be dragged along the vertical axis between its
|
|
/// [minChildSize], which defaults to `0.25` and [maxChildSize], which defaults
|
|
/// to `1.0`. These sizes are percentages of the height of the parent container.
|
|
///
|
|
/// The widget coordinates resizing and scrolling of the widget returned by
|
|
/// builder as the user drags along the horizontal axis.
|
|
///
|
|
/// The widget will initially be displayed at its initialChildSize which
|
|
/// defaults to `0.5`, meaning half the height of its parent. Dragging will work
|
|
/// between the range of minChildSize and maxChildSize (as percentages of the
|
|
/// parent container's height) as long as the builder creates a widget which
|
|
/// uses the provided [ScrollController]. If the widget created by the
|
|
/// [ScrollableWidgetBuilder] does not use the provided [ScrollController], the
|
|
/// sheet will remain at the initialChildSize.
|
|
///
|
|
/// By default, the widget will stay at whatever size the user drags it to. To
|
|
/// make the widget snap to specific sizes whenever they lift their finger
|
|
/// during a drag, set [snap] to `true`. The sheet will snap between
|
|
/// [minChildSize] and [maxChildSize]. Use [snapSizes] to add more sizes for
|
|
/// the sheet to snap between.
|
|
///
|
|
/// The snapping effect is only applied on user drags. Programmatically
|
|
/// manipulating the sheet size via [DraggableScrollableController.animateTo] or
|
|
/// [DraggableScrollableController.jumpTo] will ignore [snap] and [snapSizes].
|
|
///
|
|
/// By default, the widget will expand its non-occupied area to fill available
|
|
/// space in the parent. If this is not desired, e.g. because the parent wants
|
|
/// to position sheet based on the space it is taking, the [expand] property
|
|
/// may be set to false.
|
|
///
|
|
/// {@tool snippet}
|
|
///
|
|
/// This is a sample widget which shows a [ListView] that has 25 [ListTile]s.
|
|
/// It starts out as taking up half the body of the [Scaffold], and can be
|
|
/// dragged up to the full height of the scaffold or down to 25% of the height
|
|
/// of the scaffold. Upon reaching full height, the list contents will be
|
|
/// scrolled up or down, until they reach the top of the list again and the user
|
|
/// drags the sheet back down.
|
|
///
|
|
/// ```dart
|
|
/// class HomePage extends StatelessWidget {
|
|
/// const HomePage({super.key});
|
|
///
|
|
/// @override
|
|
/// Widget build(BuildContext context) {
|
|
/// return Scaffold(
|
|
/// appBar: AppBar(
|
|
/// title: const Text('DraggableScrollableSheet'),
|
|
/// ),
|
|
/// body: SizedBox.expand(
|
|
/// child: DraggableScrollableSheet(
|
|
/// builder: (BuildContext context, ScrollController scrollController) {
|
|
/// return Container(
|
|
/// color: Colors.blue[100],
|
|
/// child: ListView.builder(
|
|
/// controller: scrollController,
|
|
/// itemCount: 25,
|
|
/// itemBuilder: (BuildContext context, int index) {
|
|
/// return ListTile(title: Text('Item $index'));
|
|
/// },
|
|
/// ),
|
|
/// );
|
|
/// },
|
|
/// ),
|
|
/// ),
|
|
/// );
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
/// {@end-tool}
|
|
class DraggableScrollableSheet extends StatefulWidget {
|
|
/// Creates a widget that can be dragged and scrolled in a single gesture.
|
|
///
|
|
/// The [builder], [initialChildSize], [minChildSize], [maxChildSize] and
|
|
/// [expand] parameters must not be null.
|
|
const DraggableScrollableSheet({
|
|
super.key,
|
|
this.initialChildSize = 0.5,
|
|
this.minChildSize = 0.25,
|
|
this.maxChildSize = 1.0,
|
|
this.expand = true,
|
|
this.snap = false,
|
|
this.snapSizes,
|
|
this.snapAnimationDuration,
|
|
this.controller,
|
|
required this.builder,
|
|
}) : assert(initialChildSize != null),
|
|
assert(minChildSize != null),
|
|
assert(maxChildSize != null),
|
|
assert(minChildSize >= 0.0),
|
|
assert(maxChildSize <= 1.0),
|
|
assert(minChildSize <= initialChildSize),
|
|
assert(initialChildSize <= maxChildSize),
|
|
assert(snapAnimationDuration == null || snapAnimationDuration > Duration.zero),
|
|
assert(expand != null),
|
|
assert(builder != null);
|
|
|
|
/// The initial fractional value of the parent container's height to use when
|
|
/// displaying the widget.
|
|
///
|
|
/// Rebuilding the sheet with a new [initialChildSize] will only move the
|
|
/// the sheet to the new value if the sheet has not yet been dragged since it
|
|
/// was first built or since the last call to [DraggableScrollableActuator.reset].
|
|
///
|
|
/// The default value is `0.5`.
|
|
final double initialChildSize;
|
|
|
|
/// The minimum fractional value of the parent container's height to use when
|
|
/// displaying the widget.
|
|
///
|
|
/// The default value is `0.25`.
|
|
final double minChildSize;
|
|
|
|
/// The maximum fractional value of the parent container's height to use when
|
|
/// displaying the widget.
|
|
///
|
|
/// The default value is `1.0`.
|
|
final double maxChildSize;
|
|
|
|
/// Whether the widget should expand to fill the available space in its parent
|
|
/// or not.
|
|
///
|
|
/// In most cases, this should be true. However, in the case of a parent
|
|
/// widget that will position this one based on its desired size (such as a
|
|
/// [Center]), this should be set to false.
|
|
///
|
|
/// The default value is true.
|
|
final bool expand;
|
|
|
|
/// Whether the widget should snap between [snapSizes] when the user lifts
|
|
/// their finger during a drag.
|
|
///
|
|
/// If the user's finger was still moving when they lifted it, the widget will
|
|
/// snap to the next snap size (see [snapSizes]) in the direction of the drag.
|
|
/// If their finger was still, the widget will snap to the nearest snap size.
|
|
///
|
|
/// Snapping is not applied when the sheet is programmatically moved by
|
|
/// calling [DraggableScrollableController.animateTo] or [DraggableScrollableController.jumpTo].
|
|
///
|
|
/// Rebuilding the sheet with snap newly enabled will immediately trigger a
|
|
/// snap unless the sheet has not yet been dragged away from
|
|
/// [initialChildSize] since first being built or since the last call to
|
|
/// [DraggableScrollableActuator.reset].
|
|
final bool snap;
|
|
|
|
/// A list of target sizes that the widget should snap to.
|
|
///
|
|
/// Snap sizes are fractional values of the parent container's height. They
|
|
/// must be listed in increasing order and be between [minChildSize] and
|
|
/// [maxChildSize].
|
|
///
|
|
/// The [minChildSize] and [maxChildSize] are implicitly included in snap
|
|
/// sizes and do not need to be specified here. For example, `snapSizes = [.5]`
|
|
/// will result in a sheet that snaps between [minChildSize], `.5`, and
|
|
/// [maxChildSize].
|
|
///
|
|
/// Any modifications to the [snapSizes] list will not take effect until the
|
|
/// `build` function containing this widget is run again.
|
|
///
|
|
/// Rebuilding with a modified or new list will trigger a snap unless the
|
|
/// sheet has not yet been dragged away from [initialChildSize] since first
|
|
/// being built or since the last call to [DraggableScrollableActuator.reset].
|
|
final List<double>? snapSizes;
|
|
|
|
/// Defines a duration for the snap animations.
|
|
///
|
|
/// If it's not set, then the animation duration is the distance to the snap
|
|
/// target divided by the velocity of the widget.
|
|
final Duration? snapAnimationDuration;
|
|
|
|
/// A controller that can be used to programmatically control this sheet.
|
|
final DraggableScrollableController? controller;
|
|
|
|
/// The builder that creates a child to display in this widget, which will
|
|
/// use the provided [ScrollController] to enable dragging and scrolling
|
|
/// of the contents.
|
|
final ScrollableWidgetBuilder builder;
|
|
|
|
@override
|
|
State<DraggableScrollableSheet> createState() => _DraggableScrollableSheetState();
|
|
}
|
|
|
|
/// A [Notification] related to the extent, which is the size, and scroll
|
|
/// offset, which is the position of the child list, of the
|
|
/// [DraggableScrollableSheet].
|
|
///
|
|
/// [DraggableScrollableSheet] widgets notify their ancestors when the size of
|
|
/// the sheet changes. When the extent of the sheet changes via a drag,
|
|
/// this notification bubbles up through the tree, which means a given
|
|
/// [NotificationListener] will receive notifications for all descendant
|
|
/// [DraggableScrollableSheet] widgets. To focus on notifications from the
|
|
/// nearest [DraggableScrollableSheet] descendant, check that the [depth]
|
|
/// property of the notification is zero.
|
|
///
|
|
/// When an extent notification is received by a [NotificationListener], the
|
|
/// listener will already have completed build and layout, and it is therefore
|
|
/// too late for that widget to call [State.setState]. Any attempt to adjust the
|
|
/// build or layout based on an extent notification would result in a layout
|
|
/// that lagged one frame behind, which is a poor user experience. Extent
|
|
/// notifications are used primarily to drive animations. The [Scaffold] widget
|
|
/// listens for extent notifications and responds by driving animations for the
|
|
/// [FloatingActionButton] as the bottom sheet scrolls up.
|
|
class DraggableScrollableNotification extends Notification with ViewportNotificationMixin {
|
|
/// Creates a notification that the extent of a [DraggableScrollableSheet] has
|
|
/// changed.
|
|
///
|
|
/// All parameters are required. The [minExtent] must be >= 0. The [maxExtent]
|
|
/// must be <= 1.0. The [extent] must be between [minExtent] and [maxExtent].
|
|
DraggableScrollableNotification({
|
|
required this.extent,
|
|
required this.minExtent,
|
|
required this.maxExtent,
|
|
required this.initialExtent,
|
|
required this.context,
|
|
}) : assert(extent != null),
|
|
assert(initialExtent != null),
|
|
assert(minExtent != null),
|
|
assert(maxExtent != null),
|
|
assert(0.0 <= minExtent),
|
|
assert(maxExtent <= 1.0),
|
|
assert(minExtent <= extent),
|
|
assert(minExtent <= initialExtent),
|
|
assert(extent <= maxExtent),
|
|
assert(initialExtent <= maxExtent),
|
|
assert(context != null);
|
|
|
|
/// The current value of the extent, between [minExtent] and [maxExtent].
|
|
final double extent;
|
|
|
|
/// The minimum value of [extent], which is >= 0.
|
|
final double minExtent;
|
|
|
|
/// The maximum value of [extent].
|
|
final double maxExtent;
|
|
|
|
/// The initially requested value for [extent].
|
|
final double initialExtent;
|
|
|
|
/// The build context of the widget that fired this notification.
|
|
///
|
|
/// This can be used to find the sheet's render objects to determine the size
|
|
/// of the viewport, for instance. A listener can only assume this context
|
|
/// is live when it first gets the notification.
|
|
final BuildContext context;
|
|
|
|
@override
|
|
void debugFillDescription(List<String> description) {
|
|
super.debugFillDescription(description);
|
|
description.add('minExtent: $minExtent, extent: $extent, maxExtent: $maxExtent, initialExtent: $initialExtent');
|
|
}
|
|
}
|
|
|
|
/// Manages state between [_DraggableScrollableSheetState],
|
|
/// [_DraggableScrollableSheetScrollController], and
|
|
/// [_DraggableScrollableSheetScrollPosition].
|
|
///
|
|
/// The State knows the pixels available along the axis the widget wants to
|
|
/// scroll, but expects to get a fraction of those pixels to render the sheet.
|
|
///
|
|
/// The ScrollPosition knows the number of pixels a user wants to move the sheet.
|
|
///
|
|
/// The [currentSize] will never be null.
|
|
/// The [availablePixels] will never be null, but may be `double.infinity`.
|
|
class _DraggableSheetExtent {
|
|
_DraggableSheetExtent({
|
|
required this.minSize,
|
|
required this.maxSize,
|
|
required this.snap,
|
|
required this.snapSizes,
|
|
required this.initialSize,
|
|
this.snapAnimationDuration,
|
|
ValueNotifier<double>? currentSize,
|
|
bool? hasDragged,
|
|
bool? hasChanged,
|
|
}) : assert(minSize != null),
|
|
assert(maxSize != null),
|
|
assert(initialSize != null),
|
|
assert(minSize >= 0),
|
|
assert(maxSize <= 1),
|
|
assert(minSize <= initialSize),
|
|
assert(initialSize <= maxSize),
|
|
_currentSize = currentSize ?? ValueNotifier<double>(initialSize),
|
|
availablePixels = double.infinity,
|
|
hasDragged = hasDragged ?? false,
|
|
hasChanged = hasChanged ?? false;
|
|
|
|
VoidCallback? _cancelActivity;
|
|
|
|
final double minSize;
|
|
final double maxSize;
|
|
final bool snap;
|
|
final List<double> snapSizes;
|
|
final Duration? snapAnimationDuration;
|
|
final double initialSize;
|
|
final ValueNotifier<double> _currentSize;
|
|
double availablePixels;
|
|
|
|
// Used to disable snapping until the user has dragged on the sheet.
|
|
bool hasDragged;
|
|
|
|
// Used to determine if the sheet should move to a new initial size when it
|
|
// changes.
|
|
// We need both `hasChanged` and `hasDragged` to achieve the following
|
|
// behavior:
|
|
// 1. The sheet should only snap following user drags (as opposed to
|
|
// programmatic sheet changes). See docs for `animateTo` and `jumpTo`.
|
|
// 2. The sheet should move to a new initial child size on rebuild iff the
|
|
// sheet has not changed, either by drag or programmatic control. See
|
|
// docs for `initialChildSize`.
|
|
bool hasChanged;
|
|
|
|
bool get isAtMin => minSize >= _currentSize.value;
|
|
bool get isAtMax => maxSize <= _currentSize.value;
|
|
|
|
double get currentSize => _currentSize.value;
|
|
double get currentPixels => sizeToPixels(_currentSize.value);
|
|
|
|
List<double> get pixelSnapSizes => snapSizes.map(sizeToPixels).toList();
|
|
|
|
/// Start an activity that affects the sheet and register a cancel call back
|
|
/// that will be called if another activity starts.
|
|
///
|
|
/// Note that `onCanceled` will get called even if the subsequent activity
|
|
/// started after this one finished so `onCanceled` should be safe to call at
|
|
/// any time.
|
|
void startActivity({required VoidCallback onCanceled}) {
|
|
_cancelActivity?.call();
|
|
_cancelActivity = onCanceled;
|
|
}
|
|
|
|
/// The scroll position gets inputs in terms of pixels, but the size is
|
|
/// expected to be expressed as a number between 0..1.
|
|
///
|
|
/// This should only be called to respond to a user drag. To update the
|
|
/// size in response to a programmatic call, use [updateSize] directly.
|
|
void addPixelDelta(double delta, BuildContext context) {
|
|
// Stop any playing sheet animations.
|
|
_cancelActivity?.call();
|
|
_cancelActivity = null;
|
|
// The user has interacted with the sheet, set `hasDragged` to true so that
|
|
// we'll snap if applicable.
|
|
hasDragged = true;
|
|
hasChanged = true;
|
|
if (availablePixels == 0) {
|
|
return;
|
|
}
|
|
updateSize(currentSize + pixelsToSize(delta), context);
|
|
}
|
|
|
|
/// Set the size to the new value. [newSize] should be a number between
|
|
/// [minSize] and [maxSize].
|
|
///
|
|
/// This can be triggered by a programmatic (e.g. controller triggered) change
|
|
/// or a user drag.
|
|
void updateSize(double newSize, BuildContext context) {
|
|
assert(newSize != null);
|
|
_currentSize.value = clampDouble(newSize, minSize, maxSize);
|
|
DraggableScrollableNotification(
|
|
minExtent: minSize,
|
|
maxExtent: maxSize,
|
|
extent: currentSize,
|
|
initialExtent: initialSize,
|
|
context: context,
|
|
).dispatch(context);
|
|
}
|
|
|
|
double pixelsToSize(double pixels) {
|
|
return pixels / availablePixels * maxSize;
|
|
}
|
|
|
|
double sizeToPixels(double size) {
|
|
return size / maxSize * availablePixels;
|
|
}
|
|
|
|
_DraggableSheetExtent copyWith({
|
|
required double minSize,
|
|
required double maxSize,
|
|
required bool snap,
|
|
required List<double> snapSizes,
|
|
required double initialSize,
|
|
Duration? snapAnimationDuration,
|
|
}) {
|
|
return _DraggableSheetExtent(
|
|
minSize: minSize,
|
|
maxSize: maxSize,
|
|
snap: snap,
|
|
snapSizes: snapSizes,
|
|
snapAnimationDuration: snapAnimationDuration,
|
|
initialSize: initialSize,
|
|
// Set the current size to the possibly updated initial size if the sheet
|
|
// hasn't changed yet.
|
|
currentSize: ValueNotifier<double>(hasChanged
|
|
? clampDouble(_currentSize.value, minSize, maxSize)
|
|
: initialSize),
|
|
hasDragged: hasDragged,
|
|
hasChanged: hasChanged,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
|
late _DraggableScrollableSheetScrollController _scrollController;
|
|
late _DraggableSheetExtent _extent;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_extent = _DraggableSheetExtent(
|
|
minSize: widget.minChildSize,
|
|
maxSize: widget.maxChildSize,
|
|
snap: widget.snap,
|
|
snapSizes: _impliedSnapSizes(),
|
|
snapAnimationDuration: widget.snapAnimationDuration,
|
|
initialSize: widget.initialChildSize,
|
|
);
|
|
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
|
|
widget.controller?._attach(_scrollController);
|
|
}
|
|
|
|
List<double> _impliedSnapSizes() {
|
|
for (int index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) {
|
|
final double snapSize = widget.snapSizes![index];
|
|
assert(snapSize >= widget.minChildSize && snapSize <= widget.maxChildSize,
|
|
'${_snapSizeErrorMessage(index)}\nSnap sizes must be between `minChildSize` and `maxChildSize`. ');
|
|
assert(index == 0 || snapSize > widget.snapSizes![index - 1],
|
|
'${_snapSizeErrorMessage(index)}\nSnap sizes must be in ascending order. ');
|
|
}
|
|
// Ensure the snap sizes start and end with the min and max child sizes.
|
|
if (widget.snapSizes == null || widget.snapSizes!.isEmpty) {
|
|
return <double>[
|
|
widget.minChildSize,
|
|
widget.maxChildSize,
|
|
];
|
|
}
|
|
return <double>[
|
|
if (widget.snapSizes!.first != widget.minChildSize) widget.minChildSize,
|
|
...widget.snapSizes!,
|
|
if (widget.snapSizes!.last != widget.maxChildSize) widget.maxChildSize,
|
|
];
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant DraggableScrollableSheet oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.controller != oldWidget.controller) {
|
|
oldWidget.controller?._detach();
|
|
widget.controller?._attach(_scrollController);
|
|
}
|
|
_replaceExtent(oldWidget);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_InheritedResetNotifier.shouldReset(context)) {
|
|
_scrollController.reset();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ValueListenableBuilder<double>(
|
|
valueListenable: _extent._currentSize,
|
|
builder: (BuildContext context, double currentSize, Widget? child) => LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
_extent.availablePixels = widget.maxChildSize * constraints.biggest.height;
|
|
final Widget sheet = FractionallySizedBox(
|
|
heightFactor: currentSize,
|
|
alignment: Alignment.bottomCenter,
|
|
child: child,
|
|
);
|
|
return widget.expand ? SizedBox.expand(child: sheet) : sheet;
|
|
},
|
|
),
|
|
child: widget.builder(context, _scrollController),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.controller?._detach();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _replaceExtent(covariant DraggableScrollableSheet oldWidget) {
|
|
final _DraggableSheetExtent previousExtent = _extent;
|
|
_extent = _extent.copyWith(
|
|
minSize: widget.minChildSize,
|
|
maxSize: widget.maxChildSize,
|
|
snap: widget.snap,
|
|
snapSizes: _impliedSnapSizes(),
|
|
snapAnimationDuration: widget.snapAnimationDuration,
|
|
initialSize: widget.initialChildSize,
|
|
);
|
|
// Modify the existing scroll controller instead of replacing it so that
|
|
// developers listening to the controller do not have to rebuild their listeners.
|
|
_scrollController.extent = _extent;
|
|
// If an external facing controller was provided, let it know that the
|
|
// extent has been replaced.
|
|
if (widget.controller == oldWidget.controller) {
|
|
widget.controller?._onExtentReplaced(previousExtent);
|
|
}
|
|
if (widget.snap
|
|
&& (widget.snap != oldWidget.snap || widget.snapSizes != oldWidget.snapSizes)
|
|
&& _scrollController.hasClients
|
|
) {
|
|
// Trigger a snap in case snap or snapSizes has changed and there is a
|
|
// scroll position currently attached. We put this in a post frame
|
|
// callback so that `build` can update `_extent.availablePixels` before
|
|
// this runs-we can't use the previous extent's available pixels as it may
|
|
// have changed when the widget was updated.
|
|
WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) {
|
|
for (int index = 0; index < _scrollController.positions.length; index++) {
|
|
final _DraggableScrollableSheetScrollPosition position =
|
|
_scrollController.positions.elementAt(index) as _DraggableScrollableSheetScrollPosition;
|
|
position.goBallistic(0);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
String _snapSizeErrorMessage(int invalidIndex) {
|
|
final List<String> snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map(
|
|
(int index) {
|
|
final String snapSizeString = widget.snapSizes![index].toString();
|
|
if (index == invalidIndex) {
|
|
return '>>> $snapSizeString <<<';
|
|
}
|
|
return snapSizeString;
|
|
},
|
|
).toList();
|
|
return "Invalid snapSize '${widget.snapSizes![invalidIndex]}' at index $invalidIndex of:\n"
|
|
' $snapSizesWithIndicator';
|
|
}
|
|
}
|
|
|
|
/// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created
|
|
/// by a [DraggableScrollableSheet].
|
|
///
|
|
/// If a [DraggableScrollableSheet] contains content that is exceeds the height
|
|
/// of its container, this controller will allow the sheet to both be dragged to
|
|
/// fill the container and then scroll the child content.
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [_DraggableScrollableSheetScrollPosition], which manages the positioning logic for
|
|
/// this controller.
|
|
/// * [PrimaryScrollController], which can be used to establish a
|
|
/// [_DraggableScrollableSheetScrollController] as the primary controller for
|
|
/// descendants.
|
|
class _DraggableScrollableSheetScrollController extends ScrollController {
|
|
_DraggableScrollableSheetScrollController({
|
|
required this.extent,
|
|
}) : assert(extent != null);
|
|
|
|
_DraggableSheetExtent extent;
|
|
VoidCallback? onPositionDetached;
|
|
|
|
@override
|
|
_DraggableScrollableSheetScrollPosition createScrollPosition(
|
|
ScrollPhysics physics,
|
|
ScrollContext context,
|
|
ScrollPosition? oldPosition,
|
|
) {
|
|
return _DraggableScrollableSheetScrollPosition(
|
|
physics: const AlwaysScrollableScrollPhysics().applyTo(physics),
|
|
context: context,
|
|
oldPosition: oldPosition,
|
|
getExtent: () => extent,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void debugFillDescription(List<String> description) {
|
|
super.debugFillDescription(description);
|
|
description.add('extent: $extent');
|
|
}
|
|
|
|
@override
|
|
_DraggableScrollableSheetScrollPosition get position =>
|
|
super.position as _DraggableScrollableSheetScrollPosition;
|
|
|
|
void reset() {
|
|
extent._cancelActivity?.call();
|
|
extent.hasDragged = false;
|
|
extent.hasChanged = false;
|
|
// jumpTo can result in trying to replace semantics during build.
|
|
// Just animate really fast.
|
|
// Avoid doing it at all if the offset is already 0.0.
|
|
if (offset != 0.0) {
|
|
animateTo(
|
|
0.0,
|
|
duration: const Duration(milliseconds: 1),
|
|
curve: Curves.linear,
|
|
);
|
|
}
|
|
extent.updateSize(extent.initialSize, position.context.notificationContext!);
|
|
}
|
|
|
|
@override
|
|
void detach(ScrollPosition position) {
|
|
onPositionDetached?.call();
|
|
super.detach(position);
|
|
}
|
|
}
|
|
|
|
/// A scroll position that manages scroll activities for
|
|
/// [_DraggableScrollableSheetScrollController].
|
|
///
|
|
/// This class is a concrete subclass of [ScrollPosition] logic that handles a
|
|
/// single [ScrollContext], such as a [Scrollable]. An instance of this class
|
|
/// manages [ScrollActivity] instances, which changes the
|
|
/// [_DraggableSheetExtent.currentSize] or visible content offset in the
|
|
/// [Scrollable]'s [Viewport]
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition].
|
|
class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleContext {
|
|
_DraggableScrollableSheetScrollPosition({
|
|
required super.physics,
|
|
required super.context,
|
|
super.oldPosition,
|
|
required this.getExtent,
|
|
});
|
|
|
|
VoidCallback? _dragCancelCallback;
|
|
final _DraggableSheetExtent Function() getExtent;
|
|
final Set<AnimationController> _ballisticControllers = <AnimationController>{};
|
|
bool get listShouldScroll => pixels > 0.0;
|
|
|
|
_DraggableSheetExtent get extent => getExtent();
|
|
|
|
@override
|
|
void absorb(ScrollPosition other) {
|
|
super.absorb(other);
|
|
assert(_dragCancelCallback == null);
|
|
|
|
if (other is! _DraggableScrollableSheetScrollPosition) {
|
|
return;
|
|
}
|
|
|
|
if (other._dragCancelCallback != null) {
|
|
_dragCancelCallback = other._dragCancelCallback;
|
|
other._dragCancelCallback = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void beginActivity(ScrollActivity? newActivity) {
|
|
// Cancel the running ballistic simulations
|
|
for (final AnimationController ballisticController in _ballisticControllers) {
|
|
ballisticController.stop();
|
|
}
|
|
super.beginActivity(newActivity);
|
|
}
|
|
|
|
@override
|
|
void applyUserOffset(double delta) {
|
|
if (!listShouldScroll &&
|
|
(!(extent.isAtMin || extent.isAtMax) ||
|
|
(extent.isAtMin && delta < 0) ||
|
|
(extent.isAtMax && delta > 0))) {
|
|
extent.addPixelDelta(-delta, context.notificationContext!);
|
|
} else {
|
|
super.applyUserOffset(delta);
|
|
}
|
|
}
|
|
|
|
bool get _isAtSnapSize {
|
|
return extent.snapSizes.any(
|
|
(double snapSize) {
|
|
return (extent.currentSize - snapSize).abs() <= extent.pixelsToSize(physics.tolerance.distance);
|
|
},
|
|
);
|
|
}
|
|
bool get _shouldSnap => extent.snap && extent.hasDragged && !_isAtSnapSize;
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final AnimationController ballisticController in _ballisticControllers) {
|
|
ballisticController.dispose();
|
|
}
|
|
_ballisticControllers.clear();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void goBallistic(double velocity) {
|
|
if ((velocity == 0.0 && !_shouldSnap) ||
|
|
(velocity < 0.0 && listShouldScroll) ||
|
|
(velocity > 0.0 && extent.isAtMax)) {
|
|
super.goBallistic(velocity);
|
|
return;
|
|
}
|
|
// Scrollable expects that we will dispose of its current _dragCancelCallback
|
|
_dragCancelCallback?.call();
|
|
_dragCancelCallback = null;
|
|
|
|
late final Simulation simulation;
|
|
if (extent.snap) {
|
|
// Snap is enabled, simulate snapping instead of clamping scroll.
|
|
simulation = _SnappingSimulation(
|
|
position: extent.currentPixels,
|
|
initialVelocity: velocity,
|
|
pixelSnapSize: extent.pixelSnapSizes,
|
|
snapAnimationDuration: extent.snapAnimationDuration,
|
|
tolerance: physics.tolerance,
|
|
);
|
|
} else {
|
|
// The iOS bouncing simulation just isn't right here - once we delegate
|
|
// the ballistic back to the ScrollView, it will use the right simulation.
|
|
simulation = ClampingScrollSimulation(
|
|
// Run the simulation in terms of pixels, not extent.
|
|
position: extent.currentPixels,
|
|
velocity: velocity,
|
|
tolerance: physics.tolerance,
|
|
);
|
|
}
|
|
|
|
final AnimationController ballisticController = AnimationController.unbounded(
|
|
debugLabel: objectRuntimeType(this, '_DraggableScrollableSheetPosition'),
|
|
vsync: context.vsync,
|
|
);
|
|
_ballisticControllers.add(ballisticController);
|
|
|
|
double lastPosition = extent.currentPixels;
|
|
void tick() {
|
|
final double delta = ballisticController.value - lastPosition;
|
|
lastPosition = ballisticController.value;
|
|
extent.addPixelDelta(delta, context.notificationContext!);
|
|
if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) {
|
|
// Make sure we pass along enough velocity to keep scrolling - otherwise
|
|
// we just "bounce" off the top making it look like the list doesn't
|
|
// have more to scroll.
|
|
velocity = ballisticController.velocity + (physics.tolerance.velocity * ballisticController.velocity.sign);
|
|
super.goBallistic(velocity);
|
|
ballisticController.stop();
|
|
} else if (ballisticController.isCompleted) {
|
|
super.goBallistic(0);
|
|
}
|
|
}
|
|
|
|
ballisticController
|
|
..addListener(tick)
|
|
..animateWith(simulation).whenCompleteOrCancel(
|
|
() {
|
|
if (_ballisticControllers.contains(ballisticController)) {
|
|
_ballisticControllers.remove(ballisticController);
|
|
ballisticController.dispose();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
|
|
// Save this so we can call it later if we have to [goBallistic] on our own.
|
|
_dragCancelCallback = dragCancelCallback;
|
|
return super.drag(details, dragCancelCallback);
|
|
}
|
|
}
|
|
|
|
/// A widget that can notify a descendent [DraggableScrollableSheet] that it
|
|
/// should reset its position to the initial state.
|
|
///
|
|
/// The [Scaffold] uses this widget to notify a persistent bottom sheet that
|
|
/// the user has tapped back if the sheet has started to cover more of the body
|
|
/// than when at its initial position. This is important for users of assistive
|
|
/// technology, where dragging may be difficult to communicate.
|
|
///
|
|
/// This is just a wrapper on top of [DraggableScrollableController]. It is
|
|
/// primarily useful for controlling a sheet in a part of the widget tree that
|
|
/// the current code does not control (e.g. library code trying to affect a sheet
|
|
/// in library users' code). Generally, it's easier to control the sheet
|
|
/// directly by creating a controller and passing the controller to the sheet in
|
|
/// its constructor (see [DraggableScrollableSheet.controller]).
|
|
class DraggableScrollableActuator extends StatelessWidget {
|
|
/// Creates a widget that can notify descendent [DraggableScrollableSheet]s
|
|
/// to reset to their initial position.
|
|
///
|
|
/// The [child] parameter is required.
|
|
DraggableScrollableActuator({
|
|
super.key,
|
|
required this.child,
|
|
});
|
|
|
|
/// This child's [DraggableScrollableSheet] descendant will be reset when the
|
|
/// [reset] method is applied to a context that includes it.
|
|
///
|
|
/// Must not be null.
|
|
final Widget child;
|
|
|
|
final _ResetNotifier _notifier = _ResetNotifier();
|
|
|
|
/// Notifies any descendant [DraggableScrollableSheet] that it should reset
|
|
/// to its initial position.
|
|
///
|
|
/// Returns `true` if a [DraggableScrollableActuator] is available and
|
|
/// some [DraggableScrollableSheet] is listening for updates, `false`
|
|
/// otherwise.
|
|
static bool reset(BuildContext context) {
|
|
final _InheritedResetNotifier? notifier = context.dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>();
|
|
if (notifier == null) {
|
|
return false;
|
|
}
|
|
return notifier._sendReset();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _InheritedResetNotifier(notifier: _notifier, child: child);
|
|
}
|
|
}
|
|
|
|
/// A [ChangeNotifier] to use with [InheritedResetNotifier] to notify
|
|
/// descendants that they should reset to initial state.
|
|
class _ResetNotifier extends ChangeNotifier {
|
|
/// Whether someone called [sendReset] or not.
|
|
///
|
|
/// This flag should be reset after checking it.
|
|
bool _wasCalled = false;
|
|
|
|
/// Fires a reset notification to descendants.
|
|
///
|
|
/// Returns false if there are no listeners.
|
|
bool sendReset() {
|
|
if (!hasListeners) {
|
|
return false;
|
|
}
|
|
_wasCalled = true;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> {
|
|
/// Creates an [InheritedNotifier] that the [DraggableScrollableSheet] will
|
|
/// listen to for an indication that it should reset itself back to [DraggableScrollableSheet.initialChildSize].
|
|
///
|
|
/// The [child] and [notifier] properties must not be null.
|
|
const _InheritedResetNotifier({
|
|
required super.child,
|
|
required _ResetNotifier super.notifier,
|
|
});
|
|
|
|
bool _sendReset() => notifier!.sendReset();
|
|
|
|
/// Specifies whether the [DraggableScrollableSheet] should reset to its
|
|
/// initial position.
|
|
///
|
|
/// Returns true if the notifier requested a reset, false otherwise.
|
|
static bool shouldReset(BuildContext context) {
|
|
final InheritedWidget? widget = context.dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>();
|
|
if (widget == null) {
|
|
return false;
|
|
}
|
|
assert(widget is _InheritedResetNotifier);
|
|
final _InheritedResetNotifier inheritedNotifier = widget as _InheritedResetNotifier;
|
|
final bool wasCalled = inheritedNotifier.notifier!._wasCalled;
|
|
inheritedNotifier.notifier!._wasCalled = false;
|
|
return wasCalled;
|
|
}
|
|
}
|
|
|
|
class _SnappingSimulation extends Simulation {
|
|
_SnappingSimulation({
|
|
required this.position,
|
|
required double initialVelocity,
|
|
required List<double> pixelSnapSize,
|
|
Duration? snapAnimationDuration,
|
|
super.tolerance,
|
|
}) {
|
|
_pixelSnapSize = _getSnapSize(initialVelocity, pixelSnapSize);
|
|
|
|
if (snapAnimationDuration != null && snapAnimationDuration.inMilliseconds > 0) {
|
|
velocity = (_pixelSnapSize - position) * 1000 / snapAnimationDuration.inMilliseconds;
|
|
}
|
|
// Check the direction of the target instead of the sign of the velocity because
|
|
// we may snap in the opposite direction of velocity if velocity is very low.
|
|
else if (_pixelSnapSize < position) {
|
|
velocity = math.min(-minimumSpeed, initialVelocity);
|
|
} else {
|
|
velocity = math.max(minimumSpeed, initialVelocity);
|
|
}
|
|
}
|
|
|
|
final double position;
|
|
late final double velocity;
|
|
|
|
// A minimum speed to snap at. Used to ensure that the snapping animation
|
|
// does not play too slowly.
|
|
static const double minimumSpeed = 1600.0;
|
|
|
|
late final double _pixelSnapSize;
|
|
|
|
@override
|
|
double dx(double time) {
|
|
if (isDone(time)) {
|
|
return 0;
|
|
}
|
|
return velocity;
|
|
}
|
|
|
|
@override
|
|
bool isDone(double time) {
|
|
return x(time) == _pixelSnapSize;
|
|
}
|
|
|
|
@override
|
|
double x(double time) {
|
|
final double newPosition = position + velocity * time;
|
|
if ((velocity >= 0 && newPosition > _pixelSnapSize) ||
|
|
(velocity < 0 && newPosition < _pixelSnapSize)) {
|
|
// We're passed the snap size, return it instead.
|
|
return _pixelSnapSize;
|
|
}
|
|
return newPosition;
|
|
}
|
|
|
|
// Find the two closest snap sizes to the position. If the velocity is
|
|
// non-zero, select the size in the velocity's direction. Otherwise,
|
|
// the nearest snap size.
|
|
double _getSnapSize(double initialVelocity, List<double> pixelSnapSizes) {
|
|
final int indexOfNextSize = pixelSnapSizes
|
|
.indexWhere((double size) => size >= position);
|
|
if (indexOfNextSize == 0) {
|
|
return pixelSnapSizes.first;
|
|
}
|
|
final double nextSize = pixelSnapSizes[indexOfNextSize];
|
|
final double previousSize = pixelSnapSizes[indexOfNextSize - 1];
|
|
if (initialVelocity.abs() <= tolerance.velocity) {
|
|
// If velocity is zero, snap to the nearest snap size with the minimum velocity.
|
|
if (position - previousSize < nextSize - position) {
|
|
return previousSize;
|
|
} else {
|
|
return nextSize;
|
|
}
|
|
}
|
|
// Snap forward or backward depending on current velocity.
|
|
if (initialVelocity < 0.0) {
|
|
return pixelSnapSizes[indexOfNextSize - 1];
|
|
}
|
|
return pixelSnapSizes[indexOfNextSize];
|
|
}
|
|
}
|