drawnui-react 0.1.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PARITY.md +287 -0
- package/README.md +92 -0
- package/SKIPPED.md +107 -0
- package/dist/controls/AnimatedFramesRenderer.d.ts +58 -0
- package/dist/controls/AnimatedFramesRenderer.js +133 -0
- package/dist/controls/GridStructure.d.ts +79 -0
- package/dist/controls/GridStructure.js +403 -0
- package/dist/controls/RefreshIndicator.d.ts +35 -0
- package/dist/controls/RefreshIndicator.js +82 -0
- package/dist/controls/SkiaBackdrop.d.ts +25 -0
- package/dist/controls/SkiaBackdrop.js +80 -0
- package/dist/controls/SkiaButton.d.ts +58 -0
- package/dist/controls/SkiaButton.js +148 -0
- package/dist/controls/SkiaCarousel.d.ts +133 -0
- package/dist/controls/SkiaCarousel.js +603 -0
- package/dist/controls/SkiaCheckbox.d.ts +21 -0
- package/dist/controls/SkiaCheckbox.js +118 -0
- package/dist/controls/SkiaDecoratedGrid.d.ts +15 -0
- package/dist/controls/SkiaDecoratedGrid.js +46 -0
- package/dist/controls/SkiaDrawer.d.ts +46 -0
- package/dist/controls/SkiaDrawer.js +212 -0
- package/dist/controls/SkiaDynamicDrawnCell.d.ts +11 -0
- package/dist/controls/SkiaDynamicDrawnCell.js +17 -0
- package/dist/controls/SkiaEditor.d.ts +163 -0
- package/dist/controls/SkiaEditor.js +803 -0
- package/dist/controls/SkiaGif.d.ts +54 -0
- package/dist/controls/SkiaGif.js +206 -0
- package/dist/controls/SkiaHotspot.d.ts +13 -0
- package/dist/controls/SkiaHotspot.js +36 -0
- package/dist/controls/SkiaImage.d.ts +75 -0
- package/dist/controls/SkiaImage.js +222 -0
- package/dist/controls/SkiaImageTiles.d.ts +27 -0
- package/dist/controls/SkiaImageTiles.js +76 -0
- package/dist/controls/SkiaLabel.d.ts +160 -0
- package/dist/controls/SkiaLabel.js +580 -0
- package/dist/controls/SkiaLayout.d.ts +163 -0
- package/dist/controls/SkiaLayout.js +723 -0
- package/dist/controls/SkiaLottie.d.ts +75 -0
- package/dist/controls/SkiaLottie.js +290 -0
- package/dist/controls/SkiaProgress.d.ts +40 -0
- package/dist/controls/SkiaProgress.js +80 -0
- package/dist/controls/SkiaRadioButton.d.ts +32 -0
- package/dist/controls/SkiaRadioButton.js +157 -0
- package/dist/controls/SkiaRichLabel.d.ts +77 -0
- package/dist/controls/SkiaRichLabel.js +274 -0
- package/dist/controls/SkiaScroll.d.ts +231 -0
- package/dist/controls/SkiaScroll.js +958 -0
- package/dist/controls/SkiaScrollBar.d.ts +56 -0
- package/dist/controls/SkiaScrollBar.js +158 -0
- package/dist/controls/SkiaShaderCarousel.d.ts +67 -0
- package/dist/controls/SkiaShaderCarousel.js +246 -0
- package/dist/controls/SkiaShape.d.ts +63 -0
- package/dist/controls/SkiaShape.js +317 -0
- package/dist/controls/SkiaSlider.d.ts +74 -0
- package/dist/controls/SkiaSlider.js +243 -0
- package/dist/controls/SkiaSprite.d.ts +99 -0
- package/dist/controls/SkiaSprite.js +324 -0
- package/dist/controls/SkiaSpriteSet.d.ts +27 -0
- package/dist/controls/SkiaSpriteSet.js +75 -0
- package/dist/controls/SkiaSvg.d.ts +43 -0
- package/dist/controls/SkiaSvg.js +181 -0
- package/dist/controls/SkiaSwitch.d.ts +26 -0
- package/dist/controls/SkiaSwitch.js +147 -0
- package/dist/controls/SkiaToggle.d.ts +50 -0
- package/dist/controls/SkiaToggle.js +71 -0
- package/dist/controls/SnappingLayout.d.ts +78 -0
- package/dist/controls/SnappingLayout.js +217 -0
- package/dist/controls/TextSpan.d.ts +70 -0
- package/dist/controls/TextSpan.js +76 -0
- package/dist/core/Accessibility.d.ts +104 -0
- package/dist/core/Accessibility.js +150 -0
- package/dist/core/Animators.d.ts +82 -0
- package/dist/core/Animators.js +247 -0
- package/dist/core/Canvas.d.ts +89 -0
- package/dist/core/Canvas.js +328 -0
- package/dist/core/ControlStyle.d.ts +6 -0
- package/dist/core/ControlStyle.js +11 -0
- package/dist/core/Easing.d.ts +13 -0
- package/dist/core/Easing.js +15 -0
- package/dist/core/Gestures.d.ts +74 -0
- package/dist/core/Gestures.js +104 -0
- package/dist/core/ImageEffects.d.ts +29 -0
- package/dist/core/ImageEffects.js +55 -0
- package/dist/core/KeyboardManager.d.ts +25 -0
- package/dist/core/KeyboardManager.js +54 -0
- package/dist/core/ScrollAnimators.d.ts +90 -0
- package/dist/core/ScrollAnimators.js +244 -0
- package/dist/core/SkiaControl.d.ts +401 -0
- package/dist/core/SkiaControl.js +1239 -0
- package/dist/core/SkiaEffect.d.ts +216 -0
- package/dist/core/SkiaEffect.js +571 -0
- package/dist/core/SkiaImageManager.d.ts +43 -0
- package/dist/core/SkiaImageManager.js +122 -0
- package/dist/core/Super.d.ts +61 -0
- package/dist/core/Super.js +178 -0
- package/dist/core/TextInputProxy.d.ts +31 -0
- package/dist/core/TextInputProxy.js +144 -0
- package/dist/core/Types.d.ts +177 -0
- package/dist/core/Types.js +122 -0
- package/dist/core/ViewsAdapter.d.ts +41 -0
- package/dist/core/ViewsAdapter.js +113 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +48 -0
- package/dist/react/SkiaShell.d.ts +144 -0
- package/dist/react/SkiaShell.js +490 -0
- package/dist/react/index.d.ts +110 -0
- package/dist/react/index.js +158 -0
- package/dist/react/reconciler.d.ts +14 -0
- package/dist/react/reconciler.js +160 -0
- package/package.json +60 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import { SkiaControl } from "../core/SkiaControl";
|
|
2
|
+
import { Super } from "../core/Super";
|
|
3
|
+
import { SKRect, ScaledSize } from "../core/Types";
|
|
4
|
+
import { SkiaLayout } from "./SkiaLayout";
|
|
5
|
+
import { SkiaScrollBar } from "./SkiaScrollBar";
|
|
6
|
+
import { SKPoint } from "../core/Gestures";
|
|
7
|
+
import { RubberBandUtils, ScrollFlingAnimator, Spring, SpringWithVelocityAnimator, VelocityAccumulator, } from "../core/ScrollAnimators";
|
|
8
|
+
/**
|
|
9
|
+
* Mirrors DrawnUi SkiaScroll (plain content, no header/footer/refresh/virtualization yet).
|
|
10
|
+
* Offsets are in POINTS and <= 0 while inside bounds (content moves up/left as you scroll).
|
|
11
|
+
* Physics ported 1:1: deceleration fling (rate = 1 - FrictionScrolled/100) cut at the content edge,
|
|
12
|
+
* rubber-band overscroll while dragging, spring bounce back on release, wheel = WheelLineSize per notch.
|
|
13
|
+
*/
|
|
14
|
+
export class SkiaScroll extends SkiaControl {
|
|
15
|
+
static WheelLineSize = 150;
|
|
16
|
+
static ThesholdSwipeOnUp = 20;
|
|
17
|
+
static ScrollVelocityThreshold = 5;
|
|
18
|
+
Orientation = "Vertical";
|
|
19
|
+
Bounces = true;
|
|
20
|
+
RubberDamping = 0.55;
|
|
21
|
+
RubberEffect = 0.55;
|
|
22
|
+
/** 0.1..1, lower = longer fling. */
|
|
23
|
+
FrictionScrolled = 0.3;
|
|
24
|
+
ChangeVelocityScrolled = 1.33;
|
|
25
|
+
ChangeDistancePanned = 1;
|
|
26
|
+
MaxVelocity = 3000;
|
|
27
|
+
MaxBounceVelocity = 500;
|
|
28
|
+
AutoScrollingSpeedMs = 600;
|
|
29
|
+
ScrollingSpeedMs = 400;
|
|
30
|
+
IgnoreWrongDirection = false;
|
|
31
|
+
RespondsToGestures = true;
|
|
32
|
+
// ---- header / footer (C# SkiaScroll.Header / Footer) ----
|
|
33
|
+
header;
|
|
34
|
+
footer;
|
|
35
|
+
/** A view before the content along the scroll axis; `Tag="Header"` on a JSX child sets it. */
|
|
36
|
+
get Header() { return this.header; }
|
|
37
|
+
set Header(v) { if (this.header === v)
|
|
38
|
+
return; if (this.header)
|
|
39
|
+
this.header.Parent = undefined; this.header = v; if (v) {
|
|
40
|
+
v.Parent = this;
|
|
41
|
+
v.ZIndex = 1;
|
|
42
|
+
} this.InvalidateMeasure(); }
|
|
43
|
+
/** A view after the content along the scroll axis; `Tag="Footer"` on a JSX child sets it. */
|
|
44
|
+
get Footer() { return this.footer; }
|
|
45
|
+
set Footer(v) { if (this.footer === v)
|
|
46
|
+
return; if (this.footer)
|
|
47
|
+
this.footer.Parent = undefined; this.footer = v; if (v)
|
|
48
|
+
v.Parent = this; this.InvalidateMeasure(); }
|
|
49
|
+
/** The header stays at the viewport start while the content scrolls under it (drawn over the content unless HeaderBehind). */
|
|
50
|
+
HeaderSticky = false;
|
|
51
|
+
/** The header is drawn behind the content (the content covers it as it scrolls, parallax covers). */
|
|
52
|
+
HeaderBehind = false;
|
|
53
|
+
/** 1 = the header scrolls with the content, 0.5 = at half speed (parallax), 0 = stays. */
|
|
54
|
+
HeaderParallaxRatio = 1;
|
|
55
|
+
/** Apply the parallax while overscrolling at the start too (false: the header follows the content there). */
|
|
56
|
+
ParallaxOverscrollEnabled = true;
|
|
57
|
+
/** Extra points between a HeaderBehind / HeaderSticky header and the content (C# ContentOffset). */
|
|
58
|
+
ContentOffset = 0;
|
|
59
|
+
headerSize = ScaledSize.Default;
|
|
60
|
+
footerSize = ScaledSize.Default;
|
|
61
|
+
// ---- scroll bars (C# ScrollBar / ScrollBarHorizontal / ScrollBarsVisibility) ----
|
|
62
|
+
scrollBar;
|
|
63
|
+
scrollBarHorizontal;
|
|
64
|
+
scrollBarsVisibility = "None";
|
|
65
|
+
/** Creates default SkiaScrollBar overlays for the given axes (matched against Orientation). */
|
|
66
|
+
get ScrollBarsVisibility() { return this.scrollBarsVisibility; }
|
|
67
|
+
set ScrollBarsVisibility(v) { if (this.scrollBarsVisibility === v)
|
|
68
|
+
return; this.scrollBarsVisibility = v; this.ApplyScrollBarsVisibility(); }
|
|
69
|
+
/** Custom vertical bar (IScrollBar control); `Tag="ScrollBar"` on a JSX child sets it. */
|
|
70
|
+
get ScrollBar() { return this.scrollBar; }
|
|
71
|
+
set ScrollBar(v) { if (this.scrollBar === v)
|
|
72
|
+
return; if (this.scrollBar) {
|
|
73
|
+
this.scrollBar.Parent = undefined;
|
|
74
|
+
this.scrollBar.Dispose();
|
|
75
|
+
} this.scrollBar = v; if (v) {
|
|
76
|
+
v.Parent = this;
|
|
77
|
+
this.ApplyScrollBarColors(v);
|
|
78
|
+
} this.scrollBarLast = undefined; this.Repaint(); }
|
|
79
|
+
get ScrollBarHorizontal() { return this.scrollBarHorizontal; }
|
|
80
|
+
set ScrollBarHorizontal(v) { if (this.scrollBarHorizontal === v)
|
|
81
|
+
return; if (this.scrollBarHorizontal) {
|
|
82
|
+
this.scrollBarHorizontal.Parent = undefined;
|
|
83
|
+
this.scrollBarHorizontal.Dispose();
|
|
84
|
+
} this.scrollBarHorizontal = v; if (v) {
|
|
85
|
+
v.Parent = this;
|
|
86
|
+
this.ApplyScrollBarColors(v);
|
|
87
|
+
} this.scrollBarHLast = undefined; this.Repaint(); }
|
|
88
|
+
scrollBarThumbColor;
|
|
89
|
+
scrollBarTrackColor;
|
|
90
|
+
/** Colors applied to the default SkiaScrollBar (C# ScrollBarThumbColor / ScrollBarTrackColor). */
|
|
91
|
+
get ScrollBarThumbColor() { return this.scrollBarThumbColor; }
|
|
92
|
+
set ScrollBarThumbColor(v) { this.scrollBarThumbColor = v; if (this.scrollBar)
|
|
93
|
+
this.ApplyScrollBarColors(this.scrollBar); if (this.scrollBarHorizontal)
|
|
94
|
+
this.ApplyScrollBarColors(this.scrollBarHorizontal); }
|
|
95
|
+
get ScrollBarTrackColor() { return this.scrollBarTrackColor; }
|
|
96
|
+
set ScrollBarTrackColor(v) { this.scrollBarTrackColor = v; if (this.scrollBar)
|
|
97
|
+
this.ApplyScrollBarColors(this.scrollBar); if (this.scrollBarHorizontal)
|
|
98
|
+
this.ApplyScrollBarColors(this.scrollBarHorizontal); }
|
|
99
|
+
scrollBarLast;
|
|
100
|
+
scrollBarHLast;
|
|
101
|
+
ApplyScrollBarColors(bar) {
|
|
102
|
+
if (!(bar instanceof SkiaScrollBar))
|
|
103
|
+
return;
|
|
104
|
+
if (this.scrollBarThumbColor)
|
|
105
|
+
bar.ThumbColor = this.scrollBarThumbColor;
|
|
106
|
+
if (this.scrollBarTrackColor)
|
|
107
|
+
bar.TrackColor = this.scrollBarTrackColor;
|
|
108
|
+
}
|
|
109
|
+
/** C# ApplyScrollBarsVisibility: a default bar per wanted axis, none otherwise. */
|
|
110
|
+
ApplyScrollBarsVisibility() {
|
|
111
|
+
const wantV = this.scrollBarsVisibility === "Vertical" || this.scrollBarsVisibility === "Both";
|
|
112
|
+
const wantH = this.scrollBarsVisibility === "Horizontal" || this.scrollBarsVisibility === "Both";
|
|
113
|
+
if (wantV && !this.scrollBar)
|
|
114
|
+
this.ScrollBar = new SkiaScrollBar();
|
|
115
|
+
else if (!wantV && this.scrollBar instanceof SkiaScrollBar)
|
|
116
|
+
this.ScrollBar = undefined;
|
|
117
|
+
if (wantH && !this.scrollBarHorizontal)
|
|
118
|
+
this.ScrollBarHorizontal = new SkiaScrollBar();
|
|
119
|
+
else if (!wantH && this.scrollBarHorizontal instanceof SkiaScrollBar)
|
|
120
|
+
this.ScrollBarHorizontal = undefined;
|
|
121
|
+
}
|
|
122
|
+
/** C# UpdateScrollBarIndicator: pushes progress / thumb ratio / overscroll / scrolling state when any changed. */
|
|
123
|
+
UpdateScrollBarIndicator() {
|
|
124
|
+
const isScrolling = this.IsScrolling || this.IsUserPanning;
|
|
125
|
+
const scale = this.RenderingScale;
|
|
126
|
+
const viewportH = this.DrawingRect.Height / scale, viewportW = this.DrawingRect.Width / scale;
|
|
127
|
+
const extentH = this.ContentSize.Units.Height + this.HeaderExtentPts + this.FooterExtentPts, extentW = this.ContentSize.Units.Width + this.HeaderExtentPts + this.FooterExtentPts;
|
|
128
|
+
if (this.scrollBar && this.Orientation !== "Horizontal") {
|
|
129
|
+
const min = this.ContentOffsetBounds.Top;
|
|
130
|
+
const progress = min !== 0 ? this.offsetY / min : 0;
|
|
131
|
+
const ratio = extentH > 0 ? viewportH / extentH : 1;
|
|
132
|
+
const key = `${progress.toFixed(4)}|${ratio.toFixed(4)}|${this.OverscrollDistance.Y.toFixed(1)}|${isScrolling}`;
|
|
133
|
+
if (key !== this.scrollBarLast) {
|
|
134
|
+
this.scrollBarLast = key;
|
|
135
|
+
this.scrollBar.SetScrollProgress("Vertical", progress, ratio, this.OverscrollDistance.Y, isScrolling);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (this.scrollBarHorizontal && this.Orientation !== "Vertical") {
|
|
139
|
+
const min = this.ContentOffsetBounds.Left;
|
|
140
|
+
const progress = min !== 0 ? this.offsetX / min : 0;
|
|
141
|
+
const ratio = extentW > 0 ? viewportW / extentW : 1;
|
|
142
|
+
const key = `${progress.toFixed(4)}|${ratio.toFixed(4)}|${this.OverscrollDistance.X.toFixed(1)}|${isScrolling}`;
|
|
143
|
+
if (key !== this.scrollBarHLast) {
|
|
144
|
+
this.scrollBarHLast = key;
|
|
145
|
+
this.scrollBarHorizontal.SetScrollProgress("Horizontal", progress, ratio, this.OverscrollDistance.X, isScrolling);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ---- pull to refresh (C# RefreshEnabled / RefreshIndicator / RefreshCommand) ----
|
|
150
|
+
RefreshEnabled = false;
|
|
151
|
+
/** Called when the pull passed RefreshDistanceLimit (C# RefreshCommand); set IsRefreshing = false when done. */
|
|
152
|
+
RefreshCommand;
|
|
153
|
+
/** Pull distance (points) that triggers the refresh. */
|
|
154
|
+
RefreshDistanceLimit = 150;
|
|
155
|
+
/** Distance (points) where the indicator stops moving and stays while refreshing. */
|
|
156
|
+
RefreshShowDistance = 50;
|
|
157
|
+
refreshIndicator;
|
|
158
|
+
/** The pull-to-refresh view (IRefreshIndicator control); `Tag="RefreshIndicator"` on a JSX child sets it. */
|
|
159
|
+
get RefreshIndicator() { return this.refreshIndicator; }
|
|
160
|
+
set RefreshIndicator(v) { if (this.refreshIndicator === v)
|
|
161
|
+
return; if (this.refreshIndicator)
|
|
162
|
+
this.refreshIndicator.Parent = undefined; this.refreshIndicator = v; if (v)
|
|
163
|
+
v.Parent = this; this.InvalidateMeasure(); }
|
|
164
|
+
isRefreshing = false;
|
|
165
|
+
wasRefreshing = false;
|
|
166
|
+
scrollLocked = false;
|
|
167
|
+
get IsRefreshing() { return this.isRefreshing; }
|
|
168
|
+
set IsRefreshing(v) { if (this.isRefreshing === v)
|
|
169
|
+
return; this.SetIsRefreshing(v, false); }
|
|
170
|
+
get UsingRefreshDistanceLimit() { return Math.max(this.RefreshDistanceLimit, this.RefreshShowDistance); }
|
|
171
|
+
/** C# SetIsRefreshing: true locks the scroll at RefreshShowDistance and runs RefreshCommand; false hides the indicator. */
|
|
172
|
+
SetIsRefreshing(state, initial) {
|
|
173
|
+
this.isRefreshing = state;
|
|
174
|
+
if (state) {
|
|
175
|
+
this.wasRefreshing = true;
|
|
176
|
+
this.scrollLocked = true;
|
|
177
|
+
this.ShowRefreshIndicatorForced();
|
|
178
|
+
this.RefreshCommand?.(this);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
this.scrollLocked = false;
|
|
182
|
+
if (initial || (this.offsetX === 0 && this.offsetY === 0))
|
|
183
|
+
this.HideRefreshIndicator();
|
|
184
|
+
else {
|
|
185
|
+
this.wasRefreshing = false;
|
|
186
|
+
this.ScrollTo(this.Orientation === "Horizontal" ? 0 : this.offsetX, this.Orientation === "Horizontal" ? this.offsetY : 0, this.AutoScrollingSpeedMs / 1000, false);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
this.Repaint();
|
|
190
|
+
}
|
|
191
|
+
HideRefreshIndicator() {
|
|
192
|
+
this.refreshIndicator?.SetDragRatio(0, 0, this.RefreshShowDistance, this.RefreshDistanceLimit);
|
|
193
|
+
this.scrollLocked = false;
|
|
194
|
+
this.wasRefreshing = false;
|
|
195
|
+
}
|
|
196
|
+
/** C# ShowRefreshIndicatorForced: parks the offset at RefreshShowDistance and shows the indicator fully. */
|
|
197
|
+
ShowRefreshIndicatorForced() {
|
|
198
|
+
const ind = this.refreshIndicator;
|
|
199
|
+
if (!ind)
|
|
200
|
+
return;
|
|
201
|
+
ind.IsVisible = true;
|
|
202
|
+
if (this.Orientation === "Horizontal") {
|
|
203
|
+
if (this.offsetX < this.RefreshShowDistance)
|
|
204
|
+
this.ViewportOffsetX = this.RefreshShowDistance;
|
|
205
|
+
ind.SetDragRatio(1, this.offsetX, this.RefreshShowDistance, this.RefreshDistanceLimit);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
if (this.offsetY < this.RefreshShowDistance)
|
|
209
|
+
this.ViewportOffsetY = this.RefreshShowDistance;
|
|
210
|
+
ind.SetDragRatio(1, this.offsetY, this.RefreshShowDistance, this.RefreshDistanceLimit);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** C# CheckNeedRefresh + ApplyScrollPositionToRefreshViewUnsafe: called from every scroll change. */
|
|
214
|
+
CheckNeedRefresh() {
|
|
215
|
+
const ind = this.refreshIndicator;
|
|
216
|
+
if (this.isRefreshing) {
|
|
217
|
+
if (ind && !ind.IsVisible)
|
|
218
|
+
this.ShowRefreshIndicatorForced();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!this.RefreshEnabled || !ind)
|
|
222
|
+
return;
|
|
223
|
+
const horizontal = this.Orientation === "Horizontal";
|
|
224
|
+
const over = horizontal ? this.OverscrollDistance.X : this.OverscrollDistance.Y;
|
|
225
|
+
const offset = horizontal ? this.offsetX : this.offsetY;
|
|
226
|
+
if (over > 0) {
|
|
227
|
+
const ratio = over / this.RefreshShowDistance;
|
|
228
|
+
ind.SetDragRatio(ratio, offset, this.RefreshShowDistance, this.RefreshDistanceLimit);
|
|
229
|
+
const canRefresh = offset > this.UsingRefreshDistanceLimit;
|
|
230
|
+
if (this.IsUserPanning && canRefresh && !this.isRefreshing && this.RefreshCommand && !this.wasRefreshing && !this.scrollLocked)
|
|
231
|
+
this.IsRefreshing = true;
|
|
232
|
+
}
|
|
233
|
+
else if (ind.IsVisible && !this.wasRefreshing)
|
|
234
|
+
this.HideRefreshIndicator();
|
|
235
|
+
else if (over <= 0 && this.wasRefreshing && !this.isRefreshing) {
|
|
236
|
+
this.wasRefreshing = false;
|
|
237
|
+
this.HideRefreshIndicator();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// ---- snapping / index tracking (C# SnapToChildren / TrackIndexPosition) ----
|
|
241
|
+
/** Snap to the child at the tracked position after scrolling stops: Center = centered in the viewport, Side = aligned to the viewport start (or end when TrackIndexPosition is End). */
|
|
242
|
+
SnapToChildren = "Disabled";
|
|
243
|
+
/** The viewport position whose child index is reported as CurrentIndex (None = off). */
|
|
244
|
+
TrackIndexPosition = "None";
|
|
245
|
+
/** Points added to the tracked position (C# TrackIndexPositionOffset). */
|
|
246
|
+
TrackIndexPositionOffset = 8;
|
|
247
|
+
/** Index of the content child at the tracked position, -1 when none. */
|
|
248
|
+
CurrentIndex = -1;
|
|
249
|
+
CurrentIndexChanged;
|
|
250
|
+
isSnapping = false;
|
|
251
|
+
snapped = false;
|
|
252
|
+
/** The content child covering the tracked viewport point (C# CurrentIndexHit): index, its rect and the point, canvas px. */
|
|
253
|
+
GetIndexHit(position) {
|
|
254
|
+
const layout = this.content instanceof SkiaLayout ? this.content : undefined;
|
|
255
|
+
if (!layout || position === "None")
|
|
256
|
+
return undefined;
|
|
257
|
+
const r = this.DrawingRect, scale = this.RenderingScale, off = this.TrackIndexPositionOffset * scale;
|
|
258
|
+
const horizontal = this.Orientation === "Horizontal";
|
|
259
|
+
const along = position === "Start" ? off : position === "End" ? (horizontal ? r.Width : r.Height) - off : (horizontal ? r.Width : r.Height) / 2;
|
|
260
|
+
const point = horizontal ? new SKPoint(r.Left + along, r.Top + r.Height / 2) : new SKPoint(r.Left + r.Width / 2, r.Top + along);
|
|
261
|
+
const views = layout.Views;
|
|
262
|
+
for (let i = 0; i < views.length; i++) {
|
|
263
|
+
const v = views[i], a = v.DrawingRect;
|
|
264
|
+
if (!v.IsVisible || a.Width <= 0 || a.Height <= 0)
|
|
265
|
+
continue;
|
|
266
|
+
const inside = horizontal ? point.X >= a.Left && point.X < a.Right : point.Y >= a.Top && point.Y < a.Bottom;
|
|
267
|
+
if (inside)
|
|
268
|
+
return { Index: v.ContextIndex >= 0 ? v.ContextIndex : i, Area: a, Point: point };
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
TrackIndex() {
|
|
273
|
+
if (this.TrackIndexPosition === "None")
|
|
274
|
+
return;
|
|
275
|
+
const hit = this.GetIndexHit(this.TrackIndexPosition);
|
|
276
|
+
const index = hit ? hit.Index : -1;
|
|
277
|
+
if (index !== this.CurrentIndex) {
|
|
278
|
+
this.CurrentIndex = index;
|
|
279
|
+
this.CurrentIndexChanged?.(this, index);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** C# CheckNeedToSnap: after the fling stopped, not panning, no bounce, no ordered scroll. */
|
|
283
|
+
CheckNeedToSnap() {
|
|
284
|
+
return !(this.isSnapping || this.snapped || this.IsUserFocused || this.SnapToChildren === "Disabled" || this.bounceX.IsRunning || this.bounceY.IsRunning || this.animatorFlingX.IsRunning || this.animatorFlingY.IsRunning);
|
|
285
|
+
}
|
|
286
|
+
/** C# Snap: scroll so the tracked child is centered / aligned to the side. */
|
|
287
|
+
Snap(maxTimeSecs) {
|
|
288
|
+
if (this.isSnapping)
|
|
289
|
+
return;
|
|
290
|
+
this.isSnapping = true;
|
|
291
|
+
try {
|
|
292
|
+
const position = this.SnapToChildren === "Center" ? "Center" : this.TrackIndexPosition === "End" ? "End" : "Start";
|
|
293
|
+
const hit = this.GetIndexHit(position);
|
|
294
|
+
if (!hit)
|
|
295
|
+
return;
|
|
296
|
+
const horizontal = this.Orientation === "Horizontal", scale = this.RenderingScale, r = this.DrawingRect, content = this.content;
|
|
297
|
+
// geometry from the last paint (child and content rects are consistent there): where the child's anchor sits from the content start
|
|
298
|
+
const contentStart = horizontal ? content.DrawingRect.Left : content.DrawingRect.Top;
|
|
299
|
+
const anchor = this.SnapToChildren === "Center" ? (horizontal ? hit.Area.Left + hit.Area.Width / 2 : hit.Area.Top + hit.Area.Height / 2) : position === "End" ? (horizontal ? hit.Area.Right : hit.Area.Bottom) : (horizontal ? hit.Area.Left : hit.Area.Top);
|
|
300
|
+
const relPts = (anchor - contentStart) / scale;
|
|
301
|
+
const pointPts = ((horizontal ? hit.Point.X - r.Left : hit.Point.Y - r.Top)) / scale;
|
|
302
|
+
const target = pointPts - this.HeaderExtentPts - relPts; // content start + header + rel must land on the tracked point
|
|
303
|
+
const current = horizontal ? this.offsetX : this.offsetY;
|
|
304
|
+
if (Math.abs(target - current) * scale <= scale * 2)
|
|
305
|
+
return;
|
|
306
|
+
this.snapped = true;
|
|
307
|
+
this.StopAnimators();
|
|
308
|
+
this.ScrollTo(horizontal ? target : this.offsetX, horizontal ? this.offsetY : target, maxTimeSecs, true);
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
this.isSnapping = false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
get HeaderExtentPts() {
|
|
315
|
+
if (!this.header)
|
|
316
|
+
return 0;
|
|
317
|
+
const size = this.Orientation === "Horizontal" ? this.headerSize.Units.Width : this.headerSize.Units.Height;
|
|
318
|
+
return size + (this.HeaderBehind || this.HeaderSticky ? this.ContentOffset : 0);
|
|
319
|
+
}
|
|
320
|
+
get FooterExtentPts() { return this.footer ? (this.Orientation === "Horizontal" ? this.footerSize.Units.Width : this.footerSize.Units.Height) : 0; }
|
|
321
|
+
/** Offset changed (drag, fling, bounce, ScrollTo). */
|
|
322
|
+
Scrolled;
|
|
323
|
+
// ---- LoadMore (DrawnUi SkiaScroll.LoadMoreCommand / LoadMoreTopCommand, callbacks instead of ICommand) ----
|
|
324
|
+
/** Called when scrolling within LoadMoreOffset points of the end (bottom / right), or when the content underfills the viewport. */
|
|
325
|
+
LoadMoreCommand;
|
|
326
|
+
/** Called when scrolling within LoadMoreTopOffset points of the start (top / left). */
|
|
327
|
+
LoadMoreTopCommand;
|
|
328
|
+
LoadMoreOffset = 0;
|
|
329
|
+
LoadMoreTopOffset = 0;
|
|
330
|
+
loadMoreBottomAt = NaN; // content extent (points) when the bottom command last fired; NaN = armed
|
|
331
|
+
loadMoreBottomTime = 0;
|
|
332
|
+
/** Top fires only after the user has actually left the top edge and comes back (a fresh list at offset 0 is not a "reached the top"). */
|
|
333
|
+
loadMoreTopArmed = false;
|
|
334
|
+
/** C# CheckNeedToLoadMore: fires once per content extent near an edge; re-arms when the content grows or the user moves away (>offset+100pt, >2 s). */
|
|
335
|
+
CheckLoadMore() {
|
|
336
|
+
const vertical = this.Orientation !== "Horizontal";
|
|
337
|
+
const offset = vertical ? this.offsetY : this.offsetX;
|
|
338
|
+
const min = vertical ? this.ContentOffsetBounds.Top : this.ContentOffsetBounds.Left; // most negative offset
|
|
339
|
+
const extent = vertical ? this.ContentSize.Units.Height : this.ContentSize.Units.Width;
|
|
340
|
+
const now = performance.now();
|
|
341
|
+
if (this.LoadMoreCommand) {
|
|
342
|
+
const underfills = min >= 0; // nothing to scroll: keep paging until the viewport is filled
|
|
343
|
+
if (!isNaN(this.loadMoreBottomAt) && this.loadMoreBottomAt !== extent)
|
|
344
|
+
this.loadMoreBottomAt = NaN;
|
|
345
|
+
else if (!isNaN(this.loadMoreBottomAt) && !underfills && offset - min > this.LoadMoreOffset + 100 && now - this.loadMoreBottomTime > 2000)
|
|
346
|
+
this.loadMoreBottomAt = NaN;
|
|
347
|
+
if (isNaN(this.loadMoreBottomAt) && (underfills || offset <= min + this.LoadMoreOffset)) {
|
|
348
|
+
this.loadMoreBottomAt = extent;
|
|
349
|
+
this.loadMoreBottomTime = now;
|
|
350
|
+
this.LoadMoreCommand(this);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (this.LoadMoreTopCommand) {
|
|
354
|
+
if (-offset > this.LoadMoreTopOffset + 100)
|
|
355
|
+
this.loadMoreTopArmed = true;
|
|
356
|
+
else if (this.loadMoreTopArmed && min < 0 && offset >= -this.LoadMoreTopOffset) {
|
|
357
|
+
this.loadMoreTopArmed = false;
|
|
358
|
+
this.LoadMoreTopCommand(this);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
IsUserPanning = false;
|
|
363
|
+
IsUserFocused = false;
|
|
364
|
+
IsScrolling = false;
|
|
365
|
+
ContentSize = ScaledSize.Default;
|
|
366
|
+
/** Points: Left/Top = most negative allowed offset, Right/Bottom = 0. */
|
|
367
|
+
ContentOffsetBounds = SKRect.Empty;
|
|
368
|
+
OverscrollDistance = SKPoint.Empty;
|
|
369
|
+
get OverScrolled() { return this.OverscrollDistance.X !== 0 || this.OverscrollDistance.Y !== 0; }
|
|
370
|
+
content;
|
|
371
|
+
get Content() { return this.content; }
|
|
372
|
+
set Content(value) {
|
|
373
|
+
if (this.content === value)
|
|
374
|
+
return;
|
|
375
|
+
if (this.content) {
|
|
376
|
+
this.content.Parent = undefined;
|
|
377
|
+
if (this.content instanceof SkiaLayout)
|
|
378
|
+
this.content.ItemsInsertedAtStart = undefined;
|
|
379
|
+
}
|
|
380
|
+
this.content = value;
|
|
381
|
+
if (value) {
|
|
382
|
+
value.Parent = this;
|
|
383
|
+
// items prepended (chat history): move the offset by the inserted extent so the visible rows stay put
|
|
384
|
+
if (value instanceof SkiaLayout)
|
|
385
|
+
value.ItemsInsertedAtStart = (_, px) => {
|
|
386
|
+
const d = px / this.RenderingScale;
|
|
387
|
+
if (this.Orientation === "Horizontal")
|
|
388
|
+
this.offsetX -= d;
|
|
389
|
+
else
|
|
390
|
+
this.offsetY -= d;
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
this.InvalidateMeasure();
|
|
394
|
+
}
|
|
395
|
+
// ---- offsets (points) ----
|
|
396
|
+
offsetX = 0;
|
|
397
|
+
offsetY = 0;
|
|
398
|
+
get ViewportOffsetX() { return this.offsetX; }
|
|
399
|
+
set ViewportOffsetX(value) { if (this.offsetX !== value) {
|
|
400
|
+
this.offsetX = value;
|
|
401
|
+
this.OnScrolled();
|
|
402
|
+
} }
|
|
403
|
+
get ViewportOffsetY() { return this.offsetY; }
|
|
404
|
+
set ViewportOffsetY(value) { if (this.offsetY !== value) {
|
|
405
|
+
this.offsetY = value;
|
|
406
|
+
this.OnScrolled();
|
|
407
|
+
} }
|
|
408
|
+
animatorFlingX = new ScrollFlingAnimator(this);
|
|
409
|
+
animatorFlingY = new ScrollFlingAnimator(this);
|
|
410
|
+
bounceX = new SpringWithVelocityAnimator(this);
|
|
411
|
+
bounceY = new SpringWithVelocityAnimator(this);
|
|
412
|
+
OnDisposing() {
|
|
413
|
+
this.animatorFlingX.Stop();
|
|
414
|
+
this.animatorFlingY.Stop();
|
|
415
|
+
this.bounceX.Stop();
|
|
416
|
+
this.bounceY.Stop();
|
|
417
|
+
}
|
|
418
|
+
DisposeChildren() {
|
|
419
|
+
this.content?.Dispose();
|
|
420
|
+
this.content = undefined;
|
|
421
|
+
this.header?.Dispose();
|
|
422
|
+
this.header = undefined;
|
|
423
|
+
this.footer?.Dispose();
|
|
424
|
+
this.footer = undefined;
|
|
425
|
+
this.refreshIndicator?.Dispose();
|
|
426
|
+
this.refreshIndicator = undefined;
|
|
427
|
+
this.scrollBar?.Dispose();
|
|
428
|
+
this.scrollBar = undefined;
|
|
429
|
+
this.scrollBarHorizontal?.Dispose();
|
|
430
|
+
this.scrollBarHorizontal = undefined;
|
|
431
|
+
}
|
|
432
|
+
velocity = new VelocityAccumulator();
|
|
433
|
+
panningCurrentOffsetPts = SKPoint.Empty;
|
|
434
|
+
panningLastDelta = SKPoint.Empty;
|
|
435
|
+
hadDown = false;
|
|
436
|
+
childWasPanning = false;
|
|
437
|
+
velocityX = 0;
|
|
438
|
+
velocityY = 0;
|
|
439
|
+
static MinVelocity = 1.5;
|
|
440
|
+
/** Set when a fling was cut to stop at the edge: the edge offset it lands on (DrawnUi _axis / _changeSpeed). */
|
|
441
|
+
flingEdgeX = null;
|
|
442
|
+
flingEdgeY = null;
|
|
443
|
+
constructor() {
|
|
444
|
+
super();
|
|
445
|
+
this.animatorFlingX.OnUpdated = (v) => { this.ViewportOffsetX = this.ClampOffset(v, 0, this.ContentOffsetBounds).X; };
|
|
446
|
+
this.animatorFlingY.OnUpdated = (v) => { this.ViewportOffsetY = this.ClampOffset(0, v, this.ContentOffsetBounds).Y; };
|
|
447
|
+
this.bounceX.OnUpdated = (v) => { this.ViewportOffsetX = v; };
|
|
448
|
+
this.bounceY.OnUpdated = (v) => { this.ViewportOffsetY = v; };
|
|
449
|
+
for (const a of [this.animatorFlingX, this.animatorFlingY, this.bounceX, this.bounceY]) {
|
|
450
|
+
a.OnStart = () => { this.IsScrolling = true; };
|
|
451
|
+
a.OnStop = () => {
|
|
452
|
+
this.IsScrolling = this.animatorFlingX.IsRunning || this.animatorFlingY.IsRunning || this.bounceX.IsRunning || this.bounceY.IsRunning;
|
|
453
|
+
if (!this.IsScrolling) {
|
|
454
|
+
this.Repaint();
|
|
455
|
+
queueMicrotask(() => { if (this.CheckNeedToSnap())
|
|
456
|
+
this.Snap(this.AutoScrollingSpeedMs / 1000); });
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// DrawnUi OnScrollerStopped: a fling that was cut at the edge hands its remaining velocity to the bounce.
|
|
461
|
+
const flingStop = this.animatorFlingY.OnStop;
|
|
462
|
+
this.animatorFlingY.OnStop = () => { this.LandScrollTo(this.animatorFlingY, false); flingStop(); if (this.animatorFlingY.WasStarted)
|
|
463
|
+
this.BounceIfNeeded(this.animatorFlingY, false); };
|
|
464
|
+
const flingStopX = this.animatorFlingX.OnStop;
|
|
465
|
+
this.animatorFlingX.OnStop = () => { this.LandScrollTo(this.animatorFlingX, true); flingStopX(); if (this.animatorFlingX.WasStarted)
|
|
466
|
+
this.BounceIfNeeded(this.animatorFlingX, true); };
|
|
467
|
+
}
|
|
468
|
+
/** DrawnUi BounceIfNeeded: after an edge-cut fling finishes, bounce with the velocity it still had. */
|
|
469
|
+
BounceIfNeeded(animator, horizontal) {
|
|
470
|
+
const edge = horizontal ? this.flingEdgeX : this.flingEdgeY;
|
|
471
|
+
if (!this.Bounces || edge === null || !animator.SelfFinished || !animator.Parameters)
|
|
472
|
+
return;
|
|
473
|
+
if (horizontal)
|
|
474
|
+
this.flingEdgeX = null;
|
|
475
|
+
else
|
|
476
|
+
this.flingEdgeY = null;
|
|
477
|
+
const remaining = animator.Parameters.VelocityAt(animator.Speed);
|
|
478
|
+
const velocity = Math.sign(remaining) * Math.min(Math.abs(remaining), this.MaxBounceVelocity);
|
|
479
|
+
if (Math.abs(velocity) <= SkiaScroll.ThesholdSwipeOnUp * this.RenderingScale)
|
|
480
|
+
return;
|
|
481
|
+
if (horizontal)
|
|
482
|
+
this.Bounce(this.bounceX, this.animatorFlingX, this.offsetX, edge, velocity);
|
|
483
|
+
else
|
|
484
|
+
this.Bounce(this.bounceY, this.animatorFlingY, this.offsetY, edge, velocity);
|
|
485
|
+
}
|
|
486
|
+
// ---- tree: one Content, plus tagged extras (JSX children with Tag Header / Footer / RefreshIndicator / ScrollBar / ScrollBarHorizontal) ----
|
|
487
|
+
AddSubView(control) {
|
|
488
|
+
switch (control.Tag) {
|
|
489
|
+
case "Header":
|
|
490
|
+
this.Header = control;
|
|
491
|
+
return;
|
|
492
|
+
case "Footer":
|
|
493
|
+
this.Footer = control;
|
|
494
|
+
return;
|
|
495
|
+
case "RefreshIndicator":
|
|
496
|
+
this.RefreshIndicator = control;
|
|
497
|
+
return;
|
|
498
|
+
case "ScrollBar":
|
|
499
|
+
this.ScrollBar = control;
|
|
500
|
+
return;
|
|
501
|
+
case "ScrollBarHorizontal":
|
|
502
|
+
this.ScrollBarHorizontal = control;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
this.Content = control;
|
|
506
|
+
}
|
|
507
|
+
InsertSubView(_index, control) { this.AddSubView(control); }
|
|
508
|
+
RemoveSubView(control) {
|
|
509
|
+
if (this.content === control)
|
|
510
|
+
this.Content = undefined;
|
|
511
|
+
else if (this.header === control)
|
|
512
|
+
this.Header = undefined;
|
|
513
|
+
else if (this.footer === control)
|
|
514
|
+
this.Footer = undefined;
|
|
515
|
+
else if (this.refreshIndicator === control)
|
|
516
|
+
this.RefreshIndicator = undefined;
|
|
517
|
+
else if (this.scrollBar === control)
|
|
518
|
+
this.scrollBar = undefined;
|
|
519
|
+
else if (this.scrollBarHorizontal === control)
|
|
520
|
+
this.scrollBarHorizontal = undefined;
|
|
521
|
+
}
|
|
522
|
+
/** Draw order: behind header, content, flow / sticky header, footer (the base walks it top-most first for gestures). */
|
|
523
|
+
GetGestureListeners() {
|
|
524
|
+
const list = [];
|
|
525
|
+
if (this.header && this.HeaderBehind)
|
|
526
|
+
list.push(this.header);
|
|
527
|
+
if (this.content)
|
|
528
|
+
list.push(this.content);
|
|
529
|
+
if (this.header && !this.HeaderBehind)
|
|
530
|
+
list.push(this.header);
|
|
531
|
+
if (this.footer)
|
|
532
|
+
list.push(this.footer);
|
|
533
|
+
return list;
|
|
534
|
+
}
|
|
535
|
+
// ---- measure / arrange ----
|
|
536
|
+
/** Content gets an infinite constraint along the scroll axis; the scroll itself takes the box it is given. */
|
|
537
|
+
MeasureAbsolute(widthConstraint, heightConstraint, scale) {
|
|
538
|
+
const c = this.content;
|
|
539
|
+
if (c && c.IsVisible) {
|
|
540
|
+
const w = this.Orientation === "Horizontal" || this.Orientation === "Both" ? Infinity : widthConstraint;
|
|
541
|
+
const h = this.Orientation === "Vertical" || this.Orientation === "Both" ? Infinity : heightConstraint;
|
|
542
|
+
this.ContentSize = c.Measure(w, h, scale);
|
|
543
|
+
}
|
|
544
|
+
else
|
|
545
|
+
this.ContentSize = ScaledSize.Default;
|
|
546
|
+
// C#: header / footer measured with the scroll's constraints (they span the viewport across the axis)
|
|
547
|
+
this.headerSize = this.header && this.header.IsVisible ? this.header.Measure(widthConstraint, heightConstraint, scale) : ScaledSize.Default;
|
|
548
|
+
this.footerSize = this.footer && this.footer.IsVisible ? this.footer.Measure(widthConstraint, heightConstraint, scale) : ScaledSize.Default;
|
|
549
|
+
if (this.refreshIndicator)
|
|
550
|
+
this.refreshIndicator.Measure(widthConstraint, heightConstraint, scale);
|
|
551
|
+
const extra = (this.HeaderExtentPts + this.FooterExtentPts) * scale;
|
|
552
|
+
const rw = isFinite(widthConstraint) ? widthConstraint : this.ContentSize.Pixels.Width + (this.Orientation === "Horizontal" ? extra : 0);
|
|
553
|
+
const rh = isFinite(heightConstraint) ? heightConstraint : this.ContentSize.Pixels.Height + (this.Orientation !== "Horizontal" ? extra : 0);
|
|
554
|
+
return ScaledSize.FromPixels(rw, rh, scale);
|
|
555
|
+
}
|
|
556
|
+
OnLayoutChanged() {
|
|
557
|
+
const scale = this.RenderingScale;
|
|
558
|
+
const viewportW = this.DrawingRect.Width / scale, viewportH = this.DrawingRect.Height / scale;
|
|
559
|
+
const extra = this.HeaderExtentPts + this.FooterExtentPts;
|
|
560
|
+
const width = Math.max(0, this.ContentSize.Units.Width + (this.Orientation === "Horizontal" ? extra : 0) - viewportW);
|
|
561
|
+
const height = Math.max(0, this.ContentSize.Units.Height + (this.Orientation !== "Horizontal" ? extra : 0) - viewportH);
|
|
562
|
+
this.ContentOffsetBounds = new SKRect(-width, -height, 0, 0);
|
|
563
|
+
this.ArrangeContent();
|
|
564
|
+
this.CheckLoadMore();
|
|
565
|
+
}
|
|
566
|
+
/** Places Content at the current offset; called on layout and every frame while the offset moves. */
|
|
567
|
+
ArrangeContent() {
|
|
568
|
+
const c = this.content;
|
|
569
|
+
if (!c)
|
|
570
|
+
return;
|
|
571
|
+
const scale = this.RenderingScale;
|
|
572
|
+
const r = this.DrawingRect;
|
|
573
|
+
const alongX = this.Orientation === "Horizontal" || this.Orientation === "Both";
|
|
574
|
+
const alongY = this.Orientation === "Vertical" || this.Orientation === "Both";
|
|
575
|
+
const w = alongX ? Math.max(this.ContentSize.Pixels.Width, r.Width) : r.Width;
|
|
576
|
+
const h = alongY ? Math.max(this.ContentSize.Pixels.Height, r.Height) : r.Height;
|
|
577
|
+
// Offsets are snapped to whole device pixels: a fractional offset would rasterize every glyph at a
|
|
578
|
+
// different sub-pixel phase each frame (text shimmer while scrolling). DrawnUi gets the same result from
|
|
579
|
+
// nearest-sampled cached cells; here the snap applies to the whole content, cached or not.
|
|
580
|
+
const headerPx = Math.round(this.HeaderExtentPts * scale);
|
|
581
|
+
const x = r.Left + (alongX ? Math.round(this.offsetX * scale) + (this.Orientation === "Horizontal" ? headerPx : 0) : 0);
|
|
582
|
+
const y = r.Top + (alongY ? Math.round(this.offsetY * scale) + (this.Orientation !== "Horizontal" ? headerPx : 0) : 0);
|
|
583
|
+
c.Arrange(SKRect.Create(x, y, w, h), c.WidthRequest, c.HeightRequest, scale);
|
|
584
|
+
this.OverscrollDistance = this.CalculateOverscrollDistance(this.offsetX, this.offsetY);
|
|
585
|
+
this.ArrangeHeaderFooter(r, scale);
|
|
586
|
+
}
|
|
587
|
+
/** Header: sticky = pinned at the viewport start, else scrolled at HeaderParallaxRatio of the offset; footer after the content. */
|
|
588
|
+
ArrangeHeaderFooter(r, scale) {
|
|
589
|
+
const horizontal = this.Orientation === "Horizontal";
|
|
590
|
+
const offset = horizontal ? this.offsetX : this.offsetY;
|
|
591
|
+
if (this.header) {
|
|
592
|
+
let pos = 0;
|
|
593
|
+
if (!this.HeaderSticky) {
|
|
594
|
+
const overscrollStart = (horizontal ? this.OverscrollDistance.X : this.OverscrollDistance.Y) > 0;
|
|
595
|
+
pos = !this.ParallaxOverscrollEnabled && overscrollStart ? offset : offset * this.HeaderParallaxRatio;
|
|
596
|
+
}
|
|
597
|
+
const hw = horizontal ? this.headerSize.Pixels.Width : r.Width, hh = horizontal ? r.Height : this.headerSize.Pixels.Height;
|
|
598
|
+
const rect = horizontal ? SKRect.Create(r.Left + Math.round(pos * scale), r.Top, hw, hh) : SKRect.Create(r.Left, r.Top + Math.round(pos * scale), hw, hh);
|
|
599
|
+
this.header.Arrange(rect, this.header.WidthRequest, this.header.HeightRequest, scale);
|
|
600
|
+
}
|
|
601
|
+
if (this.footer) {
|
|
602
|
+
const contentEnd = (this.HeaderExtentPts + (horizontal ? this.ContentSize.Units.Width : this.ContentSize.Units.Height)) * scale;
|
|
603
|
+
const fw = horizontal ? this.footerSize.Pixels.Width : r.Width, fh = horizontal ? r.Height : this.footerSize.Pixels.Height;
|
|
604
|
+
const rect = horizontal ? SKRect.Create(r.Left + Math.round(offset * scale) + contentEnd, r.Top, fw, fh) : SKRect.Create(r.Left, r.Top + Math.round(offset * scale) + contentEnd, fw, fh);
|
|
605
|
+
this.footer.Arrange(rect, this.footer.WidthRequest, this.footer.HeightRequest, scale);
|
|
606
|
+
}
|
|
607
|
+
if (this.refreshIndicator) {
|
|
608
|
+
const ind = this.refreshIndicator;
|
|
609
|
+
const m = ind.MeasuredSize.Pixels;
|
|
610
|
+
const rect = horizontal ? SKRect.Create(r.Left, r.Top, m.Width, r.Height) : SKRect.Create(r.Left, r.Top, r.Width, m.Height);
|
|
611
|
+
ind.Arrange(rect, ind.WidthRequest, ind.HeightRequest, scale);
|
|
612
|
+
}
|
|
613
|
+
for (const bar of [this.scrollBar, this.scrollBarHorizontal])
|
|
614
|
+
if (bar) {
|
|
615
|
+
bar.Measure(r.Width, r.Height, scale);
|
|
616
|
+
bar.Arrange(r, bar.WidthRequest, bar.HeightRequest, scale);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
Paint(ctx) {
|
|
620
|
+
const c = this.content;
|
|
621
|
+
if (!c)
|
|
622
|
+
return;
|
|
623
|
+
this.ArrangeContent();
|
|
624
|
+
const canvas = ctx.Context.Canvas;
|
|
625
|
+
const d = ctx.Destination;
|
|
626
|
+
const saved = canvas.save();
|
|
627
|
+
canvas.clipRect(Super.CK.LTRBRect(d.Left, d.Top, d.Right, d.Bottom), Super.CK.ClipOp.Intersect, true);
|
|
628
|
+
const header = this.header, onScreen = (v) => { const a = v.DrawingRect; return a.Right > d.Left && a.Left < d.Right && a.Bottom > d.Top && a.Top < d.Bottom; };
|
|
629
|
+
// C# order: a HeaderBehind header first (under the content), the content, a flow / sticky header over it, the footer
|
|
630
|
+
if (header && header.IsVisible && this.HeaderBehind && onScreen(header))
|
|
631
|
+
header.Render(ctx);
|
|
632
|
+
c.Render(ctx);
|
|
633
|
+
if (header && header.IsVisible && !this.HeaderBehind && onScreen(header))
|
|
634
|
+
header.Render(ctx);
|
|
635
|
+
if (this.footer && this.footer.IsVisible && onScreen(this.footer))
|
|
636
|
+
this.footer.Render(ctx);
|
|
637
|
+
if (this.RefreshEnabled && this.refreshIndicator && this.refreshIndicator.IsVisible && (this.OverScrolled || this.isRefreshing))
|
|
638
|
+
this.refreshIndicator.Render(ctx);
|
|
639
|
+
this.UpdateScrollBarIndicator();
|
|
640
|
+
for (const bar of [this.scrollBar, this.scrollBarHorizontal])
|
|
641
|
+
if (bar && bar.IsVisible && bar.Opacity > 0)
|
|
642
|
+
bar.Render(ctx);
|
|
643
|
+
canvas.restoreToCount(saved);
|
|
644
|
+
}
|
|
645
|
+
OnScrolled() {
|
|
646
|
+
this.CheckLoadMore();
|
|
647
|
+
this.OverscrollDistance = this.CalculateOverscrollDistance(this.offsetX, this.offsetY);
|
|
648
|
+
this.CheckNeedRefresh();
|
|
649
|
+
this.TrackIndex();
|
|
650
|
+
this.RepaintComposition(); // a scroll inside a cached parent: that parent must re-record (its own cache is None by default)
|
|
651
|
+
this.Scrolled?.(this, { Units: new SKPoint(this.offsetX, this.offsetY), Pixels: new SKPoint(this.offsetX * this.RenderingScale, this.offsetY * this.RenderingScale) });
|
|
652
|
+
}
|
|
653
|
+
// ---- clamping ----
|
|
654
|
+
ClampOffset(x, y, bounds, strict = false) {
|
|
655
|
+
if (!this.Bounces || strict) {
|
|
656
|
+
return { X: Math.max(bounds.Left, Math.min(bounds.Right, x)), Y: Math.max(bounds.Top, Math.min(bounds.Bottom, y)) };
|
|
657
|
+
}
|
|
658
|
+
const scale = this.RenderingScale;
|
|
659
|
+
return RubberBandUtils.ClampOnTrack(x, y, bounds, this.RubberEffect, this.DrawingRect.Width / scale, this.DrawingRect.Height / scale);
|
|
660
|
+
}
|
|
661
|
+
CalculateOverscrollDistance(x, y) {
|
|
662
|
+
const b = this.ContentOffsetBounds;
|
|
663
|
+
const dx = x < b.Left ? x - b.Left : x > b.Right ? x - b.Right : 0;
|
|
664
|
+
const dy = y < b.Top ? y - b.Top : y > b.Bottom ? y - b.Bottom : 0;
|
|
665
|
+
return new SKPoint(dx, dy);
|
|
666
|
+
}
|
|
667
|
+
// ---- programmatic scrolling ----
|
|
668
|
+
/** Scroll to an offset in points; maxSpeedSecs > 0 animates along the deceleration curve. */
|
|
669
|
+
/**
|
|
670
|
+
* Scrolls every SkiaScroll ancestor of `control` so its DrawingRect lies inside the viewport (React extension,
|
|
671
|
+
* used by the accessibility overlay when keyboard focus lands on an off-screen node). Offsets are points.
|
|
672
|
+
*/
|
|
673
|
+
static EnsureVisible(control, maxTimeSecs = 0.25, paddingPts = 8) {
|
|
674
|
+
let p = control.Parent;
|
|
675
|
+
while (p) {
|
|
676
|
+
if (p instanceof SkiaScroll) {
|
|
677
|
+
const scale = p.RenderingScale;
|
|
678
|
+
const r = control.DrawingRect, v = p.DrawingRect;
|
|
679
|
+
let dx = 0, dy = 0;
|
|
680
|
+
if (r.Top < v.Top)
|
|
681
|
+
dy = (v.Top - r.Top) / scale + paddingPts;
|
|
682
|
+
else if (r.Bottom > v.Bottom)
|
|
683
|
+
dy = -((r.Bottom - v.Bottom) / scale + paddingPts);
|
|
684
|
+
if (r.Left < v.Left)
|
|
685
|
+
dx = (v.Left - r.Left) / scale + paddingPts;
|
|
686
|
+
else if (r.Right > v.Right)
|
|
687
|
+
dx = -((r.Right - v.Right) / scale + paddingPts);
|
|
688
|
+
if (dx !== 0 || dy !== 0)
|
|
689
|
+
p.ScrollTo(p.offsetX + dx, p.offsetY + dy, maxTimeSecs);
|
|
690
|
+
}
|
|
691
|
+
p = p.Parent;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/** Destination of the running ScrollTo per axis: the deceleration curve stops within its threshold, the exact value is applied when it finishes. */
|
|
695
|
+
scrollToTargetX = null;
|
|
696
|
+
scrollToTargetY = null;
|
|
697
|
+
LandScrollTo(animator, horizontal) {
|
|
698
|
+
const target = horizontal ? this.scrollToTargetX : this.scrollToTargetY;
|
|
699
|
+
if (target === null)
|
|
700
|
+
return;
|
|
701
|
+
if (horizontal)
|
|
702
|
+
this.scrollToTargetX = null;
|
|
703
|
+
else
|
|
704
|
+
this.scrollToTargetY = null;
|
|
705
|
+
if (!animator.SelfFinished)
|
|
706
|
+
return;
|
|
707
|
+
if (horizontal)
|
|
708
|
+
this.ViewportOffsetX = target;
|
|
709
|
+
else
|
|
710
|
+
this.ViewportOffsetY = target;
|
|
711
|
+
}
|
|
712
|
+
ScrollTo(x, y, maxSpeedSecs, clamp = true) {
|
|
713
|
+
this.StopAnimators(); // also forgets any pending edge bounce: a programmatic scroll never bounces
|
|
714
|
+
let tx = x, ty = y;
|
|
715
|
+
if (clamp) {
|
|
716
|
+
const c = this.ClampOffset(x, y, this.ContentOffsetBounds, true);
|
|
717
|
+
tx = c.X;
|
|
718
|
+
ty = c.Y;
|
|
719
|
+
}
|
|
720
|
+
const rate = 1 - this.DecelerationRatio;
|
|
721
|
+
this.scrollToTargetX = maxSpeedSecs > 0 && this.Orientation !== "Vertical" && tx !== this.offsetX ? tx : null;
|
|
722
|
+
this.scrollToTargetY = maxSpeedSecs > 0 && this.Orientation !== "Horizontal" && ty !== this.offsetY ? ty : null;
|
|
723
|
+
if (maxSpeedSecs > 0) {
|
|
724
|
+
if (this.Orientation !== "Vertical" && tx !== this.offsetX) {
|
|
725
|
+
this.animatorFlingX.InitializeWithDestination(this.offsetX, tx, maxSpeedSecs, rate);
|
|
726
|
+
this.animatorFlingX.Start();
|
|
727
|
+
}
|
|
728
|
+
if (this.Orientation !== "Horizontal" && ty !== this.offsetY) {
|
|
729
|
+
this.animatorFlingY.InitializeWithDestination(this.offsetY, ty, maxSpeedSecs, rate);
|
|
730
|
+
this.animatorFlingY.Start();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
this.ViewportOffsetX = tx;
|
|
735
|
+
this.ViewportOffsetY = ty;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
ScrollToTop(maxTimeSecs) { this.ScrollTo(this.Orientation === "Horizontal" ? 0 : this.offsetX, this.Orientation === "Horizontal" ? this.offsetY : 0, maxTimeSecs); }
|
|
739
|
+
ScrollToBottom(maxTimeSecs) {
|
|
740
|
+
if (this.Orientation === "Horizontal")
|
|
741
|
+
this.ScrollTo(this.ContentOffsetBounds.Left, this.offsetY, maxTimeSecs);
|
|
742
|
+
else
|
|
743
|
+
this.ScrollTo(this.offsetX, this.ContentOffsetBounds.Top, maxTimeSecs);
|
|
744
|
+
}
|
|
745
|
+
StopScrolling() { this.StopAnimators(); this.IsUserPanning = false; }
|
|
746
|
+
/**
|
|
747
|
+
* Scrolls so that item `index` is at the viewport start (or end). Like DrawnUi, Content must BE the
|
|
748
|
+
* templated layout (the recycled list is the scroll's only child; a header goes above the scroll or in Header).
|
|
749
|
+
*/
|
|
750
|
+
ScrollToIndex(index, animate, option = "Start") {
|
|
751
|
+
const layout = this.content instanceof SkiaLayout && this.content.IsTemplated ? this.content : undefined;
|
|
752
|
+
const items = layout?.ItemsSource;
|
|
753
|
+
if (!layout || !items || items.length === 0 || !this.content)
|
|
754
|
+
return;
|
|
755
|
+
const i = Math.max(0, Math.min(items.length - 1, index));
|
|
756
|
+
const scale = this.RenderingScale;
|
|
757
|
+
const layoutTopPts = (layout.DrawingRect.Top - this.content.DrawingRect.Top) / scale;
|
|
758
|
+
let target = layoutTopPts + layout.GetItemOffsetPixels(i) / scale;
|
|
759
|
+
if (option === "End")
|
|
760
|
+
target -= this.DrawingRect.Height / scale - layout.GetItemOffsetPixels(i + 1) / scale + layout.GetItemOffsetPixels(i) / scale;
|
|
761
|
+
else if (option === "Center")
|
|
762
|
+
target -= this.DrawingRect.Height / scale / 2;
|
|
763
|
+
this.ScrollTo(this.offsetX, -target, animate ? this.ScrollingSpeedMs / 1000 : 0, true);
|
|
764
|
+
}
|
|
765
|
+
StopAnimators() {
|
|
766
|
+
this.flingEdgeX = null;
|
|
767
|
+
this.flingEdgeY = null;
|
|
768
|
+
this.scrollToTargetX = null;
|
|
769
|
+
this.scrollToTargetY = null;
|
|
770
|
+
this.animatorFlingX.Stop();
|
|
771
|
+
this.animatorFlingY.Stop();
|
|
772
|
+
this.bounceX.Stop();
|
|
773
|
+
this.bounceY.Stop();
|
|
774
|
+
}
|
|
775
|
+
/** DrawnUi DecelerationRatio: FrictionScrolled / 100, floored at 0.1 friction. */
|
|
776
|
+
get DecelerationRatio() { return Math.max(0.1, this.FrictionScrolled) / 100; }
|
|
777
|
+
// ---- gestures ----
|
|
778
|
+
ResetPan() {
|
|
779
|
+
this.IsUserFocused = true;
|
|
780
|
+
this.IsUserPanning = false;
|
|
781
|
+
this.childWasPanning = false;
|
|
782
|
+
this.velocity.Clear();
|
|
783
|
+
this.panningLastDelta = SKPoint.Empty;
|
|
784
|
+
this.panningCurrentOffsetPts = new SKPoint(this.offsetX, this.offsetY);
|
|
785
|
+
}
|
|
786
|
+
ProcessGestures(args, apply) {
|
|
787
|
+
const consumedDefault = this.BlockGesturesBelow ? this : null;
|
|
788
|
+
const scale = this.RenderingScale;
|
|
789
|
+
const e = args.Event;
|
|
790
|
+
if (args.Type === "Down") {
|
|
791
|
+
this.hadDown = true;
|
|
792
|
+
this.snapped = false;
|
|
793
|
+
if (this.RespondsToGestures) {
|
|
794
|
+
this.StopAnimators();
|
|
795
|
+
this.ResetPan();
|
|
796
|
+
}
|
|
797
|
+
return super.ProcessGestures(args, apply) ?? consumedDefault; // children see Down (buttons press)
|
|
798
|
+
}
|
|
799
|
+
if (args.Type === "Wheel") {
|
|
800
|
+
// a nested scroll under the pointer takes the wheel first; the outer one scrolls when the inner is at its edge
|
|
801
|
+
const child = super.ProcessGestures(args, apply);
|
|
802
|
+
if (child && child !== this)
|
|
803
|
+
return child;
|
|
804
|
+
if (!this.RespondsToGestures)
|
|
805
|
+
return child ?? consumedDefault;
|
|
806
|
+
return this.ApplyWheelScroll(e.Wheel.Delta) ? this : consumedDefault;
|
|
807
|
+
}
|
|
808
|
+
this.velocityY = e.Distance.Velocity.Y / scale;
|
|
809
|
+
this.velocityX = e.Distance.Velocity.X / scale;
|
|
810
|
+
const wrongDirection = (this.Orientation === "Vertical" && Math.abs(e.Distance.Total.X) > Math.abs(e.Distance.Total.Y) && Math.abs(e.Distance.Total.X) > SkiaScroll.ScrollVelocityThreshold * scale) ||
|
|
811
|
+
(this.Orientation === "Horizontal" && Math.abs(e.Distance.Total.Y) > Math.abs(e.Distance.Total.X) && Math.abs(e.Distance.Total.Y) > SkiaScroll.ScrollVelocityThreshold * scale);
|
|
812
|
+
if (args.Type === "Panning" && this.RespondsToGestures && this.hadDown) {
|
|
813
|
+
if (!this.IsUserPanning) {
|
|
814
|
+
// A child may own the pan (slider, nested horizontal scroll); ask once before taking over.
|
|
815
|
+
const childConsumed = super.ProcessGestures(args, apply);
|
|
816
|
+
if (childConsumed && childConsumed !== this) {
|
|
817
|
+
this.childWasPanning = true;
|
|
818
|
+
return childConsumed;
|
|
819
|
+
}
|
|
820
|
+
if (this.childWasPanning)
|
|
821
|
+
return consumedDefault;
|
|
822
|
+
if (this.IgnoreWrongDirection && wrongDirection) {
|
|
823
|
+
this.IsUserFocused = false;
|
|
824
|
+
return consumedDefault;
|
|
825
|
+
}
|
|
826
|
+
const v = this.Orientation === "Vertical" ? this.velocityY : this.Orientation === "Horizontal" ? this.velocityX : Math.max(Math.abs(this.velocityX), Math.abs(this.velocityY));
|
|
827
|
+
if (Math.abs(v) <= SkiaScroll.ScrollVelocityThreshold)
|
|
828
|
+
return consumedDefault;
|
|
829
|
+
}
|
|
830
|
+
if (!this.IsUserFocused)
|
|
831
|
+
this.ResetPan();
|
|
832
|
+
this.IsUserPanning = true;
|
|
833
|
+
this.velocity.CaptureVelocity(this.velocityX, this.velocityY, args.ArrivedTimeNanos);
|
|
834
|
+
const movedX = (e.Distance.Delta.X / scale) * this.ChangeDistancePanned;
|
|
835
|
+
const movedY = (e.Distance.Delta.Y / scale) * this.ChangeDistancePanned;
|
|
836
|
+
const ix = this.panningLastDelta.X + (movedX - this.panningLastDelta.X) * 0.85;
|
|
837
|
+
const iy = this.panningLastDelta.Y + (movedY - this.panningLastDelta.Y) * 0.85;
|
|
838
|
+
this.panningLastDelta = new SKPoint(ix, iy);
|
|
839
|
+
this.panningCurrentOffsetPts = new SKPoint(this.panningCurrentOffsetPts.X + ix, this.panningCurrentOffsetPts.Y + iy);
|
|
840
|
+
const clamped = this.ClampOffset(this.panningCurrentOffsetPts.X, this.panningCurrentOffsetPts.Y, this.ContentOffsetBounds);
|
|
841
|
+
if (this.Orientation !== "Horizontal")
|
|
842
|
+
this.ViewportOffsetY = clamped.Y;
|
|
843
|
+
if (this.Orientation !== "Vertical")
|
|
844
|
+
this.ViewportOffsetX = clamped.X;
|
|
845
|
+
return this;
|
|
846
|
+
}
|
|
847
|
+
if (args.Type === "Up") {
|
|
848
|
+
const childConsumed = super.ProcessGestures(args, apply); // children release (buttons)
|
|
849
|
+
this.hadDown = false;
|
|
850
|
+
if (!this.RespondsToGestures || this.childWasPanning) {
|
|
851
|
+
this.IsUserPanning = false;
|
|
852
|
+
return childConsumed ?? consumedDefault;
|
|
853
|
+
}
|
|
854
|
+
const wasPanning = this.IsUserPanning;
|
|
855
|
+
this.IsUserPanning = false;
|
|
856
|
+
this.IsUserFocused = false;
|
|
857
|
+
if (!wasPanning && !this.OverScrolled)
|
|
858
|
+
return childConsumed ?? consumedDefault;
|
|
859
|
+
const finalVelocity = this.velocity.CalculateFinalVelocity(this.MaxVelocity);
|
|
860
|
+
const swipeThreshold = SkiaScroll.ThesholdSwipeOnUp * scale;
|
|
861
|
+
let vx = finalVelocity.X * this.ChangeVelocityScrolled;
|
|
862
|
+
let vy = finalVelocity.Y * this.ChangeVelocityScrolled;
|
|
863
|
+
let fling = false;
|
|
864
|
+
if (this.OverScrolled) {
|
|
865
|
+
const rest = this.ClampOffset(this.offsetX, this.offsetY, this.ContentOffsetBounds, true);
|
|
866
|
+
// refreshing: the bounce settles at RefreshShowDistance so the indicator stays in view (C# ScrollLocked)
|
|
867
|
+
if (this.isRefreshing && this.scrollLocked) {
|
|
868
|
+
if (this.Orientation === "Horizontal")
|
|
869
|
+
rest.X = this.RefreshShowDistance;
|
|
870
|
+
else
|
|
871
|
+
rest.Y = this.RefreshShowDistance;
|
|
872
|
+
}
|
|
873
|
+
const bvx = Math.sign(vx) * Math.min(Math.abs(vx), this.MaxBounceVelocity);
|
|
874
|
+
const bvy = Math.sign(vy) * Math.min(Math.abs(vy), this.MaxBounceVelocity);
|
|
875
|
+
if (this.OverscrollDistance.Y !== 0) {
|
|
876
|
+
this.Bounce(this.bounceY, this.animatorFlingY, this.offsetY, rest.Y, bvy);
|
|
877
|
+
fling = true;
|
|
878
|
+
}
|
|
879
|
+
if (this.OverscrollDistance.X !== 0) {
|
|
880
|
+
this.Bounce(this.bounceX, this.animatorFlingX, this.offsetX, rest.X, bvx);
|
|
881
|
+
fling = true;
|
|
882
|
+
}
|
|
883
|
+
return this;
|
|
884
|
+
}
|
|
885
|
+
const swipe = Math.abs(vx) > swipeThreshold || Math.abs(vy) > swipeThreshold;
|
|
886
|
+
if (swipe) {
|
|
887
|
+
if (this.Orientation !== "Vertical" && Math.abs(vx) > SkiaScroll.MinVelocity) {
|
|
888
|
+
this.bounceX.Stop();
|
|
889
|
+
fling = this.StartToFlingFrom(this.animatorFlingX, this.offsetX, vx, true) || fling;
|
|
890
|
+
}
|
|
891
|
+
if (this.Orientation !== "Horizontal" && Math.abs(vy) > SkiaScroll.MinVelocity) {
|
|
892
|
+
this.bounceY.Stop();
|
|
893
|
+
fling = this.StartToFlingFrom(this.animatorFlingY, this.offsetY, vy, false) || fling;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
this.Repaint();
|
|
897
|
+
if (!fling && wasPanning && this.CheckNeedToSnap())
|
|
898
|
+
this.Snap(this.AutoScrollingSpeedMs / 1000);
|
|
899
|
+
return fling || wasPanning ? this : childConsumed ?? consumedDefault;
|
|
900
|
+
}
|
|
901
|
+
if (args.Type === "Tapped" && this.IsUserPanning)
|
|
902
|
+
return this; // a drag never ends in a tap
|
|
903
|
+
return super.ProcessGestures(args, apply) ?? consumedDefault;
|
|
904
|
+
}
|
|
905
|
+
Bounce(animator, fling, offsetFrom, offsetTo, velocity) {
|
|
906
|
+
const displacement = offsetFrom - offsetTo;
|
|
907
|
+
if (displacement === 0 && velocity === 0)
|
|
908
|
+
return;
|
|
909
|
+
if (fling.IsRunning)
|
|
910
|
+
fling.Stop();
|
|
911
|
+
const spring = new Spring(1 * (1 + this.RubberDamping), 200, 0.5 * (1 + this.RubberDamping));
|
|
912
|
+
animator.Initialize(offsetTo, displacement, velocity, spring);
|
|
913
|
+
animator.Start();
|
|
914
|
+
}
|
|
915
|
+
/** DrawnUi StartToFlingFrom + PrepareToFlingAfterInitialized: cut the curve so it stops exactly at the edge. */
|
|
916
|
+
StartToFlingFrom(animator, from, velocity, horizontal) {
|
|
917
|
+
animator.InitializeWithVelocity(from, velocity, 1 - this.DecelerationRatio);
|
|
918
|
+
const p = animator.Parameters;
|
|
919
|
+
const b = this.ContentOffsetBounds;
|
|
920
|
+
const min = horizontal ? b.Left : b.Top, max = horizontal ? b.Right : b.Bottom;
|
|
921
|
+
const destination = p.Destination;
|
|
922
|
+
let edge = null;
|
|
923
|
+
if (destination < min || destination > max) {
|
|
924
|
+
edge = Math.max(min, Math.min(max, destination));
|
|
925
|
+
animator.Speed = p.DurationToValue(edge);
|
|
926
|
+
}
|
|
927
|
+
if (horizontal)
|
|
928
|
+
this.flingEdgeX = edge;
|
|
929
|
+
else
|
|
930
|
+
this.flingEdgeY = edge;
|
|
931
|
+
if (animator.Speed <= 0)
|
|
932
|
+
return false;
|
|
933
|
+
animator.Start();
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* One wheel notch = WheelLineSize points, hard-clamped, animated over AutoScrollingSpeedMs.
|
|
938
|
+
* Notches arriving while the previous one is still animating add onto its target, so a fast wheel
|
|
939
|
+
* spin travels N steps instead of restarting from the barely-moved current offset.
|
|
940
|
+
*/
|
|
941
|
+
ApplyWheelScroll(delta) {
|
|
942
|
+
const step = SkiaScroll.WheelLineSize * -Math.sign(delta); // wheel down = content up = more negative offset
|
|
943
|
+
const horizontal = this.Orientation === "Horizontal";
|
|
944
|
+
const running = horizontal ? this.animatorFlingX : this.animatorFlingY;
|
|
945
|
+
const base = running.IsRunning && running.Parameters ? running.Parameters.Destination : horizontal ? this.offsetX : this.offsetY;
|
|
946
|
+
const b = this.ContentOffsetBounds;
|
|
947
|
+
const min = horizontal ? b.Left : b.Top, max = horizontal ? b.Right : b.Bottom;
|
|
948
|
+
if ((step < 0 && base <= min) || (step > 0 && base >= max))
|
|
949
|
+
return false; // at the edge: let an outer scroll take it
|
|
950
|
+
let x = this.offsetX, y = this.offsetY;
|
|
951
|
+
if (horizontal)
|
|
952
|
+
x = base + step;
|
|
953
|
+
else
|
|
954
|
+
y = base + step;
|
|
955
|
+
this.ScrollTo(x, y, this.AutoScrollingSpeedMs / 1000, true);
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
}
|