├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── doc └── stopper_demo.gif ├── example ├── .gitignore ├── .metadata ├── lib │ └── main.dart └── pubspec.yaml ├── lib └── stopper.dart └── pubspec.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .packages 3 | android/ 4 | ios/ 5 | build/ 6 | pubspec.lock 7 | *.iml 8 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 4feefa3c9a2176ca7383246c4c01b36254fbec85 8 | channel: dev 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.0] - 04/02/2019 2 | 3 | * Initial release. 4 | 5 | ## [1.0.1] - 04/02/2019 6 | 7 | * Fixed a bug caused by not properly disposing AnimationController. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Alexander Ryzhov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stopper 2 | 3 | A bottom sheet that can be expanded to one of the pre-defined stop heights by dragging. 4 | 5 | ![animated image](https://github.com/aryzhov/flutter-stopper/blob/master/doc/stopper_demo.gif?raw=true) 6 | 7 | ## Introduction 8 | 9 | Some iOS applications have a bottom sheet that has two states: half-expanded and fully expanded. 10 | The standard `showBottomSheet` method lacks such a capability. The complexity in implementing this 11 | behavior arises when the the bottom sheet needs to be scrollable, making scroll and drag 12 | event handling difficult as these gestures can be used for scrolling the list as well as 13 | for dragging the bottom sheet up/down, depending on the position of the bottom sheet and the current 14 | scroll position. The *Stopper* plugin addresses this problem by: 15 | 16 | - Letting the developer define discreet height values (stops) to which the bottom sheet can be expanded; 17 | - Using the builder pattern to build the bottom sheet depending on the current stop value; 18 | - Instantiating `ScrollController` and `ScrollPhysics` objects and passing them to the 19 | bottom sheet builder; 20 | - Using animations to make transitions of the bottom sheet between the stops look natural; 21 | - Providing a convenient `showStopper` function to be used instead of `showBottomSheet` in 22 | order to handle dismissal of the bottom sheet by the user. 23 | 24 | This plugin utilizes bottom sheet functionality from the `Scaffold` 25 | widget and avoids copy/paste from the standard library, making the implementation clear and 26 | easy to maintain. 27 | 28 | ## Example 29 | 30 | ```dart 31 | import 'package:stopper/stopper.dart'; 32 | //... 33 | final height = MediaQuery.of(context).size.height; 34 | //... 35 | MaterialButton( 36 | child: Text("Show Stopper"), 37 | onPressed: () { 38 | showStopper( 39 | context: context, 40 | stops: [0.5 * height, height], 41 | builder: (context, scrollController, scrollPhysics, stop) { 42 | return ListView( 43 | controller: scrollController, 44 | physics: scrollPhysics, 45 | children: [ 46 | //... 47 | ] 48 | ); 49 | } 50 | ); 51 | }, 52 | ... 53 | ) 54 | ``` 55 | 56 | *Note:* The build context passed to `showStopper` must have a `Scaffold` widget as an ancestor. 57 | Therefore it's recommended to use `Builder` to build the body of the Scaffold. See 58 | the complete example app provided in this package for details on this approach. 59 | -------------------------------------------------------------------------------- /doc/stopper_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aryzhov/flutter-stopper/9b0ffe69b7403497037c0accbbb0933632dd4899/doc/stopper_demo.gif -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # Visual Studio Code related 19 | .vscode/ 20 | 21 | # Flutter/Dart/Pub related 22 | **/doc/api/ 23 | .dart_tool/ 24 | .flutter-plugins 25 | .packages 26 | .pub-cache/ 27 | .pub/ 28 | /build/ 29 | 30 | # Android related 31 | **/android/**/gradle-wrapper.jar 32 | **/android/.gradle 33 | **/android/captures/ 34 | **/android/gradlew 35 | **/android/gradlew.bat 36 | **/android/local.properties 37 | **/android/**/GeneratedPluginRegistrant.java 38 | 39 | # iOS/XCode related 40 | **/ios/**/*.mode1v3 41 | **/ios/**/*.mode2v3 42 | **/ios/**/*.moved-aside 43 | **/ios/**/*.pbxuser 44 | **/ios/**/*.perspectivev3 45 | **/ios/**/*sync/ 46 | **/ios/**/.sconsign.dblite 47 | **/ios/**/.tags* 48 | **/ios/**/.vagrant/ 49 | **/ios/**/DerivedData/ 50 | **/ios/**/Icon? 51 | **/ios/**/Pods/ 52 | **/ios/**/.symlinks/ 53 | **/ios/**/profile 54 | **/ios/**/xcuserdata 55 | **/ios/.generated/ 56 | **/ios/Flutter/App.framework 57 | **/ios/Flutter/Flutter.framework 58 | **/ios/Flutter/Generated.xcconfig 59 | **/ios/Flutter/app.flx 60 | **/ios/Flutter/app.zip 61 | **/ios/Flutter/flutter_assets/ 62 | **/ios/ServiceDefinitions.json 63 | **/ios/Runner/GeneratedPluginRegistrant.* 64 | 65 | # Exceptions to above rules. 66 | !**/ios/**/default.mode1v3 67 | !**/ios/**/default.mode2v3 68 | !**/ios/**/default.pbxuser 69 | !**/ios/**/default.perspectivev3 70 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 71 | -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 4feefa3c9a2176ca7383246c4c01b36254fbec85 8 | channel: dev 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:stopper/stopper.dart'; 3 | 4 | void main() => runApp(MyApp()); 5 | 6 | class MyApp extends StatelessWidget { 7 | @override 8 | Widget build(BuildContext context) { 9 | return MaterialApp( 10 | home: MyHomePage(), 11 | ); 12 | } 13 | } 14 | 15 | class MyHomePage extends StatefulWidget { 16 | 17 | @override 18 | _MyHomePageState createState() => _MyHomePageState(); 19 | } 20 | 21 | class _MyHomePageState extends State { 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return Scaffold( 26 | appBar: AppBar(title: Text("Hello")), 27 | body: Builder( 28 | builder: (context) { 29 | final h = MediaQuery.of(context).size.height; 30 | return Center( 31 | child: MaterialButton( 32 | color: Colors.green, 33 | child: Text("Show Stopper"), 34 | onPressed: () { 35 | showStopper( 36 | context: context, 37 | stops: [0.4 * h, h], 38 | builder: (context, scrollController, scrollPhysics, stop) { 39 | return ClipRRect( 40 | borderRadius: stop == 0 ? BorderRadius.only( 41 | topLeft: Radius.circular(10), 42 | topRight: Radius.circular(10), 43 | ): BorderRadius.only(), 44 | clipBehavior: Clip.antiAlias, 45 | child: Container( 46 | color: Colors.orange, 47 | child: CustomScrollView( 48 | slivers: [ 49 | SliverAppBar( 50 | title: Text("What's Up?"), 51 | backgroundColor: Colors.orange, 52 | automaticallyImplyLeading: false, 53 | primary: false, 54 | floating: true, 55 | pinned: true, 56 | ), 57 | SliverList( 58 | delegate: SliverChildBuilderDelegate( 59 | (context, idx) => ListTile( 60 | title: Text("Nothing much"), 61 | subtitle: Text("$idx"), 62 | ), 63 | childCount: 100, 64 | ), 65 | ) 66 | ], 67 | controller: scrollController, 68 | physics: scrollPhysics, 69 | ), 70 | ), 71 | ); 72 | }, 73 | ); 74 | }, 75 | ) 76 | ); 77 | }, 78 | ), 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | description: A new Flutter project. 3 | 4 | version: 1.0.0+1 5 | 6 | environment: 7 | sdk: ">=2.1.0 <3.0.0" 8 | 9 | dependencies: 10 | flutter: 11 | sdk: flutter 12 | 13 | cupertino_icons: ^0.1.2 14 | 15 | dev_dependencies: 16 | 17 | stopper: 18 | path: ../ 19 | 20 | 21 | flutter: 22 | 23 | uses-material-design: true 24 | -------------------------------------------------------------------------------- /lib/stopper.dart: -------------------------------------------------------------------------------- 1 | library stopper; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'dart:math'; 5 | 6 | /// A builder function to be passed to [Stopper]. 7 | typedef Widget StopperBuilder( 8 | /// A build context 9 | BuildContext context, 10 | /// A scroll controller to be passed to a scrollable widget 11 | ScrollController controller, 12 | // A scroll physics to be passed to a scrollable widget 13 | ScrollPhysics physics, 14 | /// The current stop value. 15 | int stop 16 | ); 17 | 18 | /// A widget that changes its height to one of the predefined values based on user-initiated dragging. 19 | /// Designed to be used with [showBottomSheet()] method. 20 | class Stopper extends StatefulWidget { 21 | /// The list of stop heights in logical pixels. The values must be sorted from lowest to highest. 22 | final List stops; 23 | /// This callback function is called when the user triggers a close. If null, the bottom sheet cannot be closed by the user. 24 | final Function onClose; 25 | /// A builder to build the contents of the bottom sheet. 26 | final StopperBuilder builder; 27 | /// The initial stop. 28 | final int initialStop; 29 | /// The minimum offset (in logical pixels) necessary to trigger a stop change when dragging. 30 | final double dragThreshold; 31 | 32 | /// The constructor. 33 | Stopper({ 34 | Key key, 35 | @required this.builder, 36 | @required this.stops, 37 | this.initialStop = 0, 38 | this.onClose, 39 | this.dragThreshold = 25 40 | }) 41 | : assert(initialStop < stops.length), 42 | super(key: key); 43 | 44 | @override 45 | StopperState createState() => StopperState(); 46 | } 47 | 48 | /// The state of [Stopper] widget. 49 | class StopperState extends State with SingleTickerProviderStateMixin { 50 | List _stops; 51 | int _currentStop; 52 | int _targetStop; 53 | bool _dragging = false; 54 | bool _closing = false; 55 | double _dragOffset; 56 | double _closingHeight; 57 | ScrollController _scrollController; 58 | ScrollPhysics _scrollPhysics; 59 | Animation _animation; 60 | AnimationController _animationController; 61 | Tween _tween; 62 | 63 | ScrollPhysics _getScrollPhysicsForStop(s) { 64 | if (s == _stops.length - 1) 65 | return BouncingScrollPhysics(); 66 | else 67 | return NeverScrollableScrollPhysics(); 68 | } 69 | 70 | @override 71 | void initState() { 72 | super.initState(); 73 | this._stops = widget.stops; 74 | this._currentStop = widget.initialStop; 75 | this._targetStop = _currentStop; 76 | _scrollController = ScrollController(); 77 | _scrollPhysics = _getScrollPhysicsForStop(_currentStop); 78 | _animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 500)); 79 | final curveAnimation = CurvedAnimation(parent: _animationController, curve: Curves.linear); 80 | _tween = Tween(begin: _stops[_currentStop], end: _stops[_targetStop]); 81 | _animation = _tween.animate(curveAnimation); 82 | _scrollController.addListener(() { 83 | if (_scrollController.offset < -widget.dragThreshold) { 84 | if (this._currentStop != this._targetStop || _dragging) return; 85 | if (this._currentStop > 0) { 86 | final h0 = height; 87 | this._targetStop = this._currentStop - 1; 88 | _animate(h0, _stops[_targetStop]); 89 | } else if (!_closing) { 90 | close(); 91 | } 92 | } 93 | }); 94 | } 95 | 96 | @override 97 | void didUpdateWidget(Stopper oldWidget) { 98 | super.didUpdateWidget(oldWidget); 99 | this._stops = widget.stops; 100 | this._currentStop = min(_currentStop, _stops.length - 1); 101 | this._targetStop = min(_currentStop, _stops.length - 1); 102 | } 103 | 104 | @override 105 | void dispose() { 106 | super.dispose(); 107 | _animationController.dispose(); 108 | _scrollController.dispose(); 109 | } 110 | 111 | /// The current stop value. The value changes after the stop change animation is complete. 112 | get stop => _currentStop; 113 | 114 | set stop(nextStop) { 115 | _targetStop = max(0, min(_stops.length - 1, nextStop)); 116 | _animate(height, nextStop); 117 | } 118 | 119 | /// Returns true if this [Stopper] can be closed by the user. 120 | bool get canClose { 121 | return widget.onClose != null; 122 | } 123 | 124 | /// Closes the bottom sheet. Repeated calls to this method will be ignored. 125 | void close() { 126 | if (!_closing && canClose) { 127 | _closingHeight = height; 128 | _animationController.stop(canceled: true); 129 | _dragging = false; 130 | _closing = true; 131 | widget.onClose(); 132 | } 133 | } 134 | 135 | void _animate(double from, double to, [double velocity]) { 136 | _tween.begin = from; 137 | _tween.end = to; 138 | _animationController.value = 0; 139 | if (_scrollController.offset < 0) _scrollController.animateTo(0, duration: Duration(milliseconds: 200), curve: Curves.linear); 140 | _animationController.fling().then((_) { 141 | this._currentStop = this._targetStop; 142 | setState(() { 143 | _scrollPhysics = _getScrollPhysicsForStop(_currentStop); 144 | }); 145 | }); 146 | } 147 | 148 | /// The current height of the bottom sheet. 149 | get height { 150 | if (_closing) 151 | return _closingHeight; 152 | else if (_dragging) 153 | return _stops[_currentStop] + _dragOffset; 154 | else if (_animationController.isAnimating) 155 | return _animation.value; 156 | else 157 | return _stops[_currentStop]; 158 | } 159 | 160 | @override 161 | Widget build(BuildContext context) { 162 | return AnimatedBuilder( 163 | animation: _animation, 164 | child: GestureDetector( 165 | onVerticalDragStart: (details) { 166 | if (_currentStop != _targetStop) return; 167 | _scrollController.jumpTo(0); 168 | _dragging = true; 169 | _dragOffset = 0; 170 | setState(() {}); 171 | }, 172 | onVerticalDragUpdate: (details) { 173 | if (_dragging) { 174 | _scrollController.jumpTo(0); 175 | _dragOffset -= details.delta.dy; 176 | setState(() {}); 177 | } 178 | }, 179 | onVerticalDragEnd: (details) { 180 | if (!_dragging || _closing) return; 181 | if (_dragOffset > widget.dragThreshold) { 182 | _targetStop = min(_currentStop + 1, _stops.length - 1); 183 | } else if (_dragOffset < -widget.dragThreshold) { 184 | _targetStop = max(canClose ? -1 : 0, _currentStop - 1); 185 | } 186 | if (_targetStop < 0) { 187 | close(); 188 | } else { 189 | _dragging = false; 190 | _animate(_stops[_currentStop] + _dragOffset, _stops[_targetStop]); 191 | } 192 | }, 193 | child: widget.builder(context, _scrollController, _scrollPhysics, _currentStop), 194 | ), 195 | builder: (context, child) { 196 | return SizedBox( 197 | height: min(_stops[_stops.length - 1], max(0, height)), 198 | child: child, 199 | ); 200 | }); 201 | } 202 | } 203 | 204 | /// Shows the Stopper bottom sheet. 205 | /// Returns a [PersistentBottomSheetController] that can be used 206 | PersistentBottomSheetController showStopper( 207 | { 208 | /// The key of the [Stopper] widget 209 | Key key, 210 | /// The build context 211 | @required BuildContext context, 212 | /// The builder of the bottom sheet 213 | @required StopperBuilder builder, 214 | /// The list of stop heights as logical pixel values. Use [MediaQuery] to compute the heights relative to screen height. 215 | /// The order of the stop heights must be from the lowest to the highest. 216 | @required List stops, 217 | /// The initial stop number. 218 | int initialStop = 0, 219 | /// If [true] then the user can close the bottom sheet dragging it down from the lowest stop. 220 | bool userCanClose = true, 221 | /// The minimum offset (in logical pixels) to trigger a stop change when dragging. 222 | double dragThreshold = 25 223 | }) { 224 | PersistentBottomSheetController cont; 225 | cont = showBottomSheet( 226 | context: context, 227 | builder: (context) { 228 | return Stopper( 229 | key: key, 230 | builder: builder, 231 | stops: stops, 232 | initialStop: initialStop, 233 | dragThreshold: dragThreshold, 234 | onClose: userCanClose ? () { 235 | cont.close(); 236 | }: null, 237 | ); 238 | }, 239 | ); 240 | return cont; 241 | } 242 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: stopper 2 | description: A bottom sheet that can be expanded to one of the pre-defined stop heights by dragging. 3 | version: 1.0.1 4 | author: Alexander Ryzhov 5 | homepage: https://github.com/aryzhov/flutter-stopper 6 | 7 | environment: 8 | sdk: ">=2.1.0 <3.0.0" 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | 14 | dev_dependencies: 15 | flutter_test: 16 | sdk: flutter 17 | --------------------------------------------------------------------------------