From 6ea14aa81fbab866373bc856c0a5dba8dd50c988 Mon Sep 17 00:00:00 2001 From: Bjarte Bore Date: Wed, 27 Nov 2024 17:15:57 +0100 Subject: [PATCH 1/6] Fixed issue with closeProgressThreshold for smaller bottom sheets When a ModalBottomSheets that due to its content is smaller than a full screen bottom sheet, the closeProgressThreshold is calculated based on how far the dialog has to move based on the percentage of the viewport/fullscreen dialog. We need to adjust for this based on the height difference of the viewport and the rendered content within the bottom sheet dialog Related issue: ##421 --- modal_bottom_sheet/lib/src/bottom_sheet.dart | 40 ++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/modal_bottom_sheet/lib/src/bottom_sheet.dart b/modal_bottom_sheet/lib/src/bottom_sheet.dart index 4cbc1c3..2b609fd 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet.dart @@ -138,6 +138,7 @@ class ModalBottomSheet extends StatefulWidget { class ModalBottomSheetState extends State with TickerProviderStateMixin { final GlobalKey _childKey = GlobalKey(debugLabel: 'BottomSheet child'); + final GlobalKey _contentKey = GlobalKey(debugLabel: 'BottomSheet content'); ScrollController get _scrollController => widget.scrollController; @@ -149,6 +150,13 @@ class ModalBottomSheetState extends State return renderBox.size.height; } + + double? get _contentHeight { + final childContext = _contentKey.currentContext; + final renderBox = childContext?.findRenderObject() as RenderBox; + return renderBox.size.height; + } + bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse; @@ -160,8 +168,31 @@ class ModalBottomSheetState extends State bool get hasReachedWillPopThreshold => widget.animationController.value < _willPopThreshold; - bool get hasReachedCloseThreshold => - widget.animationController.value < widget.closeProgressThreshold; + bool get hasReachedCloseThreshold { + final childHeight = _childHeight; + final contentHeight = _contentHeight; + + if (contentHeight == null || childHeight == null || childHeight <= contentHeight) { + return widget.animationController.value < widget.closeProgressThreshold; + } + + // When the content view is smaller that the child view + // we need to change the animation value to account for + // the height difference between the viewport and the content + final closeProgressThreshold = widget.closeProgressThreshold; + + // Interpolate the value intop between 1 and the lower bound + final value = (widget.animationController.value - _lowerBound) / (1 - _lowerBound); + + return value < closeProgressThreshold; + } + + double get _lowerBound { + if (_contentHeight == null || _childHeight == null) { + return 1; + } + return (_contentHeight! / _childHeight!).clamp(0.0, 1.0); + } void _close() { isDragging = false; @@ -392,7 +423,10 @@ class ModalBottomSheetState extends State _handleScrollUpdate(notification); return false; }, - child: child!, + child: KeyedSubtree( + key: _contentKey, + child: child!, + ), ), ), ), From 3efabee00131fecc942f50b08ff2b72a13b77e4a Mon Sep 17 00:00:00 2001 From: Bjarte Bore Date: Wed, 27 Nov 2024 17:23:08 +0100 Subject: [PATCH 2/6] cleanup --- modal_bottom_sheet/lib/src/bottom_sheet.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/modal_bottom_sheet/lib/src/bottom_sheet.dart b/modal_bottom_sheet/lib/src/bottom_sheet.dart index 2b609fd..773ea24 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet.dart @@ -181,7 +181,6 @@ class ModalBottomSheetState extends State // the height difference between the viewport and the content final closeProgressThreshold = widget.closeProgressThreshold; - // Interpolate the value intop between 1 and the lower bound final value = (widget.animationController.value - _lowerBound) / (1 - _lowerBound); return value < closeProgressThreshold; From 8aa61121632c75718a12ebc58c0823cd140c282d Mon Sep 17 00:00:00 2001 From: Bjarte Bore Date: Tue, 14 Jan 2025 07:32:17 +0100 Subject: [PATCH 3/6] Changed the way we drag with a scroll view By leveraging raw events from Listener instead of GestureDetector batteling in the GestureArena we are able to be less dependent on the Notification events to controll the dragging of the sheet and we can instead use vertical drag gestures directly to control the sheet. There is also added parameters to control the default scrollphysics of underlaying scrollable views, this lets us disable scrolling in the ScrollPhysics implementation from userspace. --- modal_bottom_sheet/lib/src/bottom_sheet.dart | 173 +++++++++--------- .../lib/src/bottom_sheet_route.dart | 13 ++ 2 files changed, 98 insertions(+), 88 deletions(-) diff --git a/modal_bottom_sheet/lib/src/bottom_sheet.dart b/modal_bottom_sheet/lib/src/bottom_sheet.dart index 773ea24..c330e3d 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:modal_bottom_sheet/src/utils/scroll_to_top_status_bar.dart'; import 'package:modal_bottom_sheet/src/utils/bottom_sheet_suspended_curve.dart'; @@ -48,6 +49,8 @@ class ModalBottomSheet extends StatefulWidget { double? closeProgressThreshold, @Deprecated('Use preventPopThreshold instead') double? willPopThreshold, double? preventPopThreshold, + this.scrollPhysics, + this.scrollPhysicsBuilder, }) : preventPopThreshold = preventPopThreshold ?? willPopThreshold ?? _willPopThreshold, closeProgressThreshold = @@ -98,6 +101,13 @@ class ModalBottomSheet extends StatefulWidget { /// final Widget child; + /// The default scroll physics for underlaying scroll views + /// When set, we are able to change to the NeverScrollableScrollPhysics() while + /// we are dragging the bottom sheet with scrollview + final ScrollPhysics? scrollPhysics; + + final ScrollPhysics Function(bool canScroll, ScrollPhysics parent)? scrollPhysicsBuilder; + /// If true, the bottom sheet can be dragged up and down and dismissed by /// swiping downwards. /// @@ -163,7 +173,12 @@ class ModalBottomSheetState extends State // Detect if user is dragging. // Used on NotificationListener to detect if ScrollNotifications are // before or after the user stop dragging - bool isDragging = false; + bool _isDragging = false; + + // Indicates if the scrollbar is scrollable + // when we start a drag event, we are always scrollable + bool _canScroll = true; + ScrollDirection _scrollDirection = ScrollDirection.idle; bool get hasReachedWillPopThreshold => widget.animationController.value < _willPopThreshold; @@ -194,7 +209,7 @@ class ModalBottomSheetState extends State } void _close() { - isDragging = false; + _isDragging = false; widget.onClosing(); } @@ -226,8 +241,16 @@ class ModalBottomSheetState extends State animationCurve = Curves.linear; assert(widget.enableDrag, 'Dragging is disabled'); - if (_dismissUnderway) return; - isDragging = true; + if (_dismissUnderway) { + return; + } + + // Abort if the scrollbar is scrollable + if (_canScroll && _scrollController.hasClients && _scrollController.position.maxScrollExtent > 0) { + return; + } + + _isDragging = true; final progress = primaryDelta / (_childHeight ?? primaryDelta); @@ -262,8 +285,9 @@ class ModalBottomSheetState extends State curve: _defaultCurve, ); - if (_dismissUnderway || !isDragging) return; - isDragging = false; + if (_dismissUnderway || !_isDragging) return; + _isDragging = false; + _canScroll = true; _bounceDragController.reverse(); Future tryClose() async { @@ -291,89 +315,52 @@ class ModalBottomSheetState extends State } } - // As we cannot access the dragGesture detector of the scroll view - // we can not know the DragDownDetails and therefore the end velocity. - // VelocityTracker it is used to calculate the end velocity of the scroll - // when user is trying to close the modal by dragging - VelocityTracker? _velocityTracker; - DateTime? _startTime; + Curve get _defaultCurve => widget.animationCurve ?? _decelerateEasing; - void _handleScrollUpdate(ScrollNotification notification) { - assert(notification.context != null); - //Check if scrollController is used - if (!_scrollController.hasClients) return; + late final VerticalDragGestureRecognizer _verticalDragRecognizer; - ScrollPosition scrollPosition; + void _handleRawDragUpdate(DragUpdateDetails details) { + _handleDragUpdate(details.delta.dy); + } - if (_scrollController.positions.length > 1) { - scrollPosition = _scrollController.positions.firstWhere( - (p) => p.isScrollingNotifier.value, - orElse: () => _scrollController.positions.first); - } else { - scrollPosition = _scrollController.position; - } + void _handleRawDragEnd(DragEndDetails details) { + _handleDragEnd(details.primaryVelocity ?? 0); + } - if (scrollPosition.axis == Axis.horizontal) return; - - final isScrollReversed = scrollPosition.axisDirection == AxisDirection.down; - final offset = isScrollReversed - ? scrollPosition.pixels - : scrollPosition.maxScrollExtent - scrollPosition.pixels; - - if (offset <= 0) { - // Clamping Scroll Physics end with a ScrollEndNotification with a DragEndDetail class - // while Bouncing Scroll Physics or other physics that Overflow don't return a drag end info - - // We use the velocity from DragEndDetail in case it is available - if (notification is ScrollEndNotification) { - final dragDetails = notification.dragDetails; - if (dragDetails != null) { - _handleDragEnd(dragDetails.primaryVelocity ?? 0); - _velocityTracker = null; - _startTime = null; - return; - } - } + bool _handleScrollNotification(ScrollNotification notification) { - // Otherwise the calculate the velocity with a VelocityTracker - if (_velocityTracker == null) { - final pointerKind = defaultPointerDeviceKind(context); - _velocityTracker = VelocityTracker.withKind(pointerKind); - _startTime = DateTime.now(); - } + if (notification is UserScrollNotification) { + _scrollDirection = notification.direction; + } - DragUpdateDetails? dragDetails; - if (notification is ScrollUpdateNotification) { - dragDetails = notification.dragDetails; - } - if (notification is OverscrollNotification) { - dragDetails = notification.dragDetails; - } - assert(_velocityTracker != null); - assert(_startTime != null); - final startTime = _startTime!; - final velocityTracker = _velocityTracker!; - if (dragDetails != null) { - final duration = startTime.difference(DateTime.now()); - velocityTracker.addPosition(duration, Offset(0, offset)); - _handleDragUpdate(dragDetails.delta.dy); - } else if (isDragging) { - final velocity = velocityTracker.getVelocity().pixelsPerSecond.dy; - _velocityTracker = null; - _startTime = null; - _handleDragEnd(velocity); + if (notification is OverscrollNotification || notification is ScrollUpdateNotification) { + final downwards = _scrollDirection == ScrollDirection.forward; + final atEdge = notification.metrics.atEdge; + + // disable scrolling when we are at the edge + if (downwards && atEdge && _canScroll) { + setState(() { + _canScroll = false; + }); + } else if (!downwards && atEdge && !_canScroll) { + setState(() { + _canScroll = true; + }); } } + return false; } - Curve get _defaultCurve => widget.animationCurve ?? _decelerateEasing; - @override void initState() { animationCurve = _defaultCurve; _bounceDragController = AnimationController(vsync: this, duration: Duration(milliseconds: 300)); + _verticalDragRecognizer = _AllowMultipleVerticalDragGestureRecognizer(); + _verticalDragRecognizer.onUpdate = _handleRawDragUpdate; + _verticalDragRecognizer.onEnd = _handleRawDragEnd; + // Todo: Check if we can remove scroll Controller super.initState(); } @@ -410,18 +397,12 @@ class ModalBottomSheetState extends State animation: bounceAnimation, builder: (context, _) => CustomSingleChildLayout( delegate: _CustomBottomSheetLayout(bounceAnimation.value), - child: GestureDetector( - onVerticalDragUpdate: (details) { - _handleDragUpdate(details.delta.dy); - }, - onVerticalDragEnd: (details) { - _handleDragEnd(details.primaryVelocity ?? 0); + child: Listener( + onPointerDown: (event) { + _verticalDragRecognizer.addPointer(event); }, child: NotificationListener( - onNotification: (ScrollNotification notification) { - _handleScrollUpdate(notification); - return false; - }, + onNotification: _handleScrollNotification, child: KeyedSubtree( key: _contentKey, child: child!, @@ -432,12 +413,19 @@ class ModalBottomSheetState extends State ), ); return ClipRect( - child: CustomSingleChildLayout( - delegate: _ModalBottomSheetLayout( - animationValue, - widget.expanded, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + physics: widget.scrollPhysicsBuilder + ?.call(_canScroll, ScrollConfiguration.of(context).getScrollPhysics(context)) + ?? widget.scrollPhysics, + ), + child: CustomSingleChildLayout( + delegate: _ModalBottomSheetLayout( + animationValue, + widget.expanded, + ), + child: draggableChild, ), - child: draggableChild, ), ); }, @@ -534,3 +522,12 @@ PointerDeviceKind defaultPointerDeviceKind(BuildContext context) { return PointerDeviceKind.unknown; } } + + +class _AllowMultipleVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer{ + + @override + void rejectGesture(int pointer) { + acceptGesture(pointer); + } +} \ No newline at end of file diff --git a/modal_bottom_sheet/lib/src/bottom_sheet_route.dart b/modal_bottom_sheet/lib/src/bottom_sheet_route.dart index 967851c..f957afb 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet_route.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet_route.dart @@ -16,6 +16,8 @@ class _ModalBottomSheet extends StatefulWidget { this.expanded = false, this.enableDrag = true, this.animationCurve, + this.scrollPhysics, + this.scrollPhysicsBuilder, }); final double? closeProgressThreshold; @@ -25,6 +27,8 @@ class _ModalBottomSheet extends StatefulWidget { final bool enableDrag; final AnimationController? secondAnimationController; final Curve? animationCurve; + final ScrollPhysics? scrollPhysics; + final ScrollPhysics Function(bool canScroll, ScrollPhysics parent)? scrollPhysicsBuilder; @override _ModalBottomSheetState createState() => _ModalBottomSheetState(); @@ -123,6 +127,8 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> { bounce: widget.bounce, scrollController: scrollController, animationCurve: widget.animationCurve, + scrollPhysics: widget.scrollPhysics, + scrollPhysicsBuilder: widget.scrollPhysicsBuilder, ), ); }, @@ -148,6 +154,8 @@ class ModalSheetRoute extends PageRoute { this.bounce = false, this.animationCurve, Duration? duration, + this.scrollPhysics, + this.scrollPhysicsBuilder, super.settings, }) : duration = duration ?? _bottomSheetDuration; @@ -166,6 +174,9 @@ class ModalSheetRoute extends PageRoute { final AnimationController? secondAnimationController; final Curve? animationCurve; + final ScrollPhysics? scrollPhysics; + final ScrollPhysics Function(bool canScroll, ScrollPhysics parent)? scrollPhysicsBuilder; + @override Duration get transitionDuration => duration; @@ -215,6 +226,8 @@ class ModalSheetRoute extends PageRoute { bounce: bounce, enableDrag: enableDrag, animationCurve: animationCurve, + scrollPhysics: scrollPhysics, + scrollPhysicsBuilder: scrollPhysicsBuilder, ), ); return bottomSheet; From d9e06c87d3b57315a13ca70c46d242a72014fe85 Mon Sep 17 00:00:00 2001 From: Bjarte Bore Date: Thu, 16 Jan 2025 14:27:34 +0100 Subject: [PATCH 4/6] Improved check to see if we are scrolling --- modal_bottom_sheet/lib/src/bottom_sheet.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/modal_bottom_sheet/lib/src/bottom_sheet.dart b/modal_bottom_sheet/lib/src/bottom_sheet.dart index c330e3d..697eed0 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet.dart @@ -178,6 +178,7 @@ class ModalBottomSheetState extends State // Indicates if the scrollbar is scrollable // when we start a drag event, we are always scrollable bool _canScroll = true; + bool _isScrolling = false; ScrollDirection _scrollDirection = ScrollDirection.idle; bool get hasReachedWillPopThreshold => @@ -246,7 +247,7 @@ class ModalBottomSheetState extends State } // Abort if the scrollbar is scrollable - if (_canScroll && _scrollController.hasClients && _scrollController.position.maxScrollExtent > 0) { + if (_canScroll && _isScrolling) { return; } @@ -333,6 +334,12 @@ class ModalBottomSheetState extends State _scrollDirection = notification.direction; } + if (notification is ScrollEndNotification) { + _isScrolling = false; + } else if (notification is ScrollStartNotification){ + _isScrolling = true; + } + if (notification is OverscrollNotification || notification is ScrollUpdateNotification) { final downwards = _scrollDirection == ScrollDirection.forward; final atEdge = notification.metrics.atEdge; From ea69746e4dea09ccd4949c0cbbdbd578b084a005 Mon Sep 17 00:00:00 2001 From: Bjarte Bore Date: Mon, 20 Jan 2025 08:16:22 +0100 Subject: [PATCH 5/6] Added means to prevent dragging from userspace --- modal_bottom_sheet/lib/src/bottom_sheet.dart | 33 +++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/modal_bottom_sheet/lib/src/bottom_sheet.dart b/modal_bottom_sheet/lib/src/bottom_sheet.dart index 697eed0..34178a8 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet.dart @@ -143,6 +143,24 @@ class ModalBottomSheet extends StatefulWidget { vsync: vsync, ); } + + static ModalBottomSheetState of(BuildContext context) { + ModalBottomSheetState? sheetState; + if (context is StatefulElement && context.state is ModalBottomSheetState) { + sheetState = context.state as ModalBottomSheetState; + } + sheetState = sheetState ?? context.findAncestorStateOfType(); + + assert(() { + if (sheetState == null) { + throw FlutterError( + 'No ModalBottomSheetState in the widget tree', + ); + } + return true; + }()); + return sheetState!; + } } class ModalBottomSheetState extends State @@ -179,6 +197,15 @@ class ModalBottomSheetState extends State // when we start a drag event, we are always scrollable bool _canScroll = true; bool _isScrolling = false; + bool _allowDrag = true; + + // Lets us set if dragging is allowed from userSpace + // ModalBottomSheet.of(context).setAllowDrag(false) + void setAllowDrag(bool allowDrag) { + _allowDrag = allowDrag; + } + + ScrollDirection _scrollDirection = ScrollDirection.idle; bool get hasReachedWillPopThreshold => @@ -246,6 +273,10 @@ class ModalBottomSheetState extends State return; } + if (!_allowDrag) { + return; + } + // Abort if the scrollbar is scrollable if (_canScroll && _isScrolling) { return; @@ -537,4 +568,4 @@ class _AllowMultipleVerticalDragGestureRecognizer extends VerticalDragGestureRec void rejectGesture(int pointer) { acceptGesture(pointer); } -} \ No newline at end of file +} From b49e51c8603ba10feed829da63638e043db359fc Mon Sep 17 00:00:00 2001 From: Bjarte Bore Date: Tue, 21 Jan 2025 12:29:06 +0100 Subject: [PATCH 6/6] Expose minFlingVelocity to ModalSheetRoute --- modal_bottom_sheet/lib/src/bottom_sheet.dart | 5 +++-- modal_bottom_sheet/lib/src/bottom_sheet_route.dart | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/modal_bottom_sheet/lib/src/bottom_sheet.dart b/modal_bottom_sheet/lib/src/bottom_sheet.dart index 34178a8..98f3a96 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet.dart @@ -45,7 +45,7 @@ class ModalBottomSheet extends StatefulWidget { required this.expanded, required this.onClosing, required this.child, - this.minFlingVelocity = _minFlingVelocity, + double? minFlingVelocity, double? closeProgressThreshold, @Deprecated('Use preventPopThreshold instead') double? willPopThreshold, double? preventPopThreshold, @@ -54,7 +54,8 @@ class ModalBottomSheet extends StatefulWidget { }) : preventPopThreshold = preventPopThreshold ?? willPopThreshold ?? _willPopThreshold, closeProgressThreshold = - closeProgressThreshold ?? _closeProgressThreshold; + closeProgressThreshold ?? _closeProgressThreshold, + minFlingVelocity = minFlingVelocity ?? _minFlingVelocity; /// The closeProgressThreshold parameter /// specifies when the bottom sheet will be dismissed when user drags it. diff --git a/modal_bottom_sheet/lib/src/bottom_sheet_route.dart b/modal_bottom_sheet/lib/src/bottom_sheet_route.dart index f957afb..c7876e5 100644 --- a/modal_bottom_sheet/lib/src/bottom_sheet_route.dart +++ b/modal_bottom_sheet/lib/src/bottom_sheet_route.dart @@ -18,8 +18,10 @@ class _ModalBottomSheet extends StatefulWidget { this.animationCurve, this.scrollPhysics, this.scrollPhysicsBuilder, + this.minFlingVelocity, }); + final double? minFlingVelocity; final double? closeProgressThreshold; final ModalSheetRoute route; final bool expanded; @@ -100,6 +102,7 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> { expanded: widget.route.expanded, containerBuilder: widget.route.containerBuilder, animationController: widget.route._animationController!, + minFlingVelocity: widget.minFlingVelocity, shouldClose: widget.route.popDisposition == RoutePopDisposition.doNotPop || widget.route._hasScopedWillPopCallback @@ -142,6 +145,7 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> { class ModalSheetRoute extends PageRoute { ModalSheetRoute({ this.closeProgressThreshold, + this.minFlingVelocity, this.containerBuilder, required this.builder, this.scrollController, @@ -160,6 +164,7 @@ class ModalSheetRoute extends PageRoute { }) : duration = duration ?? _bottomSheetDuration; final double? closeProgressThreshold; + final double? minFlingVelocity; final WidgetWithChildBuilder? containerBuilder; final WidgetBuilder builder; final bool expanded; @@ -219,6 +224,7 @@ class ModalSheetRoute extends PageRoute { context: context, // removeTop: true, child: _ModalBottomSheet( + minFlingVelocity: minFlingVelocity, closeProgressThreshold: closeProgressThreshold, route: this, secondAnimationController: secondAnimationController, @@ -270,6 +276,7 @@ Future showCustomModalBottomSheet({ Duration? duration, RouteSettings? settings, double? closeProgressThreshold, + double? minFlingVelocity, }) async { assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); @@ -295,6 +302,7 @@ Future showCustomModalBottomSheet({ duration: duration, settings: settings, closeProgressThreshold: closeProgressThreshold, + minFlingVelocity: minFlingVelocity, )); return result; }