1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
// All values eyeballed.
const double _kScrollbarMinLength = 36.0;
const double _kScrollbarMinOverscrollLength = 8.0;
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250);
const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100);
// Extracted from iOS 13.1 beta using Debug View Hierarchy.
const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness(
color: Color(0x59000000),
darkColor: Color(0x80FFFFFF),
);
// This is the amount of space from the top of a vertical scrollbar to the
// top edge of the scrollable, measured when the vertical scrollbar overscrolls
// to the top.
// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175
const double _kScrollbarMainAxisMargin = 3.0;
const double _kScrollbarCrossAxisMargin = 3.0;
/// An iOS style scrollbar.
///
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
/// a [CupertinoScrollbar] widget.
///
/// {@macro flutter.widgets.Scrollbar}
///
/// When dragging a [CupertinoScrollbar] thumb, the thickness and radius will
/// animate from [thickness] and [radius] to [thicknessWhileDragging] and
/// [radiusWhileDragging], respectively.
///
/// {@tool dartpad --template=stateless_widget_scaffold}
/// This sample shows a [CupertinoScrollbar] that fades in and out of view as scrolling occurs.
/// The scrollbar will fade into view as the user scrolls, and fade out when scrolling stops.
/// The `thickness` of the scrollbar will animate from 6 pixels to the `thicknessWhileDragging` of 10
/// when it is dragged by the user. The `radius` of the scrollbar thumb corners will animate from 34
/// to the `radiusWhileDragging` of 0 when the scrollbar is being dragged by the user.
///
/// ```dart imports
/// import 'package:flutter/cupertino.dart';
/// ```
///
/// ```dart
/// @override
/// Widget build(BuildContext context) {
/// return CupertinoScrollbar(
/// thickness: 6.0,
/// thicknessWhileDragging: 10.0,
/// radius: const Radius.circular(34.0),
/// radiusWhileDragging: Radius.zero,
/// child: ListView.builder(
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) {
/// return Center(
/// child: Text('item $index'),
/// );
/// },
/// ),
/// );
/// }
/// ```
/// {@end-tool}
///
/// {@tool dartpad --template=stateful_widget_scaffold}
/// When `isAlwaysShown` is true, the scrollbar thumb will remain visible without the
/// fade animation. This requires that a [ScrollController] is provided to controller,
/// or that the [PrimaryScrollController] is available.
///
/// ```dart imports
/// import 'package:flutter/cupertino.dart';
/// ```
///
/// ```dart
/// final ScrollController _controllerOne = ScrollController();
///
/// @override
/// Widget build(BuildContext context) {
/// return CupertinoScrollbar(
/// thickness: 6.0,
/// thicknessWhileDragging: 10.0,
/// radius: const Radius.circular(34.0),
/// radiusWhileDragging: Radius.zero,
/// controller: _controllerOne,
/// isAlwaysShown: true,
/// child: ListView.builder(
/// controller: _controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) {
/// return Center(
/// child: Text('item $index'),
/// );
/// },
/// ),
/// );
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [ListView], which displays a linear, scrollable list of children.
/// * [GridView], which displays a 2 dimensional, scrollable array of children.
/// * [Scrollbar], a Material Design scrollbar.
/// * [RawScrollbar], a basic scrollbar that fades in and out, extended
/// by this class to add more animations and behaviors.
class CupertinoScrollbar extends RawScrollbar {
/// Creates an iOS style scrollbar that wraps the given [child].
///
/// The [child] should be a source of [ScrollNotification] notifications,
/// typically a [Scrollable] widget.
const CupertinoScrollbar({
Key? key,
required Widget child,
ScrollController? controller,
bool isAlwaysShown = false,
double thickness = defaultThickness,
this.thicknessWhileDragging = defaultThicknessWhileDragging,
Radius radius = defaultRadius,
this.radiusWhileDragging = defaultRadiusWhileDragging,
ScrollNotificationPredicate? notificationPredicate,
ScrollbarOrientation? scrollbarOrientation,
}) : assert(thickness != null),
assert(thickness < double.infinity),
assert(thicknessWhileDragging != null),
assert(thicknessWhileDragging < double.infinity),
assert(radius != null),
assert(radiusWhileDragging != null),
super(
key: key,
child: child,
controller: controller,
isAlwaysShown: isAlwaysShown,
thickness: thickness,
radius: radius,
fadeDuration: _kScrollbarFadeDuration,
timeToFade: _kScrollbarTimeToFade,
pressDuration: const Duration(milliseconds: 100),
notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate,
scrollbarOrientation: scrollbarOrientation,
);
/// Default value for [thickness] if it's not specified in [CupertinoScrollbar].
static const double defaultThickness = 3;
/// Default value for [thicknessWhileDragging] if it's not specified in
/// [CupertinoScrollbar].
static const double defaultThicknessWhileDragging = 8.0;
/// Default value for [radius] if it's not specified in [CupertinoScrollbar].
static const Radius defaultRadius = Radius.circular(1.5);
/// Default value for [radiusWhileDragging] if it's not specified in
/// [CupertinoScrollbar].
static const Radius defaultRadiusWhileDragging = Radius.circular(4.0);
/// The thickness of the scrollbar when it's being dragged by the user.
///
/// When the user starts dragging the scrollbar, the thickness will animate
/// from [thickness] to this value, then animate back when the user stops
/// dragging the scrollbar.
final double thicknessWhileDragging;
/// The radius of the scrollbar edges when the scrollbar is being dragged by
/// the user.
///
/// When the user starts dragging the scrollbar, the radius will animate
/// from [radius] to this value, then animate back when the user stops
/// dragging the scrollbar.
final Radius radiusWhileDragging;
@override
RawScrollbarState<CupertinoScrollbar> createState() => _CupertinoScrollbarState();
}
class _CupertinoScrollbarState extends RawScrollbarState<CupertinoScrollbar> {
late AnimationController _thicknessAnimationController;
double get _thickness {
return widget.thickness! + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness!);
}
Radius get _radius {
return Radius.lerp(widget.radius, widget.radiusWhileDragging, _thicknessAnimationController.value)!;
}
@override
void initState() {
super.initState();
_thicknessAnimationController = AnimationController(
vsync: this,
duration: _kScrollbarResizeDuration,
);
_thicknessAnimationController.addListener(() {
updateScrollbarPainter();
});
}
@override
void updateScrollbarPainter() {
scrollbarPainter
..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context)
..textDirection = Directionality.of(context)
..thickness = _thickness
..mainAxisMargin = _kScrollbarMainAxisMargin
..crossAxisMargin = _kScrollbarCrossAxisMargin
..radius = _radius
..padding = MediaQuery.of(context).padding
..minLength = _kScrollbarMinLength
..minOverscrollLength = _kScrollbarMinOverscrollLength
..scrollbarOrientation = widget.scrollbarOrientation;
}
double _pressStartAxisPosition = 0.0;
// Long press event callbacks handle the gesture where the user long presses
// on the scrollbar thumb and then drags the scrollbar without releasing.
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
final Axis direction = getScrollbarDirection()!;
switch (direction) {
case Axis.vertical:
_pressStartAxisPosition = localPosition.dy;
break;
case Axis.horizontal:
_pressStartAxisPosition = localPosition.dx;
break;
}
}
@override
void handleThumbPress() {
if (getScrollbarDirection() == null) {
return;
}
super.handleThumbPress();
_thicknessAnimationController.forward().then<void>(
(_) => HapticFeedback.mediumImpact(),
);
}
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
_thicknessAnimationController.reverse();
super.handleThumbPressEnd(localPosition, velocity);
switch(direction) {
case Axis.vertical:
if (velocity.pixelsPerSecond.dy.abs() < 10 &&
(localPosition.dy - _pressStartAxisPosition).abs() > 0) {
HapticFeedback.mediumImpact();
}
break;
case Axis.horizontal:
if (velocity.pixelsPerSecond.dx.abs() < 10 &&
(localPosition.dx - _pressStartAxisPosition).abs() > 0) {
HapticFeedback.mediumImpact();
}
break;
}
}
@override
void dispose() {
_thicknessAnimationController.dispose();
super.dispose();
}
}