├── doc └── images │ ├── 1581152212.gif │ └── 1581152222.gif ├── README.md ├── .metadata ├── lib ├── page3.dart ├── page2.dart ├── page1.dart ├── shop │ ├── shop_scroll_coordinator.dart │ ├── shop_scroll_controller.dart │ └── shop_scroll_position.dart └── main.dart ├── .gitignore ├── test └── widget_test.dart ├── pubspec.yaml └── pubspec.lock /doc/images/1581152212.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyjaysong/flutter_meituan_shop/HEAD/doc/images/1581152212.gif -------------------------------------------------------------------------------- /doc/images/1581152222.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyjaysong/flutter_meituan_shop/HEAD/doc/images/1581152222.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | target 3 | result 4 |
5 | -------------------------------------------------------------------------------- /.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: 659dc8129d4edb9166e9a0d600439d135740933f 8 | channel: beta 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /lib/page3.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'shop/shop_scroll_controller.dart'; 4 | import 'shop/shop_scroll_coordinator.dart'; 5 | 6 | class Page3 extends StatefulWidget { 7 | const Page3({Key key}) : super(key: key); 8 | 9 | @override 10 | _Page3State createState() => _Page3State(); 11 | } 12 | 13 | class _Page3State extends State { 14 | @override 15 | Widget build(BuildContext context) { 16 | return Center( 17 | child: Text("店铺信息页面"), 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.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 | .flutter-plugins-dependencies 28 | .packages 29 | .pub-cache/ 30 | .pub/ 31 | /build/ 32 | 33 | # Web related 34 | lib/generated_plugin_registrant.dart 35 | 36 | # Exceptions to above rules. 37 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 38 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility that Flutter provides. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | import 'package:flutter_meituan_shop/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /lib/page2.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'shop/shop_scroll_controller.dart'; 4 | import 'shop/shop_scroll_coordinator.dart'; 5 | 6 | class Page2 extends StatefulWidget { 7 | final ShopScrollCoordinator shopCoordinator; 8 | 9 | const Page2({@required this.shopCoordinator, Key key}) : super(key: key); 10 | 11 | @override 12 | _Page2State createState() => _Page2State(); 13 | } 14 | 15 | class _Page2State extends State { 16 | ShopScrollCoordinator _shopCoordinator; 17 | ShopScrollController _listScrollController; 18 | 19 | @override 20 | void initState() { 21 | _shopCoordinator = widget.shopCoordinator; 22 | _listScrollController = _shopCoordinator.newChildScrollController(); 23 | super.initState(); 24 | } 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | return ListView.builder( 29 | padding: EdgeInsets.all(0), 30 | physics: ClampingScrollPhysics(), 31 | controller: _listScrollController, 32 | itemExtent: 150, 33 | itemBuilder: (context, index) => Container( 34 | child: Material( 35 | elevation: 4.0, 36 | borderRadius: BorderRadius.circular(5.0), 37 | color: index % 2 == 0 ? Colors.cyan : Colors.deepOrange, 38 | child: Center(child: Text(index.toString())), 39 | ), 40 | ), 41 | ); 42 | } 43 | 44 | @override 45 | void dispose() { 46 | _listScrollController?.dispose(); 47 | _listScrollController = null; 48 | super.dispose(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/page1.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'shop/shop_scroll_controller.dart'; 4 | import 'shop/shop_scroll_coordinator.dart'; 5 | 6 | class Page1 extends StatefulWidget { 7 | final ShopScrollCoordinator shopCoordinator; 8 | 9 | const Page1({@required this.shopCoordinator, Key key}) : super(key: key); 10 | 11 | @override 12 | _Page1State createState() => _Page1State(); 13 | } 14 | 15 | class _Page1State extends State { 16 | ShopScrollCoordinator _shopCoordinator; 17 | ShopScrollController _listScrollController1; 18 | ShopScrollController _listScrollController2; 19 | 20 | @override 21 | void initState() { 22 | _shopCoordinator = widget.shopCoordinator; 23 | _listScrollController1 = _shopCoordinator.newChildScrollController(); 24 | _listScrollController2 = _shopCoordinator.newChildScrollController(); 25 | super.initState(); 26 | } 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return Row( 31 | children: [ 32 | Expanded( 33 | child: ListView.builder( 34 | padding: EdgeInsets.all(0), 35 | physics: AlwaysScrollableScrollPhysics(), 36 | controller: _listScrollController1, 37 | itemExtent: 50.0, 38 | itemCount: 20, 39 | itemBuilder: (context, index) => Container( 40 | child: Material( 41 | elevation: 4.0, 42 | borderRadius: BorderRadius.circular(5.0), 43 | color: index % 2 == 0 ? Colors.cyan : Colors.deepOrange, 44 | child: Center(child: Text(index.toString())), 45 | ), 46 | ), 47 | ), 48 | ), 49 | Expanded( 50 | flex: 4, 51 | child: ListView.builder( 52 | padding: EdgeInsets.all(0), 53 | physics: AlwaysScrollableScrollPhysics(), 54 | controller: _listScrollController2, 55 | itemExtent: 200.0, 56 | itemCount: 30, 57 | itemBuilder: (context, index) => Container( 58 | padding: EdgeInsets.symmetric(horizontal: 1), 59 | child: Material( 60 | elevation: 4.0, 61 | borderRadius: BorderRadius.circular(5.0), 62 | color: index % 2 == 0 ? Colors.cyan : Colors.deepOrange, 63 | child: Center(child: Text(index.toString())), 64 | ), 65 | ), 66 | ), 67 | ), 68 | ], 69 | ); 70 | } 71 | 72 | @override 73 | void dispose() { 74 | _listScrollController1?.dispose(); 75 | _listScrollController2?.dispose(); 76 | _listScrollController1 = _listScrollController2 = null; 77 | super.dispose(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_meituan_shop 2 | 3 | description: A new Flutter project. 4 | 5 | # The following defines the version and build number for your application. 6 | # A version number is three numbers separated by dots, like 1.2.43 7 | # followed by an optional build number separated by a +. 8 | # Both the version and the builder number may be overridden in flutter 9 | # build by specifying --build-name and --build-number, respectively. 10 | # In Android, build-name is used as versionName while build-number used as versionCode. 11 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 12 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 13 | # Read more about iOS versioning at 14 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 15 | version: 1.0.0+1 16 | 17 | environment: 18 | sdk: ">=2.6.0 <3.0.0" 19 | 20 | dependencies: 21 | flutter: 22 | sdk: flutter 23 | 24 | # The following adds the Cupertino Icons font to your application. 25 | # Use with the CupertinoIcons class for iOS style icons. 26 | cupertino_icons: ^0.1.2 27 | # provider插件 https://pub.flutter-io.cn/packages/provider 28 | provider: ^4.0.2 29 | 30 | dev_dependencies: 31 | flutter_test: 32 | sdk: flutter 33 | 34 | 35 | # For information on the generic Dart part of this file, see the 36 | # following page: https://dart.dev/tools/pub/pubspec 37 | 38 | # The following section is specific to Flutter. 39 | flutter: 40 | 41 | # The following line ensures that the Material Icons font is 42 | # included with your application, so that you can use the icons in 43 | # the material Icons class. 44 | uses-material-design: true 45 | 46 | # To add assets to your application, add an assets section, like this: 47 | # assets: 48 | # - bg.jpg 49 | # - images/a_dot_ham.jpeg 50 | 51 | # An image asset can refer to one or more resolution-specific "variants", see 52 | # https://flutter.dev/assets-and-images/#resolution-aware. 53 | 54 | # For details regarding adding assets from package dependencies, see 55 | # https://flutter.dev/assets-and-images/#from-packages 56 | 57 | # To add custom fonts to your application, add a fonts section here, 58 | # in this "flutter" section. Each entry in this list should have a 59 | # "family" key with the font family name, and a "fonts" key with a 60 | # list giving the asset and other descriptors for the font. For 61 | # example: 62 | # fonts: 63 | # - family: Schyler 64 | # fonts: 65 | # - asset: fonts/Schyler-Regular.ttf 66 | # - asset: fonts/Schyler-Italic.ttf 67 | # style: italic 68 | # - family: Trajan Pro 69 | # fonts: 70 | # - asset: fonts/TrajanPro.ttf 71 | # - asset: fonts/TrajanPro_Bold.ttf 72 | # weight: 700 73 | # 74 | # For details regarding fonts from package dependencies, 75 | # see https://flutter.dev/custom-fonts/#from-packages 76 | -------------------------------------------------------------------------------- /lib/shop/shop_scroll_coordinator.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/rendering.dart'; 4 | 5 | import 'shop_scroll_controller.dart'; 6 | import 'shop_scroll_position.dart'; 7 | 8 | enum PageExpandState { NotExpand, Expanding, Expanded } 9 | 10 | /// 协调器 11 | /// 12 | /// 页面 Primary [CustomScrollView] 控制 13 | class ShopScrollCoordinator { 14 | final String pageLabel = 'page'; 15 | 16 | ShopScrollController _pageScrollController; 17 | double Function() pinnedHeaderSliverHeightBuilder; 18 | 19 | ShopScrollPosition get _pageScrollPosition => _pageScrollController.position; 20 | 21 | ScrollDragController scrollDragController; 22 | 23 | /// 主页面滑动部件默认位置 24 | double _pageInitialOffset; 25 | 26 | /// 获取主页面滑动控制器 27 | ShopScrollController pageScrollController([double initialOffset = 0.0]) { 28 | assert(initialOffset != null, initialOffset >= 0.0); 29 | _pageInitialOffset = initialOffset; 30 | _pageScrollController = ShopScrollController( 31 | this, 32 | debugLabel: pageLabel, 33 | initialScrollOffset: initialOffset, 34 | ); 35 | return _pageScrollController; 36 | } 37 | 38 | /// 创建并获取一个子滑动控制器 39 | ShopScrollController newChildScrollController([String debugLabel]) => 40 | ShopScrollController(this, debugLabel: debugLabel); 41 | 42 | /// 子部件滑动数据协调 43 | /// 44 | /// [userScrollDirection] 用户滑动方向 45 | /// [position] 被滑动的子部件的位置信息 46 | void applyUserOffset( 47 | double delta, [ 48 | ScrollDirection userScrollDirection, 49 | ShopScrollPosition position, 50 | ]) { 51 | if (userScrollDirection == ScrollDirection.reverse) { 52 | updateUserScrollDirection(_pageScrollPosition, userScrollDirection); 53 | final double innerDelta = 54 | _pageScrollPosition.applyClampedDragUpdate(delta); 55 | if (innerDelta != 0.0) { 56 | updateUserScrollDirection(position, userScrollDirection); 57 | position.applyFullDragUpdate(innerDelta); 58 | } 59 | } else { 60 | updateUserScrollDirection(position, userScrollDirection); 61 | final double outerDelta = position.applyClampedDragUpdate(delta); 62 | if (outerDelta != 0.0) { 63 | updateUserScrollDirection(_pageScrollPosition, userScrollDirection); 64 | _pageScrollPosition.applyFullDragUpdate(outerDelta); 65 | } 66 | } 67 | } 68 | 69 | bool applyContentDimensions(double minScrollExtent, double maxScrollExtent, 70 | ShopScrollPosition position) { 71 | if (pinnedHeaderSliverHeightBuilder != null) { 72 | maxScrollExtent = maxScrollExtent - pinnedHeaderSliverHeightBuilder(); 73 | maxScrollExtent = math.max(0.0, maxScrollExtent); 74 | } 75 | return position.applyContentDimensions( 76 | minScrollExtent, maxScrollExtent, true); 77 | } 78 | 79 | /// 当默认位置不为0时,主部件已下拉距离超过默认位置,但超过的距离不大于该值时, 80 | /// 若手指离开屏幕,主部件头部会回弹至默认位置 81 | final double _scrollRedundancy = 80; 82 | 83 | /// 当前页面Header最大程度展开状态 84 | PageExpandState pageExpand = PageExpandState.NotExpand; 85 | 86 | /// 当手指离开屏幕 87 | void onPointerUp(PointerUpEvent event) { 88 | final double _pagePixels = _pageScrollPosition.pixels; 89 | if (0.0 < _pagePixels && _pagePixels < _pageInitialOffset) { 90 | if (pageExpand == PageExpandState.NotExpand && 91 | _pageInitialOffset - _pagePixels > _scrollRedundancy) { 92 | _pageScrollPosition 93 | .animateTo( 94 | 0.0, 95 | duration: const Duration(milliseconds: 400), 96 | curve: Curves.ease, 97 | ) 98 | .then((_) => pageExpand = PageExpandState.Expanded); 99 | } else { 100 | pageExpand = PageExpandState.Expanding; 101 | _pageScrollPosition 102 | .animateTo( 103 | _pageInitialOffset, 104 | duration: const Duration(milliseconds: 400), 105 | curve: Curves.ease, 106 | ) 107 | .then((_) => pageExpand = PageExpandState.NotExpand); 108 | } 109 | } 110 | } 111 | 112 | /// 更新用户滑动方向 113 | void updateUserScrollDirection( 114 | ShopScrollPosition position, ScrollDirection value) { 115 | assert(position != null && value != null); 116 | position.didUpdateScrollDirection(value); 117 | } 118 | 119 | /// 以特定的速度开始一个物理驱动的模拟,该模拟确定 [pixels] 位置。 120 | /// 121 | /// 此方法遵从 [ScrollPhysics.createBallisticSimulation],通常在当前位置超出范围时 122 | /// 提供滑动模拟,而在当前位置超出范围但具有非零速度时提供摩擦模拟。 123 | /// 124 | /// 速度应以 逻辑像素/秒 为单位。 125 | void goBallistic(double velocity) => 126 | _pageScrollPosition.goBallistic(velocity); 127 | } 128 | -------------------------------------------------------------------------------- /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.flutter-io.cn" 9 | source: hosted 10 | version: "2.5.0-nullsafety.1" 11 | boolean_selector: 12 | dependency: transitive 13 | description: 14 | name: boolean_selector 15 | url: "https://pub.flutter-io.cn" 16 | source: hosted 17 | version: "2.1.0-nullsafety.1" 18 | characters: 19 | dependency: transitive 20 | description: 21 | name: characters 22 | url: "https://pub.flutter-io.cn" 23 | source: hosted 24 | version: "1.1.0-nullsafety.3" 25 | charcode: 26 | dependency: transitive 27 | description: 28 | name: charcode 29 | url: "https://pub.flutter-io.cn" 30 | source: hosted 31 | version: "1.2.0-nullsafety.1" 32 | clock: 33 | dependency: transitive 34 | description: 35 | name: clock 36 | url: "https://pub.flutter-io.cn" 37 | source: hosted 38 | version: "1.1.0-nullsafety.1" 39 | collection: 40 | dependency: transitive 41 | description: 42 | name: collection 43 | url: "https://pub.flutter-io.cn" 44 | source: hosted 45 | version: "1.15.0-nullsafety.3" 46 | cupertino_icons: 47 | dependency: "direct main" 48 | description: 49 | name: cupertino_icons 50 | url: "https://pub.flutter-io.cn" 51 | source: hosted 52 | version: "0.1.3" 53 | fake_async: 54 | dependency: transitive 55 | description: 56 | name: fake_async 57 | url: "https://pub.flutter-io.cn" 58 | source: hosted 59 | version: "1.2.0-nullsafety.1" 60 | flutter: 61 | dependency: "direct main" 62 | description: flutter 63 | source: sdk 64 | version: "0.0.0" 65 | flutter_test: 66 | dependency: "direct dev" 67 | description: flutter 68 | source: sdk 69 | version: "0.0.0" 70 | matcher: 71 | dependency: transitive 72 | description: 73 | name: matcher 74 | url: "https://pub.flutter-io.cn" 75 | source: hosted 76 | version: "0.12.10-nullsafety.1" 77 | meta: 78 | dependency: transitive 79 | description: 80 | name: meta 81 | url: "https://pub.flutter-io.cn" 82 | source: hosted 83 | version: "1.3.0-nullsafety.3" 84 | nested: 85 | dependency: transitive 86 | description: 87 | name: nested 88 | url: "https://pub.flutter-io.cn" 89 | source: hosted 90 | version: "0.0.4" 91 | path: 92 | dependency: transitive 93 | description: 94 | name: path 95 | url: "https://pub.flutter-io.cn" 96 | source: hosted 97 | version: "1.8.0-nullsafety.1" 98 | provider: 99 | dependency: "direct main" 100 | description: 101 | name: provider 102 | url: "https://pub.flutter-io.cn" 103 | source: hosted 104 | version: "4.0.2" 105 | sky_engine: 106 | dependency: transitive 107 | description: flutter 108 | source: sdk 109 | version: "0.0.99" 110 | source_span: 111 | dependency: transitive 112 | description: 113 | name: source_span 114 | url: "https://pub.flutter-io.cn" 115 | source: hosted 116 | version: "1.8.0-nullsafety.2" 117 | stack_trace: 118 | dependency: transitive 119 | description: 120 | name: stack_trace 121 | url: "https://pub.flutter-io.cn" 122 | source: hosted 123 | version: "1.10.0-nullsafety.1" 124 | stream_channel: 125 | dependency: transitive 126 | description: 127 | name: stream_channel 128 | url: "https://pub.flutter-io.cn" 129 | source: hosted 130 | version: "2.1.0-nullsafety.1" 131 | string_scanner: 132 | dependency: transitive 133 | description: 134 | name: string_scanner 135 | url: "https://pub.flutter-io.cn" 136 | source: hosted 137 | version: "1.1.0-nullsafety.1" 138 | term_glyph: 139 | dependency: transitive 140 | description: 141 | name: term_glyph 142 | url: "https://pub.flutter-io.cn" 143 | source: hosted 144 | version: "1.2.0-nullsafety.1" 145 | test_api: 146 | dependency: transitive 147 | description: 148 | name: test_api 149 | url: "https://pub.flutter-io.cn" 150 | source: hosted 151 | version: "0.2.19-nullsafety.2" 152 | typed_data: 153 | dependency: transitive 154 | description: 155 | name: typed_data 156 | url: "https://pub.flutter-io.cn" 157 | source: hosted 158 | version: "1.3.0-nullsafety.3" 159 | vector_math: 160 | dependency: transitive 161 | description: 162 | name: vector_math 163 | url: "https://pub.flutter-io.cn" 164 | source: hosted 165 | version: "2.1.0-nullsafety.3" 166 | sdks: 167 | dart: ">=2.10.0-110 <2.11.0" 168 | flutter: ">=1.12.1" 169 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'package:flutter/material.dart'; 3 | import 'page1.dart'; 4 | import 'page2.dart'; 5 | import 'page3.dart'; 6 | import 'shop/shop_scroll_coordinator.dart'; 7 | 8 | import 'shop/shop_scroll_controller.dart'; 9 | 10 | void main() => runApp(MyApp()); 11 | MediaQueryData mediaQuery; 12 | double statusBarHeight; 13 | double screenHeight; 14 | 15 | class MyApp extends StatelessWidget { 16 | @override 17 | Widget build(BuildContext context) { 18 | return MaterialApp( 19 | debugShowCheckedModeBanner: false, 20 | title: 'ShopScroll', 21 | home: ShopPage(), 22 | theme: ThemeData(primarySwatch: Colors.blue), 23 | ); 24 | } 25 | } 26 | 27 | class ShopPage extends StatefulWidget { 28 | ShopPage({Key key}) : super(key: key); 29 | 30 | @override 31 | _ShopPageState createState() => _ShopPageState(); 32 | } 33 | 34 | class _ShopPageState extends State 35 | with SingleTickerProviderStateMixin { 36 | ///页面滑动协调器 37 | ShopScrollCoordinator _shopCoordinator; 38 | ShopScrollController _pageScrollController; 39 | 40 | TabController _tabController; 41 | 42 | final double _sliverAppBarInitHeight = 200; 43 | final double _tabBarHeight = 50; 44 | double _sliverAppBarMaxHeight; 45 | 46 | @override 47 | void initState() { 48 | super.initState(); 49 | _shopCoordinator = ShopScrollCoordinator(); 50 | _tabController = TabController(vsync: this, length: 3); 51 | } 52 | 53 | @override 54 | Widget build(BuildContext context) { 55 | mediaQuery ??= MediaQuery.of(context); 56 | screenHeight ??= mediaQuery.size.height; 57 | statusBarHeight ??= mediaQuery.padding.top; 58 | 59 | _sliverAppBarMaxHeight ??= screenHeight; 60 | _pageScrollController ??= _shopCoordinator 61 | .pageScrollController(_sliverAppBarMaxHeight - _sliverAppBarInitHeight); 62 | 63 | _shopCoordinator.pinnedHeaderSliverHeightBuilder ??= () { 64 | return statusBarHeight + kToolbarHeight + _tabBarHeight; 65 | }; 66 | return Scaffold( 67 | body: Listener( 68 | onPointerUp: _shopCoordinator.onPointerUp, 69 | child: CustomScrollView( 70 | controller: _pageScrollController, 71 | physics: ClampingScrollPhysics(), 72 | slivers: [ 73 | SliverAppBar( 74 | pinned: true, 75 | title: Text("店铺首页", style: TextStyle(color: Colors.white)), 76 | backgroundColor: Colors.blue, 77 | expandedHeight: _sliverAppBarMaxHeight, 78 | ), 79 | SliverPersistentHeader( 80 | pinned: false, 81 | floating: true, 82 | delegate: _SliverAppBarDelegate( 83 | maxHeight: 100, 84 | minHeight: 100, 85 | child: Center(child: Text("我是活动Header")), 86 | ), 87 | ), 88 | SliverPersistentHeader( 89 | pinned: true, 90 | floating: false, 91 | delegate: _SliverAppBarDelegate( 92 | maxHeight: _tabBarHeight, 93 | minHeight: _tabBarHeight, 94 | child: Container( 95 | color: Colors.white, 96 | child: TabBar( 97 | labelColor: Colors.black, 98 | controller: _tabController, 99 | tabs: [ 100 | Tab(text: "商品"), 101 | Tab(text: "评价"), 102 | Tab(text: "商家"), 103 | ], 104 | ), 105 | ), 106 | ), 107 | ), 108 | SliverFillRemaining( 109 | child: TabBarView( 110 | controller: _tabController, 111 | children: [ 112 | Page1(shopCoordinator: _shopCoordinator), 113 | Page2(shopCoordinator: _shopCoordinator), 114 | Page3(), 115 | ], 116 | ), 117 | ) 118 | ], 119 | ), 120 | ), 121 | ); 122 | } 123 | 124 | @override 125 | void dispose() { 126 | _tabController?.dispose(); 127 | _pageScrollController?.dispose(); 128 | super.dispose(); 129 | } 130 | } 131 | 132 | class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { 133 | _SliverAppBarDelegate({ 134 | @required this.minHeight, 135 | @required this.maxHeight, 136 | @required this.child, 137 | }); 138 | 139 | final double minHeight; 140 | final double maxHeight; 141 | final Widget child; 142 | 143 | @override 144 | double get minExtent => this.minHeight; 145 | 146 | @override 147 | double get maxExtent => max(maxHeight, minHeight); 148 | 149 | @override 150 | Widget build( 151 | BuildContext context, double shrinkOffset, bool overlapsContent) { 152 | return SizedBox.expand(child: child); 153 | } 154 | 155 | @override 156 | bool shouldRebuild(_SliverAppBarDelegate oldDelegate) { 157 | return maxHeight != oldDelegate.maxHeight || 158 | minHeight != oldDelegate.minHeight || 159 | child != oldDelegate.child; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /lib/shop/shop_scroll_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | 4 | import 'shop_scroll_coordinator.dart'; 5 | import 'shop_scroll_position.dart'; 6 | 7 | /// 滑动控制器。为可滚动小部件创建一个控制器。 8 | /// 9 | /// [initialScrollOffset] 和 [keepScrollOffset] 的值不能为 null。 10 | class ShopScrollController extends ScrollController { 11 | ShopScrollController( 12 | this.coordinator, { 13 | double initialScrollOffset = 0.0, 14 | 15 | /// 每次滚动完成时,请使用 [PageStorage] 保存当前滚动 [offset] ,如果重新创建了此 16 | /// 控制器的可滚动内容,则将其还原。 17 | /// 18 | /// 如果将此属性设置为false,则永远不会保存滚动偏移量, 19 | /// 并且始终使用 [initialScrollOffset] 来初始化滚动偏移量。如果为 true(默认值), 20 | /// 则第一次创建控制器的可滚动对象时将使用初始滚动偏移量,因为尚无要还原的滚动偏移量。 21 | /// 随后,将恢复保存的偏移,并且忽略[initialScrollOffset]。 22 | /// 23 | /// 也可以看看: 24 | /// * [PageStorageKey],当同一路径中出现多个滚动条时,应使用 [PageStorageKey] 25 | /// 来区分用于保存滚动偏移量的 [PageStorage] 位置。 26 | bool keepScrollOffset = true, 27 | 28 | /// [toString] 输出中使用的标签。帮助在调试输出中标识滚动控制器实例。 29 | String debugLabel, 30 | }) : assert(initialScrollOffset != null), 31 | assert(keepScrollOffset != null), 32 | _initialScrollOffset = initialScrollOffset, 33 | super(keepScrollOffset: keepScrollOffset, debugLabel: debugLabel); 34 | 35 | final ShopScrollCoordinator coordinator; 36 | 37 | /// 用于 [offset] 的初始值。 38 | /// 如果 [keepScrollOffset] 为 false 或尚未保存滚动偏移量, 39 | /// 则创建并附加到此控制器的新 [ShopScrollPosition] 对象的偏移量将初始化为该值。 40 | /// 默认为 0.0。 41 | @override 42 | double get initialScrollOffset => _initialScrollOffset; 43 | final double _initialScrollOffset; 44 | 45 | /// 当前附加的 [positions]。 46 | /// 47 | /// 不应直接突变。 48 | /// 可以使用 [attach] 和 [detach] 添加和删除 [ShopScrollPosition] 对象。 49 | @protected 50 | @override 51 | Iterable get positions => _positions; 52 | final List _positions = []; 53 | 54 | /// 是否有任何 [ShopScrollPosition] 对象已使用 [attach] 方法 55 | /// 将自身附加到 [ScrollController]。 56 | /// 57 | /// 如果为 false,则不得调用与 [ShopScrollPosition] 交互的成员, 58 | /// 例如 [position],[offset],[animateTo] 和 [jumpTo]。 59 | @override 60 | bool get hasClients => _positions.isNotEmpty; 61 | 62 | /// 返回附加的[ScrollPosition],可以从中获取[ScrollView]的实际滚动偏移量。 63 | /// 64 | /// 仅在仅连接一个 [position] 时调用此选项才有效。 65 | @override 66 | ShopScrollPosition get position { 67 | assert(_positions.isNotEmpty, 68 | 'ScrollController not attached to any scroll views.'); 69 | assert(_positions.length == 1, 70 | 'ScrollController attached to multiple scroll views.'); 71 | return _positions.single; 72 | } 73 | 74 | /// 可滚动小部件的当前滚动偏移量。要求控制器仅控制一个可滚动小部件。 75 | @override 76 | double get offset => position.pixels; 77 | 78 | /// 从当前位置到给定值的位置动画。 79 | /// 任何活动的动画都将被取消。 如果用户当前正在滚动,则该操作将被取消。 80 | /// 返回的 [Future] 将在动画结束时完成,无论它是否成功完成或是否被过早中断。 81 | /// 82 | /// 每当用户尝试手动滚动或启动其他活动,或者动画到达视口边缘并尝试过度滚动时, 83 | /// 动画都会中断。如果 [ShopScrollPosition] 不会过度滚动,而是允许滚动超出范围, 84 | /// 那么超出范围不会中断动画。 85 | /// 86 | /// 动画对视口或内容尺寸的更改无动于衷。 87 | /// 88 | /// 一旦动画完成,如果滚动位置的值不稳定,则滚动位置将尝试开始弹道活动。 89 | /// (例如,如果滚动超出范围,并且在这种情况下滚动位置通常会弹回) 90 | /// 91 | /// 持续时间不能为零。要在没有动画的情况下跳至特定值,请使用 [jumpTo]。 92 | @override 93 | Future animateTo( 94 | double offset, { 95 | @required Duration duration, 96 | @required Curve curve, 97 | }) { 98 | assert( 99 | _positions.isNotEmpty, 100 | 'ScrollController not attached to any scroll views.', 101 | ); 102 | final List> animations = List>(_positions.length); 103 | for (int i = 0; i < _positions.length; i += 1) 104 | animations[i] = _positions[i].animateTo( 105 | offset, 106 | duration: duration, 107 | curve: curve, 108 | ); 109 | return Future.wait(animations).then((List _) => null); 110 | } 111 | 112 | /// 将滚动位置从其当前值跳转到给定值,而不进行动画处理,也无需检查新值是否在范围内。 113 | /// 任何活动的动画都将被取消。 如果用户当前正在滚动,则该操作将被取消。 114 | /// 115 | /// 如果此方法更改了滚动位置,则将分派开始/更新/结束滚动通知的序列。 116 | /// 此方法不能生成过滚动通知。跳跃之后,如果数值超出范围,则立即开始弹道活动。 117 | @override 118 | void jumpTo(double value) { 119 | assert( 120 | _positions.isNotEmpty, 121 | 'ScrollController not attached to any scroll views.', 122 | ); 123 | for (final ScrollPosition position in List.from(_positions)) 124 | position.jumpTo(value); 125 | } 126 | 127 | /// 在此控制器上注册给定位置。 128 | /// 此函数返回后,此控制器上的 [animateTo] 和 [jumpTo] 方法将操纵给定位置。 129 | @override 130 | void attach(covariant ShopScrollPosition position) { 131 | assert(!_positions.contains(position)); 132 | _positions.add(position); 133 | position.addListener(notifyListeners); 134 | } 135 | 136 | /// 用此控制器注销给定位置。 137 | /// 此函数返回后,此控制器上的 [animateTo] 和 [jumpTo] 方法将不会操纵给定位置。 138 | @override 139 | void detach(ScrollPosition position) { 140 | assert(_positions.contains(position)); 141 | position.removeListener(notifyListeners); 142 | _positions.remove(position); 143 | } 144 | 145 | @override 146 | void dispose() { 147 | for (final ScrollPosition position in _positions) 148 | position.removeListener(notifyListeners); 149 | super.dispose(); 150 | } 151 | 152 | /// 创建一个 [ShopScrollPosition] 供 [Scrollable] 小部件使用。 153 | /// 154 | /// 子类可以重写此功能,以自定义其控制的可滚动小部件使用的 [ShopScrollPosition]。 155 | /// 例如,[PageController] 重写此函数以返回面向页面的滚动位置子类, 156 | /// 该子类在可滚动窗口小部件调整大小时保持同一页面可见。 157 | /// 158 | /// 默认情况下,返回 [ScrollPositionWithSingleContext]。 159 | /// 参数通常传递给正在创建的 [ScrollPosition]: 160 | /// 161 | /// * [physics]:[ScrollPhysics] 的一个实例,它确定 [ScrollPosition] 对用户交互的 162 | /// 反应方式,释放或甩动时如何模拟滚动等。该值不会为null。它通常来自 [ScrollView] 163 | /// 或其他创建 [Scrollable]的小部件, 164 | /// 或者(如果未提供)来自环境的 [ScrollConfiguration]。 165 | /// * [context]:一个 [ScrollContext],用于与拥有 [ScrollPosition] 的对象进行通信 166 | /// (通常是 [Scrollable] 本身)。 167 | /// * [oldPosition]:如果这不是第一次为此 [Scrollable] 创建 [ScrollPosition],则 168 | /// 它将是前一个实例。当环境已更改并且 [Scrollable] 需要重新创建 [ScrollPosition] 169 | /// 对象时,将使用此方法。 第一次创建 [ScrollPosition] 时为 null。 170 | @override 171 | ShopScrollPosition createScrollPosition( 172 | ScrollPhysics physics, 173 | ScrollContext context, 174 | ScrollPosition oldPosition, 175 | ) { 176 | return ShopScrollPosition( 177 | coordinator: coordinator, 178 | physics: physics, 179 | context: context, 180 | initialPixels: initialScrollOffset, 181 | keepScrollOffset: keepScrollOffset, 182 | oldPosition: oldPosition, 183 | debugLabel: debugLabel, 184 | ); 185 | } 186 | 187 | @override 188 | String toString() { 189 | final List description = []; 190 | debugFillDescription(description); 191 | return '${describeIdentity(this)}(${description.join(", ")})'; 192 | } 193 | 194 | /// 在给定的描述中添加其他信息,以供 [toString] 使用。 195 | /// 此方法使子类更易于协调以提供高质量的 [toString] 实现。[ScrollController] 基类上 196 | /// 的 [toString] 实现调用 [debugFillDescription] 来从子类中收集有用的信息,以合并 197 | /// 到其返回值中。如果您重写了此方法,请确保通过调用 198 | /// `super.debugFillDescription)description)` 来启动方法。 199 | @override 200 | @mustCallSuper 201 | void debugFillDescription(List description) { 202 | super.debugFillDescription(description); 203 | if (debugLabel != null) { 204 | description.add(debugLabel); 205 | } 206 | if (initialScrollOffset != 0.0) 207 | description.add( 208 | 'initialScrollOffset: ${initialScrollOffset.toStringAsFixed(1)}, '); 209 | if (_positions.isEmpty) { 210 | description.add('no clients'); 211 | } else if (_positions.length == 1) { 212 | // 实际上不列出客户端本身,因为它的 toString 可能引用了我们。 213 | description.add('one client, offset ${offset?.toStringAsFixed(1)}'); 214 | } else { 215 | description.add('${_positions.length} clients'); 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /lib/shop/shop_scroll_position.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | 3 | import 'package:flutter/gestures.dart'; 4 | import 'package:flutter/physics.dart'; 5 | import 'package:flutter/rendering.dart'; 6 | import 'package:flutter/widgets.dart'; 7 | 8 | import 'shop_scroll_coordinator.dart'; 9 | 10 | /// 滑动位置信息 11 | class ShopScrollPosition extends ScrollPosition 12 | implements ScrollActivityDelegate { 13 | ShopScrollPosition({ 14 | @required ScrollPhysics physics, 15 | @required ScrollContext context, 16 | double initialPixels = 0.0, 17 | bool keepScrollOffset = true, 18 | ScrollPosition oldPosition, 19 | String debugLabel, 20 | @required this.coordinator, 21 | }) : super( 22 | physics: physics, 23 | context: context, 24 | keepScrollOffset: keepScrollOffset, 25 | oldPosition: oldPosition, 26 | debugLabel: debugLabel, 27 | ) { 28 | // 如果oldPosition不为null,则父级将首先调用Absorb(),它可以设置_pixels和_activity. 29 | if (pixels == null && initialPixels != null) { 30 | correctPixels(initialPixels); 31 | } 32 | if (activity == null) { 33 | goIdle(); 34 | } 35 | assert(activity != null); 36 | } 37 | 38 | final ShopScrollCoordinator coordinator; // 协调器 39 | ScrollDragController _currentDrag; 40 | double _heldPreviousVelocity = 0.0; 41 | 42 | @override 43 | AxisDirection get axisDirection => context.axisDirection; 44 | 45 | @override 46 | double setPixels(double newPixels) { 47 | assert(activity.isScrolling); 48 | return super.setPixels(newPixels); 49 | } 50 | 51 | @override 52 | void absorb(ScrollPosition other) { 53 | super.absorb(other); 54 | if (other is! ShopScrollPosition) { 55 | goIdle(); 56 | return; 57 | } 58 | activity.updateDelegate(this); 59 | final ShopScrollPosition typedOther = other as ShopScrollPosition; 60 | _userScrollDirection = typedOther._userScrollDirection; 61 | assert(_currentDrag == null); 62 | if (typedOther._currentDrag != null) { 63 | _currentDrag = typedOther._currentDrag; 64 | _currentDrag.updateDelegate(this); 65 | typedOther._currentDrag = null; 66 | } 67 | } 68 | 69 | @override 70 | void applyNewDimensions() { 71 | super.applyNewDimensions(); 72 | context.setCanDrag(physics.shouldAcceptUserOffset(this)); 73 | } 74 | 75 | /// 返回未使用的增量。 76 | /// 77 | /// 正增量表示下降(在上方显示内容),负增量向上(在下方显示内容)。 78 | double applyClampedDragUpdate(double delta) { 79 | assert(delta != 0.0); 80 | // 如果我们要朝向 maxScrollExtent(负滚动偏移),那么我们在 minScrollExtent 方向上 81 | // 可以达到的最大距离是负无穷大。例如,如果我们已经过度滚动,则滚动以减少过度滚动不应 82 | // 禁止过度滚动。如果我们要朝 minScrollExtent(正滚动偏移量)方向移动,那么我们在 83 | // minScrollExtent 方向上可以达到的最大距离是我们现在所处的位置。 84 | // 换句话说,我们不能通过 applyClampedDragUpdate 进入过滚动状态。 85 | // 尽管如此,可能通过多种方式进入了过度滚动的情况。一种是物理是否允许通过 86 | // applyFullDragUpdate(请参见下文)。 87 | // 可能会发生过度滚动的情况,例如,使用滚动控制器人工设置了滚动位置。 88 | final double min = 89 | delta < 0.0 ? -double.infinity : math.min(minScrollExtent, pixels); 90 | // max 的逻辑是等效的,但反向。 91 | final double max = 92 | delta > 0.0 ? double.infinity : math.max(maxScrollExtent, pixels); 93 | final double oldPixels = pixels; 94 | final double newPixels = (pixels - delta).clamp(min, max) as double; 95 | final double clampedDelta = newPixels - pixels; 96 | if (clampedDelta == 0.0) { 97 | return delta; 98 | } 99 | final double overScroll = physics.applyBoundaryConditions(this, newPixels); 100 | final double actualNewPixels = newPixels - overScroll; 101 | final double offset = actualNewPixels - oldPixels; 102 | if (offset != 0.0) { 103 | forcePixels(actualNewPixels); 104 | didUpdateScrollPositionBy(offset); 105 | } 106 | return delta + offset; 107 | } 108 | 109 | // 返回过度滚动。 110 | double applyFullDragUpdate(double delta) { 111 | assert(delta != 0.0); 112 | final double oldPixels = pixels; 113 | // Apply friction: 施加摩擦: 114 | final double newPixels = 115 | pixels - physics.applyPhysicsToUserOffset(this, delta); 116 | if (oldPixels == newPixels) { 117 | // 增量一定很小,我们在添加浮点数时将其删除了 118 | return 0.0; 119 | } 120 | // Check for overScroll: 检查过度滚动: 121 | final double overScroll = physics.applyBoundaryConditions(this, newPixels); 122 | final double actualNewPixels = newPixels - overScroll; 123 | if (actualNewPixels != oldPixels) { 124 | forcePixels(actualNewPixels); 125 | didUpdateScrollPositionBy(actualNewPixels - oldPixels); 126 | } 127 | return overScroll; 128 | } 129 | 130 | /// 当手指滑动时,该方法会获取到滑动距离。 131 | /// 132 | /// [delta] 滑动距离,正增量表示下滑,负增量向上滑。 133 | /// 134 | /// 我们需要把子部件的滑动数据交给协调器处理,主部件无干扰。 135 | @override 136 | void applyUserOffset(double delta) { 137 | final ScrollDirection userScrollDirection = 138 | delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse; 139 | if (debugLabel != coordinator.pageLabel) { 140 | return coordinator.applyUserOffset(delta, userScrollDirection, this); 141 | } 142 | updateUserScrollDirection(userScrollDirection); 143 | setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta)); 144 | } 145 | 146 | @override 147 | void beginActivity(ScrollActivity newActivity) { 148 | _heldPreviousVelocity = 0.0; 149 | if (newActivity == null) { 150 | return; 151 | } 152 | assert(newActivity.delegate == this); 153 | super.beginActivity(newActivity); 154 | _currentDrag?.dispose(); 155 | _currentDrag = null; 156 | if (!activity.isScrolling) { 157 | updateUserScrollDirection(ScrollDirection.idle); 158 | } 159 | } 160 | 161 | /// 将用户滚动方向设置为给定值。 162 | /// 如果更改了该值,则将分派 [User ScrollNotification]。 163 | @protected 164 | @visibleForTesting 165 | void updateUserScrollDirection(ScrollDirection value) { 166 | assert(value != null); 167 | if (userScrollDirection == value) { 168 | return; 169 | } 170 | _userScrollDirection = value; 171 | didUpdateScrollDirection(value); 172 | } 173 | 174 | @override 175 | ScrollHoldController hold(VoidCallback holdCancelCallback) { 176 | final double previousVelocity = activity.velocity; 177 | final HoldScrollActivity holdActivity = HoldScrollActivity( 178 | delegate: this, 179 | onHoldCanceled: holdCancelCallback, 180 | ); 181 | beginActivity(holdActivity); 182 | _heldPreviousVelocity = previousVelocity; 183 | return holdActivity; 184 | } 185 | 186 | @override 187 | Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { 188 | final ScrollDragController drag = ScrollDragController( 189 | delegate: this, 190 | details: details, 191 | onDragCanceled: dragCancelCallback, 192 | carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity), 193 | motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold, 194 | ); 195 | beginActivity(DragScrollActivity(this, drag)); 196 | assert(_currentDrag == null); 197 | _currentDrag = drag; 198 | return drag; 199 | } 200 | 201 | @override 202 | void goIdle() { 203 | beginActivity(IdleScrollActivity(this)); 204 | } 205 | 206 | /// 以特定的速度开始一个物理驱动的模拟,该模拟确定 [pixels] 位置。 207 | /// 此方法遵从 [ScrollPhysics.createBallisticSimulation],该方法通常在当前位置超出 208 | /// 范围时提供滑动模拟,而在当前位置超出范围但具有非零速度时提供摩擦模拟。 209 | /// 速度应以逻辑像素/秒为单位。 210 | @override 211 | void goBallistic(double velocity, [bool fromCoordinator = false]) { 212 | if (debugLabel != coordinator.pageLabel) { 213 | if (velocity > 0.0) { 214 | coordinator.goBallistic(velocity); 215 | } 216 | } else { 217 | if (fromCoordinator && velocity <= 0.0) { 218 | return; 219 | } 220 | if (coordinator.pageExpand == PageExpandState.Expanding) { 221 | return; 222 | } 223 | } 224 | assert(pixels != null); 225 | final Simulation simulation = 226 | physics.createBallisticSimulation(this, velocity); 227 | if (simulation != null) { 228 | beginActivity(BallisticScrollActivity(this, simulation, context.vsync)); 229 | } else { 230 | goIdle(); 231 | } 232 | } 233 | 234 | @override 235 | bool applyContentDimensions(double minScrollExtent, double maxScrollExtent, 236 | [bool fromCoordinator = false]) { 237 | if (debugLabel == coordinator.pageLabel && !fromCoordinator) 238 | return coordinator.applyContentDimensions( 239 | minScrollExtent, maxScrollExtent, this); 240 | return super.applyContentDimensions(minScrollExtent, maxScrollExtent); 241 | } 242 | 243 | @override 244 | ScrollDirection get userScrollDirection => _userScrollDirection; 245 | ScrollDirection _userScrollDirection = ScrollDirection.idle; 246 | 247 | @override 248 | Future animateTo( 249 | double to, { 250 | @required Duration duration, 251 | @required Curve curve, 252 | }) { 253 | if (nearEqual(to, pixels, physics.tolerance.distance)) { 254 | // 跳过动画,直接移到我们已经靠近的位置。 255 | jumpTo(to); 256 | return Future.value(); 257 | } 258 | 259 | final DrivenScrollActivity activity = DrivenScrollActivity( 260 | this, 261 | from: pixels, 262 | to: to, 263 | duration: duration, 264 | curve: curve, 265 | vsync: context.vsync, 266 | ); 267 | beginActivity(activity); 268 | return activity.done; 269 | } 270 | 271 | @override 272 | void jumpTo(double value) { 273 | goIdle(); 274 | if (pixels != value) { 275 | final double oldPixels = pixels; 276 | forcePixels(value); 277 | notifyListeners(); 278 | didStartScroll(); 279 | didUpdateScrollPositionBy(pixels - oldPixels); 280 | didEndScroll(); 281 | } 282 | goBallistic(0.0); 283 | } 284 | 285 | @Deprecated('This will lead to bugs.') 286 | @override 287 | void jumpToWithoutSettling(double value) { 288 | goIdle(); 289 | if (pixels != value) { 290 | final double oldPixels = pixels; 291 | forcePixels(value); 292 | notifyListeners(); 293 | didStartScroll(); 294 | didUpdateScrollPositionBy(pixels - oldPixels); 295 | didEndScroll(); 296 | } 297 | } 298 | 299 | @override 300 | void dispose() { 301 | _currentDrag?.dispose(); 302 | _currentDrag = null; 303 | super.dispose(); 304 | } 305 | 306 | @override 307 | void debugFillDescription(List description) { 308 | super.debugFillDescription(description); 309 | description.add('${context.runtimeType}'); 310 | description.add('$physics'); 311 | description.add('$activity'); 312 | description.add('$userScrollDirection'); 313 | } 314 | } 315 | --------------------------------------------------------------------------------