├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── README.md ├── dynamic_horizontal.dart ├── horizontal_jumbotron.dart ├── horizontal_list.dart ├── tab_with_horizontal.dart └── vertical_list.dart ├── lib └── scroll_snap_list.dart ├── pubspec.lock ├── pubspec.yaml ├── readme_data ├── custom_dynamic_size.gif ├── dynamic_size.gif ├── horizontal_list.gif ├── jumbotron_list.gif └── vertical_list.gif └── test └── scroll_snap_list_test.dart /.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 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | .dart_tool/ 26 | .flutter-plugins 27 | .packages 28 | .pub-cache/ 29 | .pub/ 30 | build/ 31 | 32 | # Android related 33 | **/android/**/gradle-wrapper.jar 34 | **/android/.gradle 35 | **/android/captures/ 36 | **/android/gradlew 37 | **/android/gradlew.bat 38 | **/android/local.properties 39 | **/android/**/GeneratedPluginRegistrant.java 40 | 41 | # iOS/XCode related 42 | **/ios/**/*.mode1v3 43 | **/ios/**/*.mode2v3 44 | **/ios/**/*.moved-aside 45 | **/ios/**/*.pbxuser 46 | **/ios/**/*.perspectivev3 47 | **/ios/**/*sync/ 48 | **/ios/**/.sconsign.dblite 49 | **/ios/**/.tags* 50 | **/ios/**/.vagrant/ 51 | **/ios/**/DerivedData/ 52 | **/ios/**/Icon? 53 | **/ios/**/Pods/ 54 | **/ios/**/.symlinks/ 55 | **/ios/**/profile 56 | **/ios/**/xcuserdata 57 | **/ios/.generated/ 58 | **/ios/Flutter/App.framework 59 | **/ios/Flutter/Flutter.framework 60 | **/ios/Flutter/Generated.xcconfig 61 | **/ios/Flutter/app.flx 62 | **/ios/Flutter/app.zip 63 | **/ios/Flutter/flutter_assets/ 64 | **/ios/Flutter/flutter_export_environment.sh 65 | **/ios/ServiceDefinitions.json 66 | **/ios/Runner/GeneratedPluginRegistrant.* 67 | 68 | # Exceptions to above rules. 69 | !**/ios/**/default.mode1v3 70 | !**/ios/**/default.mode2v3 71 | !**/ios/**/default.pbxuser 72 | !**/ios/**/default.perspectivev3 73 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 74 | 75 | # Other exceptions 76 | /lib/animated_scroll_snap_list.dart -------------------------------------------------------------------------------- /.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: cc949a8e8b9cf394b9290a8e80f87af3e207dce5 8 | channel: stable 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.9.1] 2 | - Minor code cleanup to remove warning 3 | 4 | ## [0.9.0] 5 | - Fix Flutter 3 warning for WidgetsBinding (thanks @ScratchX98) 6 | - Expose listPadding calculation to `calculateListPadding` to allow overriding 7 | 8 | ## [0.8.6] 9 | - Experimental fix to avoid inifinte animation loop caused by multi-level NotificationListener (thanks @rienkkk) 10 | 11 | ## [0.8.5] 12 | - Experimental fix for infinite loop bug on small listview area (thanks @rienkkk) 13 | - Expose listview padding to param (thanks @siloebb) 14 | 15 | ## [0.8.4] 16 | - Experimental fix overflow when changing orientation (thanks @mirrorlink) 17 | 18 | ## [0.8.3] 19 | - Add flag to control to dispatch scroll notifications to further ancestors (thanks @msangals) 20 | 21 | ## [0.8.2] 22 | - Experimental fix for iOS over-scroll bug 23 | 24 | ## [0.8.1] 25 | - Added param to fix unintended scroll notification when child is scrollable with different axis (thanks @j3su5cr1st) 26 | 27 | ## [0.8.0] 28 | - Added shrinkWrap and scrollPhysics param for internal ListView (thanks @Svet-00) 29 | - Added clipBehavior and keyboardDismissBehavior param for internal ListView 30 | 31 | ## [0.7.0] 32 | - Updated for Flutter 2 33 | - Added null-safety 34 | 35 | ## [0.6.0] 36 | - Added way to anchor selected item at the start & end of the list 37 | - Bugfix dynamic item opacity not working if dynamicItemSize is false 38 | 39 | ## [0.5.1] 40 | - Updated readme 41 | 42 | ## [0.5.0] 43 | - Added dynamic item opacity (thanks @granoeste) 44 | 45 | ## [0.4.1] 46 | - Added way to pass key to child ListView of ScrollSnapList 47 | - Added demo on how to use `PageStorageKey` with ScrollSnapList to preserve scroll location 48 | 49 | ## [0.4.0] 50 | - Added dynamic item size feature 51 | 52 | ## [0.3.1] 53 | - Fixed `animateTo` incorrectly removes user-scroll event 54 | 55 | ## [0.3.0] 56 | - Added updateOnScroll and initial value (value before first snap) (thanks @hawkinsjb1) 57 | - Added checking to avoid multiple onItemFocus call for the same index 58 | - Updated method to handle isInit (delayed instead of one trigger) 59 | 60 | ## [0.2.1] 61 | - Updated horizontal_list sample to include simulated data loading 62 | - Added `endOfListTolerance` to determine end-of-list position (which trigger `onReachEnd`) 63 | 64 | ## [0.2.0] 65 | - Fix bug sometimes scrolling stuck at the end of listview 66 | - Breaking: Changed `buildItemList` parameter to `itemBuilder` 67 | 68 | ## [0.1.3] 69 | - Updated readme 70 | 71 | ## [0.1.2] 72 | - Added Horizontal Jumbotron List demo 73 | 74 | ## [0.1.1] 75 | - Minor update description 76 | 77 | ## [0.1.0] - First release 78 | - First release 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | scroll_snap_list 2 | 3 | ------------------------------------------------------------- 4 | 5 | MIT License 6 | 7 | Copyright (c) 2019 Vincent Utomo 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scroll_snap_list 2 | 3 | A wrapper for `ListView.builder` widget that allows "snaping" event to an item at the end of user-scroll. 4 | 5 | This widget allows unrestricted scrolling (unlike other widget that only able to "snap" to left/right neighbor at a time). 6 | - Support horizontal & vertical list 7 | - Use `ListView.Builder`, means no Animation 8 | 9 | You can use this widget to achieve: 10 | - Unrestricted scrollable Jumbotron 11 | - Show daily entry for Health App and shows its detail directly below the list (reduce number of click) 12 | - Scrollable InputSelect, like datetime picker in cupertino 13 | 14 | |![Jumbotron List](https://raw.githubusercontent.com/MSVCode/scroll_snap_list/master/readme_data/jumbotron_list.gif)|![Horizontal List](https://raw.githubusercontent.com/MSVCode/scroll_snap_list/master/readme_data/horizontal_list.gif)|![Vertical List](https://raw.githubusercontent.com/MSVCode/scroll_snap_list/master/readme_data/vertical_list.gif)|![Dynamic Size](https://raw.githubusercontent.com/MSVCode/scroll_snap_list/master/readme_data/dynamic_size.gif)|![Custom Dynamic Size](https://raw.githubusercontent.com/MSVCode/scroll_snap_list/master/readme_data/custom_dynamic_size.gif)| 15 | |-----|-----|-----|-----|-----| 16 | |Horizontal Jumbotron List|Horizontal List with infinite-loading|Vertical list with InkWell ListItems|Horizontal with dynamic item size|Horizontal with custom dynamic item size| 17 | 18 | ## Getting Started 19 | In your flutter project `pubspec.yaml` add the dependency: 20 | ```yaml 21 | dependencies: 22 | ... 23 | scroll_snap_list: ^[version] 24 | ``` 25 | 26 | ## Usage example 27 | This library doesn't use other library, so it should work out of the box. 28 | 29 | Import `scroll_snap_list.dart`. 30 | 31 | ```dart 32 | import 'package:scroll_snap_list/scroll_snap_list.dart'; 33 | ``` 34 | 35 | Add `ScrollSnapList` in your `build` method. Make sure to enter correct `itemSize` (helps with "snapping" process). 36 | ```dart 37 | Expanded( 38 | child: ScrollSnapList( 39 | onItemFocus: _onItemFocus, 40 | itemSize: 35, 41 | itemBuilder: _buildListItem, 42 | itemCount: data.length, 43 | reverse: true, 44 | ), 45 | ), 46 | ``` 47 | 48 | Full example: 49 | ```dart 50 | import 'dart:math'; 51 | 52 | import 'package:flutter/material.dart'; 53 | import 'package:scroll_snap_list/scroll_snap_list.dart'; 54 | 55 | void main() => runApp(HorizontalListDemo()); 56 | 57 | class HorizontalListDemo extends StatefulWidget { 58 | @override 59 | _HorizontalListDemoState createState() => _HorizontalListDemoState(); 60 | } 61 | 62 | class _HorizontalListDemoState extends State { 63 | List data = []; 64 | int _focusedIndex = 0; 65 | 66 | @override 67 | void initState() { 68 | super.initState(); 69 | 70 | for (int i = 0; i < 30; i++) { 71 | data.add(Random().nextInt(100) + 1); 72 | } 73 | } 74 | 75 | void _onItemFocus(int index) { 76 | setState(() { 77 | _focusedIndex = index; 78 | }); 79 | } 80 | 81 | Widget _buildItemDetail() { 82 | if (data.length > _focusedIndex) 83 | return Container( 84 | height: 150, 85 | child: Text("index $_focusedIndex: ${data[_focusedIndex]}"), 86 | ); 87 | 88 | return Container( 89 | height: 150, 90 | child: Text("No Data"), 91 | ); 92 | } 93 | 94 | Widget _buildListItem(BuildContext context, int index) { 95 | //horizontal 96 | return Container( 97 | width: 35, 98 | child: Column( 99 | mainAxisAlignment: MainAxisAlignment.end, 100 | children: [ 101 | Container( 102 | height: data[index].toDouble()*2, 103 | width: 25, 104 | color: Colors.lightBlueAccent, 105 | child: Text("i:$index\n${data[index]}"), 106 | ) 107 | ], 108 | ), 109 | ); 110 | } 111 | 112 | @override 113 | Widget build(BuildContext context) { 114 | return MaterialApp( 115 | title: 'Horizontal List Demo', 116 | home: Scaffold( 117 | appBar: AppBar( 118 | title: Text("Horizontal List"), 119 | ), 120 | body: Container( 121 | child: Column( 122 | children: [ 123 | Expanded( 124 | child: ScrollSnapList( 125 | onItemFocus: _onItemFocus, 126 | itemSize: 35, 127 | itemBuilder: _buildListItem, 128 | itemCount: data.length, 129 | reverse: true, 130 | ), 131 | ), 132 | _buildItemDetail(), 133 | Row( 134 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 135 | children: [ 136 | RaisedButton( 137 | child: Text("Add Item"), 138 | onPressed: () { 139 | setState(() { 140 | data.add(Random().nextInt(100) + 1); 141 | }); 142 | }, 143 | ), 144 | RaisedButton( 145 | child: Text("Remove Item"), 146 | onPressed: () { 147 | int index = data.length > 1 148 | ? Random().nextInt(data.length - 1) 149 | : 0; 150 | setState(() { 151 | data.removeAt(index); 152 | }); 153 | }, 154 | ), 155 | ], 156 | ) 157 | ], 158 | ), 159 | ), 160 | ), 161 | ); 162 | } 163 | } 164 | ``` 165 | 166 | ### Usage with dynamic item size 167 | This feature is added since version `0.4.0`. 168 | 169 | We can customize item size with respect to distance between item and center of `ScrollSnapList`. 170 | 171 | ```dart 172 | @override 173 | Widget build(BuildContext context) { 174 | return MaterialApp( 175 | title: 'Horizontal List Demo', 176 | home: Scaffold( 177 | appBar: AppBar( 178 | title: Text("Horizontal List"), 179 | ), 180 | body: Container( 181 | child: Column( 182 | children: [ 183 | Expanded( 184 | child: ScrollSnapList( 185 | onItemFocus: _onItemFocus, 186 | itemSize: 150, 187 | itemBuilder: _buildListItem, 188 | itemCount: data.length, 189 | dynamicItemSize: true, 190 | // dynamicSizeEquation: customEquation, //optional 191 | ), 192 | ), 193 | _buildItemDetail(), 194 | ], 195 | ), 196 | ), 197 | ), 198 | ); 199 | } 200 | ``` 201 | 202 | ## Important parameters and explanation 203 | |Parameter|Explanation| 204 | |-----|-----| 205 | |key|Key for `ScrollSnapList`, used to call `ScrollSnapListState`| 206 | |listViewKey|Key for `ListView` inside `ScrollSnapListState`, often used to preserve scroll location| 207 | |itemBuilder|Same as ListView's itemBuilder| 208 | |curve|Animation curve when `snapping`| 209 | |duration|Animation duration| 210 | |endOfListTolerance|Pixel tolerance to trigger `onReachEnd`| 211 | |focusOnItemTap|Focus to an item when user tap on it. Inactive if the list-item have its own onTap detector (use state-key to help focusing instead).| 212 | |focusToItem|Method to manually trigger focus to an item. Call with help of `GlobalKey`| 213 | |margin|Container's margin| 214 | |itemCount|Number of item in this list| 215 | |itemSize|Size used is width if `scrollDirection` is `Axis.horizontal`, height if `Axis.vertical`. Composed of the size of each item + its margin/padding.| 216 | |onItemFocus|Callback function when list snaps/focuses to an item 217 | |onReachEnd|Callback function when user reach end of list. E.g. load more data from db| 218 | |reverse|Same as ListView's `reverse` to reverse `scrollDirection`| 219 | |updateOnScroll|Calls onItemFocus (if it exists) when ScrollUpdateNotification fires| 220 | |initialIndex|Optional initial position which will not snap until after the first drag| 221 | |scrollDirection|ListView's `scrollDirection`| 222 | |listController|External `ScrollController`| 223 | |dynamicItemSize|Scale item's size depending on distance to center| 224 | |dynamicSizeEquation|Custom equation to determine dynamic item scaling calculation| 225 | |dynamicItemOpacity|Custom opacity for offset item (single value)| 226 | |selectedItemAnchor|Anchor location for selected item in the list (Start, Middle, End)| 227 | |shrinkWrap|ListView's `shrinkWrap`| 228 | |scrollPhysics|ListView's `scrollPhysics`| 229 | |clipBehavior|ListView's `clipBehavior`| 230 | |keyboardDismissBehavior|ListView's `keyboardDismissBehavior`| 231 | |allowAnotherDirection|Allow List items to be scrolled using other direction| 232 | |dispatchScrollNotifications|Control if ScrollNotifications should be dispatched to further ancestors. Default: false| 233 | 234 | 235 | ## Other Notice 236 | By default, `SnapScrollList` set `focusOnItemTap` as `true`. Means any tap event on items will trigger snap/focus event to that item. 237 | 238 | This behavior use `GestureDetector`, so if the list's items have their own detector (e.g. InkWell, Button), `SnapScrollList` unable to trigger snap event on its own. To handle this, we may use `GlobalKey` to trigger the event manually. 239 | 240 | Add `GlobalKey` in your widget 241 | ```dart 242 | GlobalKey sslKey = GlobalKey(); 243 | ``` 244 | 245 | Add `key` parameter to `SnapScrollList`. 246 | ```dart 247 | ScrollSnapList( 248 | onItemFocus: _onItemFocus, 249 | itemSize: 50, 250 | itemBuilder: _buildListItem, 251 | itemCount: data.length, 252 | key: sslKey, 253 | scrollDirection: Axis.vertical, 254 | ) 255 | ``` 256 | 257 | In your `buildItem` method, call `focusToItem` method. 258 | ```dart 259 | Widget _buildListItem(BuildContext context, int index) { 260 | //horizontal 261 | return Container( 262 | height: 50, 263 | child: Material( 264 | color: _focusedIndex == index ? Colors.lightBlueAccent : Colors.white, 265 | child: InkWell( 266 | child: Text("Index: $index | Value: ${data[index]}"), 267 | onTap: () { 268 | print("Do anything here"); 269 | 270 | //trigger focus manually 271 | sslKey.currentState.focusToItem(index); 272 | }, 273 | ), 274 | ), 275 | ); 276 | } 277 | ``` 278 | 279 | Full example for this part can be found in `example/vertical_list.dart`. 280 | 281 | # About 282 | Created by Vincent (MSVCode) 283 | Email: msvcode@gmail.com 284 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | Usage: Copy-paste the codes into a new Flutter Project (e.g. in `main.dart`), then run them. 3 | 4 | ## Horizontal Jumbotron 5 | Simple horizontal jumbotron from left to right. 6 | 7 | ## Horizontal List 8 | Horizontal right to left list with simulated "loading from DB" data. Often used in Health Apps. 9 | 10 | ## Vertical List 11 | Simple InputSelect UI. Similar to Cupertino's InputSelect. 12 | 13 | ## Dynamic Horizontal 14 | Horizontal list with dynamic (calculated) size. 15 | 16 | ## Tab With Horizontal 17 | Demo on how to use `PageStorageKey` with `ScrollSnapList`. -------------------------------------------------------------------------------- /example/dynamic_horizontal.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:scroll_snap_list/scroll_snap_list.dart'; 5 | 6 | void main() => runApp(DynamicHorizontalDemo()); 7 | 8 | class DynamicHorizontalDemo extends StatefulWidget { 9 | @override 10 | _DynamicHorizontalDemoState createState() => _DynamicHorizontalDemoState(); 11 | } 12 | 13 | class _DynamicHorizontalDemoState extends State { 14 | List data = []; 15 | int _focusedIndex = -1; 16 | 17 | @override 18 | void initState() { 19 | super.initState(); 20 | 21 | for (int i = 0; i < 10; i++) { 22 | data.add(Random().nextInt(100) + 1); 23 | } 24 | } 25 | 26 | void _onItemFocus(int index) { 27 | print(index); 28 | setState(() { 29 | _focusedIndex = index; 30 | }); 31 | } 32 | 33 | 34 | Widget _buildItemDetail() { 35 | if (_focusedIndex<0) return Container( 36 | height: 250, 37 | child: Text("Nothing selected"), 38 | ); 39 | 40 | if (data.length > _focusedIndex) 41 | return Container( 42 | height: 250, 43 | child: Text("index $_focusedIndex: ${data[_focusedIndex]}"), 44 | ); 45 | 46 | return Container( 47 | height: 250, 48 | child: Text("No Data"), 49 | ); 50 | } 51 | 52 | Widget _buildListItem(BuildContext context, int index) { 53 | if (index == data.length) 54 | return Center(child: CircularProgressIndicator(),); 55 | 56 | //horizontal 57 | return Container( 58 | width: 150, 59 | child: Column( 60 | mainAxisAlignment: MainAxisAlignment.center, 61 | children: [ 62 | Container( 63 | height: 200, 64 | width: 150, 65 | color: Colors.lightBlueAccent, 66 | child: Text("i:$index\n${data[index]}"), 67 | ) 68 | ], 69 | ), 70 | ); 71 | } 72 | 73 | ///Override default dynamicItemSize calculation 74 | double customEquation(double distance){ 75 | // return 1-min(distance.abs()/500, 0.2); 76 | return 1-(distance/1000); 77 | } 78 | 79 | @override 80 | Widget build(BuildContext context) { 81 | return MaterialApp( 82 | title: 'Horizontal List Demo', 83 | home: Scaffold( 84 | appBar: AppBar( 85 | title: Text("Horizontal List"), 86 | ), 87 | body: Container( 88 | child: Column( 89 | children: [ 90 | Expanded( 91 | child: ScrollSnapList( 92 | onItemFocus: _onItemFocus, 93 | itemSize: 150, 94 | itemBuilder: _buildListItem, 95 | itemCount: data.length, 96 | dynamicItemSize: true, 97 | // dynamicSizeEquation: customEquation, //optional 98 | ), 99 | ), 100 | _buildItemDetail(), 101 | ], 102 | ), 103 | ), 104 | ), 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /example/horizontal_jumbotron.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:scroll_snap_list/scroll_snap_list.dart'; 5 | 6 | void main() => runApp(HorizontalListJumboDemo()); 7 | 8 | class HorizontalListJumboDemo extends StatefulWidget { 9 | @override 10 | _HorizontalListJumboDemoState createState() => _HorizontalListJumboDemoState(); 11 | } 12 | 13 | class _HorizontalListJumboDemoState extends State { 14 | List data = []; 15 | int _focusedIndex = 0; 16 | GlobalKey sslKey = GlobalKey(); 17 | 18 | @override 19 | void initState() { 20 | super.initState(); 21 | 22 | for (int i = 0; i < 30; i++) { 23 | data.add(Random().nextInt(100) + 1); 24 | } 25 | } 26 | 27 | void _onItemFocus(int index) { 28 | setState(() { 29 | _focusedIndex = index; 30 | }); 31 | } 32 | 33 | Widget _buildItemDetail() { 34 | if (data.length > _focusedIndex) 35 | return Container( 36 | height: 350, 37 | child: Text("index $_focusedIndex: ${data[_focusedIndex]}"), 38 | ); 39 | 40 | return Container( 41 | height: 350, 42 | child: Text("No Data"), 43 | ); 44 | } 45 | 46 | Widget _buildListItem(BuildContext context, int index) { 47 | return Container( 48 | margin: EdgeInsets.symmetric(horizontal: 5), 49 | width: 350, 50 | child: Material( 51 | color: Colors.lightBlueAccent, 52 | child: InkWell( 53 | onTap: (){ 54 | sslKey.currentState!.focusToItem(index); 55 | }, 56 | child: Text("Child index $index"), 57 | ), 58 | ), 59 | ); 60 | } 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | return MaterialApp( 65 | title: 'Jumbo List Demo', 66 | home: Scaffold( 67 | appBar: AppBar( 68 | title: Text("Jumbo List"), 69 | ), 70 | body: Container( 71 | child: Column( 72 | children: [ 73 | Expanded( 74 | child: ScrollSnapList( 75 | margin: EdgeInsets.symmetric(vertical: 10), 76 | onItemFocus: _onItemFocus, 77 | itemSize: 360, 78 | itemBuilder: _buildListItem, 79 | itemCount: data.length, 80 | key: sslKey, 81 | ), 82 | ), 83 | _buildItemDetail(), 84 | ], 85 | ), 86 | ), 87 | ), 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /example/horizontal_list.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:scroll_snap_list/scroll_snap_list.dart'; 5 | 6 | void main() => runApp(HorizontalListDemo()); 7 | 8 | class HorizontalListDemo extends StatefulWidget { 9 | @override 10 | _HorizontalListDemoState createState() => _HorizontalListDemoState(); 11 | } 12 | 13 | class _HorizontalListDemoState extends State { 14 | List data = []; 15 | int _focusedIndex = 0; 16 | bool _isLoading = false; 17 | 18 | @override 19 | void initState() { 20 | super.initState(); 21 | 22 | for (int i = 0; i < 10; i++) { 23 | data.add(Random().nextInt(100) + 1); 24 | } 25 | } 26 | 27 | void _onItemFocus(int index) { 28 | setState(() { 29 | _focusedIndex = index; 30 | }); 31 | } 32 | 33 | void _loadMoreData() { 34 | setState(() { 35 | _isLoading = true; 36 | }); 37 | 38 | Future.delayed(Duration(seconds: 1), () { 39 | setState(() { 40 | for (int i = 0; i < 10; i++) { 41 | data.add(Random().nextInt(100) + 1); 42 | } 43 | _isLoading = false; 44 | }); 45 | }); 46 | } 47 | 48 | Widget _buildItemDetail() { 49 | if (data.length > _focusedIndex) 50 | return Container( 51 | height: 250, 52 | child: Text("index $_focusedIndex: ${data[_focusedIndex]}"), 53 | ); 54 | 55 | return Container( 56 | height: 250, 57 | child: Text("No Data"), 58 | ); 59 | } 60 | 61 | Widget _buildListItem(BuildContext context, int index) { 62 | if (index == data.length) 63 | return Center(child: CircularProgressIndicator(),); 64 | 65 | //horizontal 66 | return Container( 67 | width: 35, 68 | child: Column( 69 | mainAxisAlignment: MainAxisAlignment.end, 70 | children: [ 71 | Container( 72 | height: data[index].toDouble() * 2, 73 | width: 25, 74 | color: Colors.lightBlueAccent, 75 | child: Text("i:$index\n${data[index]}"), 76 | ) 77 | ], 78 | ), 79 | ); 80 | } 81 | 82 | @override 83 | Widget build(BuildContext context) { 84 | return MaterialApp( 85 | title: 'Horizontal List Demo', 86 | home: Scaffold( 87 | appBar: AppBar( 88 | title: Text("Horizontal List"), 89 | ), 90 | body: Container( 91 | child: Column( 92 | children: [ 93 | Expanded( 94 | child: ScrollSnapList( 95 | onItemFocus: _onItemFocus, 96 | onReachEnd: _loadMoreData, 97 | itemSize: 35, 98 | itemBuilder: _buildListItem, 99 | itemCount: _isLoading?data.length+1:data.length, 100 | reverse: true, 101 | endOfListTolerance: 100, 102 | ), 103 | ), 104 | _buildItemDetail(), 105 | Row( 106 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 107 | children: [ 108 | ElevatedButton( 109 | child: Text("Add Item"), 110 | onPressed: () { 111 | setState(() { 112 | data.add(Random().nextInt(100) + 1); 113 | }); 114 | }, 115 | ), 116 | ElevatedButton( 117 | child: Text("Remove Item"), 118 | onPressed: () { 119 | int index = data.length > 1 120 | ? Random().nextInt(data.length - 1) 121 | : 0; 122 | setState(() { 123 | data.removeAt(index); 124 | }); 125 | }, 126 | ), 127 | ], 128 | ) 129 | ], 130 | ), 131 | ), 132 | ), 133 | ); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /example/tab_with_horizontal.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:scroll_snap_list/scroll_snap_list.dart'; 3 | 4 | void main() => runApp(TabWithHorizontalListDemo()); 5 | 6 | class TabWithHorizontalListDemo extends StatelessWidget { 7 | final List _tabScreen = [ 8 | HorizontalListJumbo( 9 | key: PageStorageKey("Tab 1"), 10 | ), 11 | HorizontalListJumbo( 12 | key: PageStorageKey("Tab 2"), 13 | ), 14 | HorizontalListJumbo( 15 | key: PageStorageKey("Tab 3"), 16 | ) 17 | ]; 18 | final List _tabMenu = [ 19 | Tab(child: Text('Tab 1')), 20 | Tab(child: Text('Tab 2')), 21 | Tab(child: Text('Tab 3')) 22 | ]; 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return MaterialApp( 27 | title: 'Jumbo List Demo', 28 | home: DefaultTabController( 29 | length: 3, 30 | child: Scaffold( 31 | appBar: AppBar( 32 | title: Text( 33 | 'Tab test', 34 | style: TextStyle( 35 | fontWeight: FontWeight.bold, 36 | ), 37 | ), 38 | bottom: TabBar(tabs: _tabMenu), 39 | ), 40 | body: TabBarView( 41 | children: _tabScreen, 42 | ), 43 | ), 44 | ), 45 | ); 46 | } 47 | } 48 | 49 | class HorizontalListJumbo extends StatefulWidget { 50 | final Key? key; 51 | 52 | HorizontalListJumbo({this.key}); 53 | @override 54 | _HorizontalListJumboState createState() => _HorizontalListJumboState(); 55 | } 56 | 57 | class _HorizontalListJumboState extends State { 58 | List data = []; 59 | int _focusedIndex = 0; 60 | GlobalKey sslKey = GlobalKey(); 61 | 62 | @override 63 | void initState() { 64 | super.initState(); 65 | 66 | for (int i = 0; i < 30; i++) { 67 | data.add(i + 1); 68 | } 69 | } 70 | 71 | void _onItemFocus(int index) { 72 | setState(() { 73 | _focusedIndex = index; 74 | }); 75 | } 76 | 77 | Widget _buildItemDetail() { 78 | if (data.length > _focusedIndex) 79 | return Container( 80 | height: 350, 81 | child: Text("index $_focusedIndex: ${data[_focusedIndex]}"), 82 | ); 83 | 84 | return Container( 85 | height: 350, 86 | child: Text("No Data"), 87 | ); 88 | } 89 | 90 | Widget _buildListItem(BuildContext context, int index) { 91 | return Container( 92 | margin: EdgeInsets.symmetric(horizontal: 5), 93 | width: 350, 94 | child: Material( 95 | color: Colors.lightBlueAccent, 96 | child: InkWell( 97 | onTap: () { 98 | sslKey.currentState!.focusToItem(index); 99 | }, 100 | child: Text("Child index $index"), 101 | ), 102 | ), 103 | ); 104 | } 105 | 106 | @override 107 | Widget build(BuildContext context) { 108 | return Container( 109 | child: Column( 110 | children: [ 111 | Expanded( 112 | child: ScrollSnapList( 113 | margin: EdgeInsets.symmetric(vertical: 10), 114 | onItemFocus: _onItemFocus, 115 | itemSize: 360, 116 | itemBuilder: _buildListItem, 117 | itemCount: data.length, 118 | key: sslKey, 119 | listViewKey: widget.key, 120 | ), 121 | ), 122 | _buildItemDetail(), 123 | ], 124 | ), 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /example/vertical_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:scroll_snap_list/scroll_snap_list.dart'; 3 | 4 | void main() => runApp(VerticalListDemo()); 5 | 6 | class VerticalListDemo extends StatefulWidget { 7 | @override 8 | _VerticalListDemoState createState() => _VerticalListDemoState(); 9 | } 10 | 11 | class _VerticalListDemoState extends State { 12 | List data = []; 13 | int _focusedIndex = 0; 14 | GlobalKey sslKey = GlobalKey(); 15 | 16 | @override 17 | void initState() { 18 | super.initState(); 19 | 20 | for (int i = 0; i < 30; i++) { 21 | data.add(i); 22 | } 23 | } 24 | 25 | void _onItemFocus(int index) { 26 | setState(() { 27 | _focusedIndex = index; 28 | }); 29 | } 30 | 31 | Widget _buildItemDetail() { 32 | if (data.length > _focusedIndex) 33 | return Container( 34 | height: 150, 35 | child: Text("index $_focusedIndex: ${data[_focusedIndex]}"), 36 | ); 37 | 38 | return Container( 39 | height: 150, 40 | child: Text("No Data"), 41 | ); 42 | } 43 | 44 | Widget _buildListItem(BuildContext context, int index) { 45 | //horizontal 46 | return Container( 47 | height: 50, 48 | child: Material( 49 | color: _focusedIndex == index ? Colors.lightBlueAccent : Colors.white, 50 | child: InkWell( 51 | child: Text("Index: $index | Value: ${data[index]}"), 52 | onTap: () { 53 | print("Do anything here"); 54 | 55 | //trigger focus manually 56 | sslKey.currentState!.focusToItem(index); 57 | }, 58 | ), 59 | ), 60 | ); 61 | } 62 | 63 | @override 64 | Widget build(BuildContext context) { 65 | return MaterialApp( 66 | title: 'Vertical List Demo', 67 | home: Scaffold( 68 | appBar: AppBar( 69 | title: Text("Vertical List"), 70 | ), 71 | body: Container( 72 | child: Column( 73 | children: [ 74 | SizedBox( 75 | height: 100, 76 | ), 77 | Center( 78 | child: Container( 79 | decoration: BoxDecoration(border: Border.all(width: 2)), 80 | width: 250, 81 | height: 300, 82 | child: ScrollSnapList( 83 | onItemFocus: _onItemFocus, 84 | itemSize: 50, 85 | // selectedItemAnchor: SelectedItemAnchor.START, //to change item anchor uncomment this line 86 | // dynamicItemOpacity: 0.3, //to set unselected item opacity uncomment this line 87 | itemBuilder: _buildListItem, 88 | itemCount: data.length, 89 | key: sslKey, 90 | scrollDirection: Axis.vertical, 91 | ), 92 | ), 93 | ), 94 | SizedBox( 95 | height: 10, 96 | ), 97 | _buildItemDetail(), 98 | ], 99 | ), 100 | ), 101 | ), 102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/scroll_snap_list.dart: -------------------------------------------------------------------------------- 1 | library scroll_snap_list; 2 | 3 | import 'dart:math'; 4 | 5 | import 'package:flutter/material.dart'; 6 | 7 | ///Anchor location for selected item in the list 8 | enum SelectedItemAnchor { START, MIDDLE, END } 9 | 10 | ///A ListView widget that able to "snap" or focus to an item whenever user scrolls. 11 | /// 12 | ///Allows unrestricted scroll speed. Snap/focus event done on every `ScrollEndNotification`. 13 | /// 14 | ///Contains `ScrollNotification` widget, so might be incompatible with other scroll notification. 15 | class ScrollSnapList extends StatefulWidget { 16 | ///List background 17 | final Color? background; 18 | 19 | ///Widget builder. 20 | final Widget Function(BuildContext, int) itemBuilder; 21 | 22 | ///Animation curve 23 | final Curve curve; 24 | 25 | ///Animation duration in milliseconds (ms) 26 | final int duration; 27 | 28 | ///Pixel tolerance to trigger onReachEnd. 29 | ///Default is itemSize/2 30 | final double? endOfListTolerance; 31 | 32 | ///Focus to an item when user tap on it. Inactive if the list-item have its own onTap detector (use state-key to help focusing instead). 33 | final bool focusOnItemTap; 34 | 35 | ///Method to manually trigger focus to an item. Call with help of `GlobalKey`. 36 | final void Function(int)? focusToItem; 37 | 38 | ///Container's margin 39 | final EdgeInsetsGeometry? margin; 40 | 41 | ///Number of item in this list 42 | final int itemCount; 43 | 44 | ///Composed of the size of each item + its margin/padding. 45 | ///Size used is width if `scrollDirection` is `Axis.horizontal`, height if `Axis.vertical`. 46 | /// 47 | ///Example: 48 | ///- Horizontal list 49 | ///- Card with `width` 100 50 | ///- Margin is `EdgeInsets.symmetric(horizontal: 5)` 51 | ///- itemSize is `100+5+5 = 110` 52 | final double itemSize; 53 | 54 | ///Global key that's used to call `focusToItem` method to manually trigger focus event. 55 | final Key? key; 56 | 57 | ///Global key that passed to child ListView. Can be used for PageStorageKey 58 | final Key? listViewKey; 59 | 60 | ///Callback function when list snaps/focuses to an item 61 | final void Function(int) onItemFocus; 62 | 63 | ///Callback function when user reach end of list. 64 | /// 65 | ///Can be used to load more data from database. 66 | final Function? onReachEnd; 67 | 68 | ///Container's padding 69 | final EdgeInsetsGeometry? padding; 70 | 71 | ///Reverse scrollDirection 72 | final bool reverse; 73 | 74 | ///Calls onItemFocus (if it exists) when ScrollUpdateNotification fires 75 | final bool? updateOnScroll; 76 | 77 | ///An optional initial position which will not snap until after the first drag 78 | final double? initialIndex; 79 | 80 | ///ListView's scrollDirection 81 | final Axis scrollDirection; 82 | 83 | ///Allows external controller 84 | final ScrollController listController; 85 | 86 | ///Scale item's size depending on distance to center 87 | final bool dynamicItemSize; 88 | 89 | ///Custom equation to determine dynamic item scaling calculation 90 | /// 91 | ///Input parameter is distance between item position and center of ScrollSnapList (Negative for left side, positive for right side) 92 | /// 93 | ///Output value is scale size (must be >=0, 1 is normal-size) 94 | /// 95 | ///Need to set `dynamicItemSize` to `true` 96 | final double Function(double distance)? dynamicSizeEquation; 97 | 98 | ///Custom Opacity of items off center 99 | final double? dynamicItemOpacity; 100 | 101 | ///Anchor location for selected item in the list 102 | final SelectedItemAnchor selectedItemAnchor; 103 | 104 | /// {@macro flutter.widgets.scroll_view.shrinkWrap} 105 | final bool shrinkWrap; 106 | 107 | /// {@macro flutter.widgets.scroll_view.physics} 108 | final ScrollPhysics? scrollPhysics; 109 | 110 | ///{@macro flutter.material.Material.clipBehavior} 111 | final Clip clipBehavior; 112 | 113 | ///{@macro flutter.widgets.scroll_view.keyboardDismissBehavior} 114 | final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; 115 | 116 | ///Allow List items to be scrolled using other direction 117 | ///(e.g scroll items vertically if `ScrollSnapList` axis is `Axis.horizontal`) 118 | final bool allowAnotherDirection; 119 | 120 | ///If set to false(default) scroll notification bubbling will be canceled. Set to true to 121 | ///dispatch notifications to further ancestors. 122 | final bool dispatchScrollNotifications; 123 | 124 | final EdgeInsetsGeometry? listViewPadding; 125 | 126 | ScrollSnapList( 127 | {this.background, 128 | required this.itemBuilder, 129 | ScrollController? listController, 130 | this.curve = Curves.ease, 131 | this.allowAnotherDirection = true, 132 | this.duration = 500, 133 | this.endOfListTolerance, 134 | this.focusOnItemTap = true, 135 | this.focusToItem, 136 | required this.itemCount, 137 | required this.itemSize, 138 | this.key, 139 | this.listViewKey, 140 | this.margin, 141 | required this.onItemFocus, 142 | this.onReachEnd, 143 | this.padding, 144 | this.reverse = false, 145 | this.updateOnScroll, 146 | this.initialIndex, 147 | this.scrollDirection = Axis.horizontal, 148 | this.dynamicItemSize = false, 149 | this.dynamicSizeEquation, 150 | this.dynamicItemOpacity, 151 | this.selectedItemAnchor = SelectedItemAnchor.MIDDLE, 152 | this.shrinkWrap = false, 153 | this.scrollPhysics, 154 | this.clipBehavior = Clip.hardEdge, 155 | this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, 156 | this.dispatchScrollNotifications = false, 157 | this.listViewPadding}) 158 | : listController = listController ?? ScrollController(), 159 | super(key: key); 160 | 161 | @override 162 | ScrollSnapListState createState() => ScrollSnapListState(); 163 | } 164 | 165 | class ScrollSnapListState extends State { 166 | //true if initialIndex exists and first drag hasn't occurred 167 | bool isInit = true; 168 | 169 | //to avoid multiple onItemFocus when using updateOnScroll 170 | int previousIndex = -1; 171 | 172 | //Current scroll-position in pixel 173 | double currentPixel = 0; 174 | 175 | void initState() { 176 | super.initState(); 177 | WidgetsBinding.instance.addPostFrameCallback((_) { 178 | if (widget.initialIndex != null) { 179 | //set list's initial position 180 | focusToInitialPosition(); 181 | } else { 182 | isInit = false; 183 | } 184 | }); 185 | 186 | ///After initial jump, set isInit to false 187 | Future.delayed(Duration(milliseconds: 10), () { 188 | if (this.mounted) { 189 | setState(() { 190 | isInit = false; 191 | }); 192 | } 193 | }); 194 | } 195 | 196 | ///Scroll list to an offset 197 | void _animateScroll(double location) { 198 | Future.delayed(Duration.zero, () { 199 | widget.listController.animateTo( 200 | location, 201 | duration: new Duration(milliseconds: widget.duration), 202 | curve: widget.curve, 203 | ); 204 | }); 205 | } 206 | 207 | ///Calculate scale transformation for dynamic item size 208 | double calculateScale(int index) { 209 | //scroll-pixel position for index to be at the center of ScrollSnapList 210 | double intendedPixel = index * widget.itemSize; 211 | double difference = intendedPixel - currentPixel; 212 | 213 | if (widget.dynamicSizeEquation != null) { 214 | //force to be >= 0 215 | double scale = widget.dynamicSizeEquation!(difference); 216 | return scale < 0 ? 0 : scale; 217 | } 218 | 219 | //default equation 220 | return 1 - min(difference.abs() / 500, 0.4); 221 | } 222 | 223 | ///Calculate opacity transformation for dynamic item opacity 224 | double calculateOpacity(int index) { 225 | //scroll-pixel position for index to be at the center of ScrollSnapList 226 | double intendedPixel = index * widget.itemSize; 227 | double difference = intendedPixel - currentPixel; 228 | 229 | return (difference == 0) ? 1.0 : widget.dynamicItemOpacity ?? 1.0; 230 | } 231 | 232 | Widget _buildListItem(BuildContext context, int index) { 233 | Widget child; 234 | if (widget.dynamicItemSize) { 235 | child = Transform.scale( 236 | scale: calculateScale(index), 237 | child: widget.itemBuilder(context, index), 238 | ); 239 | } else { 240 | child = widget.itemBuilder(context, index); 241 | } 242 | 243 | if (widget.dynamicItemOpacity != null) { 244 | child = Opacity(child: child, opacity: calculateOpacity(index)); 245 | } 246 | 247 | if (widget.focusOnItemTap) 248 | return GestureDetector( 249 | onTap: () => focusToItem(index), 250 | child: child, 251 | ); 252 | 253 | return child; 254 | } 255 | 256 | ///Calculates target pixel for scroll animation 257 | /// 258 | ///Then trigger `onItemFocus` 259 | double _calcCardLocation( 260 | {double? pixel, required double itemSize, int? index}) { 261 | //current pixel: pixel 262 | //listPadding is not considered as moving pixel by scroll (0.0 is after padding) 263 | //substracted by itemSize/2 (to center the item) 264 | //divided by pixels taken by each item 265 | int cardIndex = 266 | index != null ? index : ((pixel! - itemSize / 2) / itemSize).ceil(); 267 | 268 | //Avoid index getting out of bounds 269 | if (cardIndex < 0) { 270 | cardIndex = 0; 271 | } else if (cardIndex > widget.itemCount - 1) { 272 | cardIndex = widget.itemCount - 1; 273 | } 274 | 275 | //trigger onItemFocus 276 | if (cardIndex != previousIndex) { 277 | previousIndex = cardIndex; 278 | widget.onItemFocus(cardIndex); 279 | } 280 | 281 | //target position 282 | return (cardIndex * itemSize); 283 | } 284 | 285 | /// Trigger focus to an item inside the list 286 | /// Will trigger scoll animation to focused item 287 | void focusToItem(int index) { 288 | double targetLoc = 289 | _calcCardLocation(index: index, itemSize: widget.itemSize); 290 | _animateScroll(targetLoc); 291 | } 292 | 293 | ///Determine location if initialIndex is set 294 | void focusToInitialPosition() { 295 | widget.listController.jumpTo((widget.initialIndex! * widget.itemSize)); 296 | } 297 | 298 | ///Trigger callback on reach end-of-list 299 | void _onReachEnd() { 300 | if (widget.onReachEnd != null) widget.onReachEnd!(); 301 | } 302 | 303 | @override 304 | void dispose() { 305 | widget.listController.dispose(); 306 | super.dispose(); 307 | } 308 | 309 | /// Calculate List Padding by checking SelectedItemAnchor 310 | double calculateListPadding(BoxConstraints constraint) { 311 | switch (widget.selectedItemAnchor) { 312 | case SelectedItemAnchor.MIDDLE: 313 | return (widget.scrollDirection == Axis.horizontal 314 | ? constraint.maxWidth 315 | : constraint.maxHeight) / 316 | 2 - 317 | widget.itemSize / 2; 318 | case SelectedItemAnchor.END: 319 | return (widget.scrollDirection == Axis.horizontal 320 | ? constraint.maxWidth 321 | : constraint.maxHeight) - 322 | widget.itemSize; 323 | case SelectedItemAnchor.START: 324 | default: 325 | return 0; 326 | } 327 | } 328 | 329 | @override 330 | Widget build(BuildContext context) { 331 | return Container( 332 | padding: widget.padding, 333 | margin: widget.margin, 334 | child: LayoutBuilder( 335 | builder: (BuildContext ctx, BoxConstraints constraint) { 336 | double _listPadding = calculateListPadding(constraint); 337 | 338 | return GestureDetector( 339 | //by catching onTapDown gesture, it's possible to keep animateTo from removing user's scroll listener 340 | onTapDown: (_) {}, 341 | child: NotificationListener( 342 | onNotification: (ScrollNotification scrollInfo) { 343 | //Check if the received gestures are coming directly from the ScrollSnapList. If not, skip them 344 | //Try to avoid inifinte animation loop caused by multi-level NotificationListener 345 | if (scrollInfo.depth > 0) { 346 | return false; 347 | } 348 | 349 | if (!widget.allowAnotherDirection) { 350 | if (scrollInfo.metrics.axisDirection == AxisDirection.right || 351 | scrollInfo.metrics.axisDirection == AxisDirection.left) { 352 | if (widget.scrollDirection != Axis.horizontal) { 353 | return false; 354 | } 355 | } 356 | 357 | if (scrollInfo.metrics.axisDirection == AxisDirection.up || 358 | scrollInfo.metrics.axisDirection == AxisDirection.down) { 359 | if (widget.scrollDirection != Axis.vertical) { 360 | return false; 361 | } 362 | } 363 | } 364 | 365 | if (scrollInfo is ScrollEndNotification) { 366 | // dont snap until after first drag 367 | if (isInit) { 368 | return true; 369 | } 370 | 371 | double tolerance = 372 | widget.endOfListTolerance ?? (widget.itemSize / 2); 373 | if (scrollInfo.metrics.pixels >= 374 | scrollInfo.metrics.maxScrollExtent - tolerance) { 375 | _onReachEnd(); 376 | } 377 | 378 | //snap the selection 379 | double offset = _calcCardLocation( 380 | pixel: scrollInfo.metrics.pixels, 381 | itemSize: widget.itemSize, 382 | ); 383 | 384 | //only animate if not yet snapped (tolerance 0.01 pixel) 385 | if ((scrollInfo.metrics.pixels - offset).abs() > 0.01) { 386 | _animateScroll(offset); 387 | } 388 | } else if (scrollInfo is ScrollUpdateNotification) { 389 | //save pixel position for scale-effect 390 | if (widget.dynamicItemSize || 391 | widget.dynamicItemOpacity != null) { 392 | setState(() { 393 | currentPixel = scrollInfo.metrics.pixels; 394 | }); 395 | } 396 | 397 | if (widget.updateOnScroll == true) { 398 | // dont snap until after first drag 399 | if (isInit) { 400 | return true; 401 | } 402 | 403 | if (isInit == false) { 404 | _calcCardLocation( 405 | pixel: scrollInfo.metrics.pixels, 406 | itemSize: widget.itemSize, 407 | ); 408 | } 409 | } 410 | } 411 | return !widget.dispatchScrollNotifications; 412 | }, 413 | child: ListView.builder( 414 | key: widget.listViewKey, 415 | controller: widget.listController, 416 | clipBehavior: widget.clipBehavior, 417 | keyboardDismissBehavior: widget.keyboardDismissBehavior, 418 | padding: widget.listViewPadding ?? 419 | (widget.scrollDirection == Axis.horizontal 420 | ? EdgeInsets.symmetric( 421 | horizontal: max( 422 | 0, 423 | _listPadding, 424 | )) 425 | : EdgeInsets.symmetric( 426 | vertical: max( 427 | 0, 428 | _listPadding, 429 | ), 430 | )), 431 | reverse: widget.reverse, 432 | scrollDirection: widget.scrollDirection, 433 | itemBuilder: _buildListItem, 434 | itemCount: widget.itemCount, 435 | shrinkWrap: widget.shrinkWrap, 436 | physics: widget.scrollPhysics, 437 | ), 438 | ), 439 | ); 440 | }, 441 | ), 442 | ); 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | async: 5 | dependency: transitive 6 | description: 7 | name: async 8 | url: "https://pub.dartlang.org" 9 | source: hosted 10 | version: "2.8.2" 11 | boolean_selector: 12 | dependency: transitive 13 | description: 14 | name: boolean_selector 15 | url: "https://pub.dartlang.org" 16 | source: hosted 17 | version: "2.1.0" 18 | characters: 19 | dependency: transitive 20 | description: 21 | name: characters 22 | url: "https://pub.dartlang.org" 23 | source: hosted 24 | version: "1.2.0" 25 | charcode: 26 | dependency: transitive 27 | description: 28 | name: charcode 29 | url: "https://pub.dartlang.org" 30 | source: hosted 31 | version: "1.3.1" 32 | clock: 33 | dependency: transitive 34 | description: 35 | name: clock 36 | url: "https://pub.dartlang.org" 37 | source: hosted 38 | version: "1.1.0" 39 | collection: 40 | dependency: transitive 41 | description: 42 | name: collection 43 | url: "https://pub.dartlang.org" 44 | source: hosted 45 | version: "1.16.0" 46 | fake_async: 47 | dependency: transitive 48 | description: 49 | name: fake_async 50 | url: "https://pub.dartlang.org" 51 | source: hosted 52 | version: "1.3.0" 53 | flutter: 54 | dependency: "direct main" 55 | description: flutter 56 | source: sdk 57 | version: "0.0.0" 58 | flutter_test: 59 | dependency: "direct dev" 60 | description: flutter 61 | source: sdk 62 | version: "0.0.0" 63 | matcher: 64 | dependency: transitive 65 | description: 66 | name: matcher 67 | url: "https://pub.dartlang.org" 68 | source: hosted 69 | version: "0.12.11" 70 | material_color_utilities: 71 | dependency: transitive 72 | description: 73 | name: material_color_utilities 74 | url: "https://pub.dartlang.org" 75 | source: hosted 76 | version: "0.1.4" 77 | meta: 78 | dependency: transitive 79 | description: 80 | name: meta 81 | url: "https://pub.dartlang.org" 82 | source: hosted 83 | version: "1.7.0" 84 | path: 85 | dependency: transitive 86 | description: 87 | name: path 88 | url: "https://pub.dartlang.org" 89 | source: hosted 90 | version: "1.8.1" 91 | sky_engine: 92 | dependency: transitive 93 | description: flutter 94 | source: sdk 95 | version: "0.0.99" 96 | source_span: 97 | dependency: transitive 98 | description: 99 | name: source_span 100 | url: "https://pub.dartlang.org" 101 | source: hosted 102 | version: "1.8.2" 103 | stack_trace: 104 | dependency: transitive 105 | description: 106 | name: stack_trace 107 | url: "https://pub.dartlang.org" 108 | source: hosted 109 | version: "1.10.0" 110 | stream_channel: 111 | dependency: transitive 112 | description: 113 | name: stream_channel 114 | url: "https://pub.dartlang.org" 115 | source: hosted 116 | version: "2.1.0" 117 | string_scanner: 118 | dependency: transitive 119 | description: 120 | name: string_scanner 121 | url: "https://pub.dartlang.org" 122 | source: hosted 123 | version: "1.1.0" 124 | term_glyph: 125 | dependency: transitive 126 | description: 127 | name: term_glyph 128 | url: "https://pub.dartlang.org" 129 | source: hosted 130 | version: "1.2.0" 131 | test_api: 132 | dependency: transitive 133 | description: 134 | name: test_api 135 | url: "https://pub.dartlang.org" 136 | source: hosted 137 | version: "0.4.9" 138 | vector_math: 139 | dependency: transitive 140 | description: 141 | name: vector_math 142 | url: "https://pub.dartlang.org" 143 | source: hosted 144 | version: "2.1.2" 145 | sdks: 146 | dart: ">=2.17.0-0 <3.0.0" 147 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: scroll_snap_list 2 | description: A Flutter widget that allows "snaping" event to an item at the end of user-scroll. 3 | version: 0.9.1 4 | homepage: https://github.com/MSVCode/scroll_snap_list 5 | repository: https://github.com/MSVCode/scroll_snap_list 6 | 7 | environment: 8 | sdk: '>=2.12.0 <3.0.0' 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | 14 | dev_dependencies: 15 | flutter_test: 16 | sdk: flutter -------------------------------------------------------------------------------- /readme_data/custom_dynamic_size.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MSVCode/scroll_snap_list/2c31281e6b16518c4c167dc6833ce6cdb51cbefd/readme_data/custom_dynamic_size.gif -------------------------------------------------------------------------------- /readme_data/dynamic_size.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MSVCode/scroll_snap_list/2c31281e6b16518c4c167dc6833ce6cdb51cbefd/readme_data/dynamic_size.gif -------------------------------------------------------------------------------- /readme_data/horizontal_list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MSVCode/scroll_snap_list/2c31281e6b16518c4c167dc6833ce6cdb51cbefd/readme_data/horizontal_list.gif -------------------------------------------------------------------------------- /readme_data/jumbotron_list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MSVCode/scroll_snap_list/2c31281e6b16518c4c167dc6833ce6cdb51cbefd/readme_data/jumbotron_list.gif -------------------------------------------------------------------------------- /readme_data/vertical_list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MSVCode/scroll_snap_list/2c31281e6b16518c4c167dc6833ce6cdb51cbefd/readme_data/vertical_list.gif -------------------------------------------------------------------------------- /test/scroll_snap_list_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | void main() { 4 | test('No Test', () { 5 | }); 6 | } 7 | --------------------------------------------------------------------------------