drawnui-react 0.1.0-preview.2 → 0.1.0-preview.21

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.
Files changed (87) hide show
  1. package/README.md +17 -4
  2. package/SKIPPED.md +33 -27
  3. package/dist/controls/AnimatedFramesRenderer.d.ts +7 -0
  4. package/dist/controls/AnimatedFramesRenderer.js +16 -1
  5. package/dist/controls/DrawnGame.d.ts +40 -0
  6. package/dist/controls/DrawnGame.js +64 -0
  7. package/dist/controls/GridStructure.d.ts +16 -2
  8. package/dist/controls/GridStructure.js +40 -4
  9. package/dist/controls/SkiaBackdrop.d.ts +8 -2
  10. package/dist/controls/SkiaBackdrop.js +48 -8
  11. package/dist/controls/SkiaButton.d.ts +19 -2
  12. package/dist/controls/SkiaButton.js +41 -11
  13. package/dist/controls/SkiaCarousel.d.ts +9 -1
  14. package/dist/controls/SkiaCarousel.js +16 -3
  15. package/dist/controls/SkiaCheckbox.d.ts +0 -1
  16. package/dist/controls/SkiaCheckbox.js +0 -6
  17. package/dist/controls/SkiaDrawer.d.ts +2 -0
  18. package/dist/controls/SkiaDrawer.js +2 -0
  19. package/dist/controls/SkiaEditor.d.ts +12 -1
  20. package/dist/controls/SkiaEditor.js +46 -19
  21. package/dist/controls/SkiaGif.d.ts +3 -0
  22. package/dist/controls/SkiaGif.js +12 -2
  23. package/dist/controls/SkiaImage.js +15 -10
  24. package/dist/controls/SkiaLabel.d.ts +54 -3
  25. package/dist/controls/SkiaLabel.js +235 -52
  26. package/dist/controls/SkiaLabelFps.d.ts +16 -0
  27. package/dist/controls/SkiaLabelFps.js +41 -0
  28. package/dist/controls/SkiaLayout.d.ts +64 -7
  29. package/dist/controls/SkiaLayout.js +316 -19
  30. package/dist/controls/SkiaLottie.js +4 -2
  31. package/dist/controls/SkiaProgress.d.ts +2 -1
  32. package/dist/controls/SkiaProgress.js +6 -3
  33. package/dist/controls/SkiaRadioButton.js +4 -3
  34. package/dist/controls/SkiaScroll.d.ts +35 -5
  35. package/dist/controls/SkiaScroll.js +233 -23
  36. package/dist/controls/SkiaScrollBar.d.ts +27 -0
  37. package/dist/controls/SkiaScrollBar.js +51 -2
  38. package/dist/controls/SkiaShaderCarousel.d.ts +5 -0
  39. package/dist/controls/SkiaShaderCarousel.js +5 -0
  40. package/dist/controls/SkiaShape.d.ts +13 -2
  41. package/dist/controls/SkiaShape.js +50 -10
  42. package/dist/controls/SkiaSlider.d.ts +20 -2
  43. package/dist/controls/SkiaSlider.js +59 -5
  44. package/dist/controls/SkiaSprite.d.ts +1 -0
  45. package/dist/controls/SkiaSprite.js +4 -0
  46. package/dist/controls/SkiaSvg.js +1 -1
  47. package/dist/controls/SkiaSwitch.d.ts +0 -1
  48. package/dist/controls/SkiaSwitch.js +0 -6
  49. package/dist/controls/SkiaToggle.d.ts +25 -5
  50. package/dist/controls/SkiaToggle.js +58 -5
  51. package/dist/controls/SnappingLayout.d.ts +12 -1
  52. package/dist/controls/SnappingLayout.js +19 -1
  53. package/dist/core/Accessibility.d.ts +80 -0
  54. package/dist/core/Accessibility.js +365 -8
  55. package/dist/core/Animators.d.ts +37 -1
  56. package/dist/core/Animators.js +102 -9
  57. package/dist/core/Canvas.d.ts +108 -7
  58. package/dist/core/Canvas.js +363 -35
  59. package/dist/core/Easing.d.ts +12 -1
  60. package/dist/core/Easing.js +25 -1
  61. package/dist/core/Gestures.d.ts +48 -2
  62. package/dist/core/Gestures.js +95 -3
  63. package/dist/core/Hover.d.ts +47 -0
  64. package/dist/core/Hover.js +134 -0
  65. package/dist/core/KeyboardManager.d.ts +13 -0
  66. package/dist/core/KeyboardManager.js +18 -0
  67. package/dist/core/ScrollAnimators.js +9 -0
  68. package/dist/core/SkiaControl.d.ts +216 -7
  69. package/dist/core/SkiaControl.js +516 -59
  70. package/dist/core/SkiaEffect.d.ts +17 -0
  71. package/dist/core/SkiaEffect.js +43 -8
  72. package/dist/core/SkiaImageManager.js +1 -1
  73. package/dist/core/Styles.d.ts +51 -0
  74. package/dist/core/Styles.js +92 -0
  75. package/dist/core/Super.d.ts +17 -1
  76. package/dist/core/Super.js +35 -4
  77. package/dist/core/ViewsAdapter.d.ts +7 -2
  78. package/dist/core/ViewsAdapter.js +10 -3
  79. package/dist/index.d.ts +4 -0
  80. package/dist/index.js +4 -0
  81. package/dist/react/SkiaShell.js +33 -14
  82. package/dist/react/index.d.ts +15 -4
  83. package/dist/react/index.js +102 -15
  84. package/dist/react/reconciler.js +34 -12
  85. package/dist/vite/index.d.ts +9 -0
  86. package/dist/vite/index.js +36 -3
  87. package/package.json +33 -3
@@ -12,7 +12,7 @@ export interface ScaledPoint {
12
12
  * Mirrors DrawnUi SkiaScroll (plain content, no header/footer/refresh/virtualization yet).
13
13
  * Offsets are in POINTS and <= 0 while inside bounds (content moves up/left as you scroll).
14
14
  * Physics ported 1:1: deceleration fling (rate = 1 - FrictionScrolled/100) cut at the content edge,
15
- * rubber-band overscroll while dragging, spring bounce back on release, wheel = WheelLineSize per notch.
15
+ * rubber-band overscroll while dragging, spring bounce back on release, wheel = WheelLineSize per notch (an event moves by its share of a notch).
16
16
  */
17
17
  export declare class SkiaScroll extends SkiaControl {
18
18
  static WheelLineSize: number;
@@ -31,7 +31,11 @@ export declare class SkiaScroll extends SkiaControl {
31
31
  AutoScrollingSpeedMs: number;
32
32
  ScrollingSpeedMs: number;
33
33
  IgnoreWrongDirection: boolean;
34
- RespondsToGestures: boolean;
34
+ private respondsToGestures;
35
+ /** False: neither a drag, the wheel nor the keyboard scrolls it (code still can), and it is reported disabled; its content keeps its own taps (drawnui-cross 6w). */
36
+ get RespondsToGestures(): boolean;
37
+ set RespondsToGestures(v: boolean);
38
+ protected AcceptsInput(): boolean;
35
39
  private header?;
36
40
  private footer?;
37
41
  /** A view before the content along the scroll axis; `Tag="Header"` on a JSX child sets it. */
@@ -156,6 +160,10 @@ export declare class SkiaScroll extends SkiaControl {
156
160
  set ViewportOffsetX(value: number);
157
161
  get ViewportOffsetY(): number;
158
162
  set ViewportOffsetY(value: number);
163
+ /** An offset set without a glide or a pan (a jump, a touchpad step): the controls under a still mouse changed. */
164
+ private OffsetJumped;
165
+ /** Hover waits while the content glides, flings, bounces or follows a pan (C# IsScrolling). */
166
+ MovesContent(): boolean;
159
167
  private readonly animatorFlingX;
160
168
  private readonly animatorFlingY;
161
169
  private readonly bounceX;
@@ -214,18 +222,40 @@ export declare class SkiaScroll extends SkiaControl {
214
222
  * templated layout (the recycled list is the scroll's only child; a header goes above the scroll or in Header).
215
223
  */
216
224
  ScrollToIndex(index: number, animate: boolean, option?: RelativePositionType): void;
225
+ /**
226
+ * An open ScrollToIndex (C# OrderedScrollToIndex, DrawnUi.Rust scroll_to_index): a row not measured yet is gone to by
227
+ * the sizes as they are estimated, and the order is aimed again every frame until the row has its real place and the
228
+ * viewport stands on it; an animated move goes on to a moved target in the time it has left, later corrections jump.
229
+ * A press, the wheel or another scroll drops it; while it is open LoadMore waits.
230
+ */
231
+ private indexOrder?;
232
+ private pursuing;
233
+ /** One step of the open order (C# ExecuteScrollToIndexOrder, Rust `pursue`), at the start of every paint. */
234
+ private PursueIndexOrder;
235
+ /** The offset (points) that puts item `index` where `option` says, by the sizes the list knows now; Exact = its place is measured. */
236
+ private AimIndex;
217
237
  private StopAnimators;
218
238
  /** DrawnUi DecelerationRatio: FrictionScrolled / 100, floored at 0.1 friction. */
219
239
  private get DecelerationRatio();
220
240
  private ResetPan;
241
+ private draggedScrollBar?;
242
+ private scrollBarOwnsGesture;
243
+ /**
244
+ * Thumb drag and track press on a ScrollBar / ScrollBarHorizontal with IsDraggable set (C# ProcessScrollBarGestures).
245
+ * A gesture that starts on the bar belongs to it until the next down: the content neither pans nor gets the tap.
246
+ * True when the gesture was handled here.
247
+ */
248
+ protected ProcessScrollBarGestures(args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): boolean;
249
+ private ApplyScrollBarDrag;
221
250
  ProcessGestures(args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): SkiaControl | null;
222
251
  private Bounce;
223
252
  /** DrawnUi StartToFlingFrom + PrepareToFlingAfterInitialized: cut the curve so it stops exactly at the edge. */
224
253
  private StartToFlingFrom;
225
254
  /**
226
- * One wheel notch = WheelLineSize points, hard-clamped, animated over AutoScrollingSpeedMs.
227
- * Notches arriving while the previous one is still animating add onto its target, so a fast wheel
228
- * spin travels N steps instead of restarting from the barely-moved current offset.
255
+ * One wheel notch = WheelLineSize points, hard-clamped, animated over AutoScrollingSpeedMs. An event moves by its share
256
+ * of a notch (Canvas.WheelDeltaPerNotch), so a touchpad's many small events scroll as far as the fingers moved (C#
257
+ * aaa871c3); an event under half a notch moves the content at once (no glide). Events arriving while the previous one is still animating add onto its target, so a fast wheel spin
258
+ * travels N steps instead of restarting from the barely-moved current offset.
229
259
  */
230
260
  private ApplyWheelScroll;
231
261
  }
@@ -9,7 +9,7 @@ import { RubberBandUtils, ScrollFlingAnimator, Spring, SpringWithVelocityAnimato
9
9
  * Mirrors DrawnUi SkiaScroll (plain content, no header/footer/refresh/virtualization yet).
10
10
  * Offsets are in POINTS and <= 0 while inside bounds (content moves up/left as you scroll).
11
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.
12
+ * rubber-band overscroll while dragging, spring bounce back on release, wheel = WheelLineSize per notch (an event moves by its share of a notch).
13
13
  */
14
14
  export class SkiaScroll extends SkiaControl {
15
15
  static WheelLineSize = 150;
@@ -28,7 +28,14 @@ export class SkiaScroll extends SkiaControl {
28
28
  AutoScrollingSpeedMs = 600;
29
29
  ScrollingSpeedMs = 400;
30
30
  IgnoreWrongDirection = false;
31
- RespondsToGestures = true;
31
+ respondsToGestures = true;
32
+ /** False: neither a drag, the wheel nor the keyboard scrolls it (code still can), and it is reported disabled; its content keeps its own taps (drawnui-cross 6w). */
33
+ get RespondsToGestures() { return this.respondsToGestures; }
34
+ set RespondsToGestures(v) { if (this.respondsToGestures !== v) {
35
+ this.respondsToGestures = v;
36
+ this.NotifyAccessibility();
37
+ } }
38
+ AcceptsInput() { return this.respondsToGestures; }
32
39
  // ---- header / footer (C# SkiaScroll.Header / Footer) ----
33
40
  header;
34
41
  footer;
@@ -121,7 +128,7 @@ export class SkiaScroll extends SkiaControl {
121
128
  }
122
129
  /** C# UpdateScrollBarIndicator: pushes progress / thumb ratio / overscroll / scrolling state when any changed. */
123
130
  UpdateScrollBarIndicator() {
124
- const isScrolling = this.IsScrolling || this.IsUserPanning;
131
+ const isScrolling = this.IsScrolling || this.IsUserPanning || !!this.draggedScrollBar;
125
132
  const scale = this.RenderingScale;
126
133
  const viewportH = this.DrawingRect.Height / scale, viewportW = this.DrawingRect.Width / scale;
127
134
  const extentH = this.ContentSize.Units.Height + this.HeaderExtentPts + this.FooterExtentPts, extentW = this.ContentSize.Units.Width + this.HeaderExtentPts + this.FooterExtentPts;
@@ -333,6 +340,8 @@ export class SkiaScroll extends SkiaControl {
333
340
  loadMoreTopArmed = false;
334
341
  /** 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
342
  CheckLoadMore() {
343
+ if (this.indexOrder)
344
+ return; // an open ScrollToIndex has the viewport on its way: the edges are looked at after it
336
345
  const vertical = this.Orientation !== "Horizontal";
337
346
  const offset = vertical ? this.offsetY : this.offsetX;
338
347
  const min = vertical ? this.ContentOffsetBounds.Top : this.ContentOffsetBounds.Left; // most negative offset
@@ -399,12 +408,19 @@ export class SkiaScroll extends SkiaControl {
399
408
  set ViewportOffsetX(value) { if (this.offsetX !== value) {
400
409
  this.offsetX = value;
401
410
  this.OnScrolled();
411
+ this.OffsetJumped();
402
412
  } }
403
413
  get ViewportOffsetY() { return this.offsetY; }
404
414
  set ViewportOffsetY(value) { if (this.offsetY !== value) {
405
415
  this.offsetY = value;
406
416
  this.OnScrolled();
417
+ this.OffsetJumped();
407
418
  } }
419
+ /** An offset set without a glide or a pan (a jump, a touchpad step): the controls under a still mouse changed. */
420
+ OffsetJumped() { if (!this.MovesContent())
421
+ this.Superview?.Hover?.RequestCheck(); }
422
+ /** Hover waits while the content glides, flings, bounces or follows a pan (C# IsScrolling). */
423
+ MovesContent() { return this.IsScrolling || this.IsUserPanning || !!this.draggedScrollBar; }
408
424
  animatorFlingX = new ScrollFlingAnimator(this);
409
425
  animatorFlingY = new ScrollFlingAnimator(this);
410
426
  bounceX = new SpringWithVelocityAnimator(this);
@@ -506,6 +522,7 @@ export class SkiaScroll extends SkiaControl {
506
522
  }
507
523
  InsertSubView(_index, control) { this.AddSubView(control); }
508
524
  RemoveSubView(control) {
525
+ this.Superview?.Hover?.RequestCheck();
509
526
  if (this.content === control)
510
527
  this.Content = undefined;
511
528
  else if (this.header === control)
@@ -560,6 +577,15 @@ export class SkiaScroll extends SkiaControl {
560
577
  const width = Math.max(0, this.ContentSize.Units.Width + (this.Orientation === "Horizontal" ? extra : 0) - viewportW);
561
578
  const height = Math.max(0, this.ContentSize.Units.Height + (this.Orientation !== "Horizontal" ? extra : 0) - viewportH);
562
579
  this.ContentOffsetBounds = new SKRect(-width, -height, 0, 0);
580
+ // C# InitializeViewport: pull an offset the new bounds no longer contain back inside them, here where
581
+ // the bounds change. Shrinking content (fewer items) otherwise leaves the viewport parked past its end
582
+ // with nothing on screen. Skipped under a live pan, a fling / bounce or pull-to-refresh, where being
583
+ // past the edge is intentional (rubber band, refresh indicator).
584
+ if (!this.IsUserPanning && !this.IsScrolling && !this.isRefreshing) {
585
+ const c = this.ClampOffset(this.offsetX, this.offsetY, this.ContentOffsetBounds, true);
586
+ this.ViewportOffsetX = c.X;
587
+ this.ViewportOffsetY = c.Y;
588
+ }
563
589
  this.ArrangeContent();
564
590
  this.CheckLoadMore();
565
591
  }
@@ -617,6 +643,8 @@ export class SkiaScroll extends SkiaControl {
617
643
  }
618
644
  }
619
645
  Paint(ctx) {
646
+ this.Superview?.Hover?.RegisterMover(this);
647
+ this.PursueIndexOrder();
620
648
  const c = this.content;
621
649
  if (!c)
622
650
  return;
@@ -637,9 +665,18 @@ export class SkiaScroll extends SkiaControl {
637
665
  if (this.RefreshEnabled && this.refreshIndicator && this.refreshIndicator.IsVisible && (this.OverScrolled || this.isRefreshing))
638
666
  this.refreshIndicator.Render(ctx);
639
667
  this.UpdateScrollBarIndicator();
640
- for (const bar of [this.scrollBar, this.scrollBarHorizontal])
641
- if (bar && bar.IsVisible && bar.Opacity > 0)
642
- bar.Render(ctx);
668
+ for (const bar of [this.scrollBar, this.scrollBarHorizontal]) {
669
+ if (!bar || !bar.IsVisible || bar.Opacity <= 0)
670
+ continue;
671
+ // the bar is parent-independent: its thumb resizing (squash on a bounce) invalidates the bar alone, so it is
672
+ // re-measured here on draw instead of dragging the scroll and every ancestor through a measure each frame
673
+ if (bar.NeedMeasure) {
674
+ const r = this.DrawingRect;
675
+ bar.Measure(r.Width, r.Height, this.RenderingScale);
676
+ bar.Arrange(r, bar.WidthRequest, bar.HeightRequest, this.RenderingScale);
677
+ }
678
+ bar.Render(ctx);
679
+ }
643
680
  canvas.restoreToCount(saved);
644
681
  }
645
682
  OnScrolled() {
@@ -710,6 +747,8 @@ export class SkiaScroll extends SkiaControl {
710
747
  this.ViewportOffsetY = target;
711
748
  }
712
749
  ScrollTo(x, y, maxSpeedSecs, clamp = true) {
750
+ if (!this.pursuing)
751
+ this.indexOrder = undefined; // another scroll drops an open ScrollToIndex
713
752
  this.StopAnimators(); // also forgets any pending edge bounce: a programmatic scroll never bounces
714
753
  let tx = x, ty = y;
715
754
  if (clamp) {
@@ -742,25 +781,109 @@ export class SkiaScroll extends SkiaControl {
742
781
  else
743
782
  this.ScrollTo(this.offsetX, this.ContentOffsetBounds.Top, maxTimeSecs);
744
783
  }
745
- StopScrolling() { this.StopAnimators(); this.IsUserPanning = false; }
784
+ StopScrolling() { this.indexOrder = undefined; this.StopAnimators(); this.IsUserPanning = false; }
746
785
  /**
747
786
  * Scrolls so that item `index` is at the viewport start (or end). Like DrawnUi, Content must BE the
748
787
  * templated layout (the recycled list is the scroll's only child; a header goes above the scroll or in Header).
749
788
  */
750
789
  ScrollToIndex(index, animate, option = "Start") {
790
+ this.indexOrder = { Index: index, Option: option, Secs: animate ? this.ScrollingSpeedMs / 1000 : 0, Stalled: 0 };
791
+ this.PursueIndexOrder();
792
+ this.Repaint();
793
+ }
794
+ /**
795
+ * An open ScrollToIndex (C# OrderedScrollToIndex, DrawnUi.Rust scroll_to_index): a row not measured yet is gone to by
796
+ * the sizes as they are estimated, and the order is aimed again every frame until the row has its real place and the
797
+ * viewport stands on it; an animated move goes on to a moved target in the time it has left, later corrections jump.
798
+ * A press, the wheel or another scroll drops it; while it is open LoadMore waits.
799
+ */
800
+ indexOrder;
801
+ pursuing = false;
802
+ /** One step of the open order (C# ExecuteScrollToIndexOrder, Rust `pursue`), at the start of every paint. */
803
+ PursueIndexOrder() {
804
+ const order = this.indexOrder;
805
+ if (!order)
806
+ return;
807
+ const horizontal = this.Orientation === "Horizontal";
808
+ const before = horizontal ? this.offsetX : this.offsetY;
809
+ const running = horizontal ? this.animatorFlingX : this.animatorFlingY;
810
+ const aim = this.AimIndex(order.Index, order.Option);
811
+ let done = false;
812
+ if (aim) {
813
+ const c = this.ClampOffset(horizontal ? aim.Offset : this.offsetX, horizontal ? this.offsetY : aim.Offset, this.ContentOffsetBounds, true);
814
+ const target = horizontal ? c.X : c.Y;
815
+ const runningTo = horizontal ? this.scrollToTargetX : this.scrollToTargetY;
816
+ const move = (to, secs) => {
817
+ this.pursuing = true;
818
+ try {
819
+ this.ScrollTo(horizontal ? to : this.offsetX, horizontal ? this.offsetY : to, secs, true);
820
+ }
821
+ finally {
822
+ this.pursuing = false;
823
+ }
824
+ };
825
+ if (running.IsRunning && runningTo !== null) {
826
+ if (Math.abs(runningTo - target) > 0.5) {
827
+ // on the way: the move goes on to the new target in the time it has left
828
+ const now = this.Superview?.FrameTimeNanos || performance.now() * 1_000_000;
829
+ move(target, running.StartFrameTimeNanos ? Math.max(0.016, running.Speed - (now - running.StartFrameTimeNanos) / 1e9) : running.Speed);
830
+ running.StartFrameTimeNanos = running.LastFrameTimeNanos = now;
831
+ }
832
+ }
833
+ else if (Math.abs(before - target) <= 0.5) {
834
+ if (horizontal)
835
+ this.ViewportOffsetX = target;
836
+ else
837
+ this.ViewportOffsetY = target;
838
+ done = aim.Exact;
839
+ }
840
+ else {
841
+ move(target, order.Secs);
842
+ order.Secs = 0;
843
+ }
844
+ }
845
+ // no list yet, or a row that never gets its real place: not forever
846
+ order.Stalled = (horizontal ? this.offsetX : this.offsetY) === before && !running.IsRunning ? order.Stalled + 1 : 0;
847
+ if (done || order.Stalled >= 10) {
848
+ this.indexOrder = undefined;
849
+ this.CheckLoadMore();
850
+ return;
851
+ }
852
+ this.Repaint(); // the next frame aims again
853
+ }
854
+ /** The offset (points) that puts item `index` where `option` says, by the sizes the list knows now; Exact = its place is measured. */
855
+ AimIndex(index, option) {
751
856
  const layout = this.content instanceof SkiaLayout && this.content.IsTemplated ? this.content : undefined;
752
857
  const items = layout?.ItemsSource;
753
858
  if (!layout || !items || items.length === 0 || !this.content)
754
- return;
859
+ return undefined;
755
860
  const i = Math.max(0, Math.min(items.length - 1, index));
756
861
  const scale = this.RenderingScale;
757
- const layoutTopPts = (layout.DrawingRect.Top - this.content.DrawingRect.Top) / scale;
758
- let target = layoutTopPts + layout.GetItemOffsetPixels(i) / scale;
862
+ const horizontal = this.Orientation === "Horizontal";
863
+ const viewport = (horizontal ? this.DrawingRect.Width : this.DrawingRect.Height) / scale;
864
+ // Item start and size in points, relative to the content start along the scroll axis.
865
+ let itemStart, itemSize;
866
+ if (!horizontal && layout.Type === "Column" && layout.Split <= 1) {
867
+ // The virtualized list: cells past the viewport are not realized, the structure knows their offsets.
868
+ itemStart = (layout.DrawingRect.Top - this.content.DrawingRect.Top + layout.GetItemOffsetPixels(i)) / scale;
869
+ itemSize = (layout.GetItemOffsetPixels(i + 1) - layout.GetItemOffsetPixels(i)) / scale;
870
+ }
871
+ else {
872
+ // A templated Row / Wrap / Grid realizes every item (C# non-list layouts are not virtualized): read the cell's
873
+ // arranged rect, like C# reads the structure cell's Destination.
874
+ const cell = layout.ChildrenFactory.GetViewForIndex(i);
875
+ if (!cell)
876
+ return undefined;
877
+ const r = cell.DrawingRect, c = this.content.DrawingRect;
878
+ itemStart = (horizontal ? r.Left - c.Left : r.Top - c.Top) / scale;
879
+ itemSize = (horizontal ? r.Width : r.Height) / scale;
880
+ }
881
+ let target = itemStart;
759
882
  if (option === "End")
760
- target -= this.DrawingRect.Height / scale - layout.GetItemOffsetPixels(i + 1) / scale + layout.GetItemOffsetPixels(i) / scale;
883
+ target -= viewport - itemSize;
761
884
  else if (option === "Center")
762
- target -= this.DrawingRect.Height / scale / 2;
763
- this.ScrollTo(this.offsetX, -target, animate ? this.ScrollingSpeedMs / 1000 : 0, true);
885
+ target -= (viewport - itemSize) / 2;
886
+ return { Offset: -target, Exact: layout.IsItemMeasured(i) };
764
887
  }
765
888
  StopAnimators() {
766
889
  this.flingEdgeX = null;
@@ -783,11 +906,70 @@ export class SkiaScroll extends SkiaControl {
783
906
  this.panningLastDelta = SKPoint.Empty;
784
907
  this.panningCurrentOffsetPts = new SKPoint(this.offsetX, this.offsetY);
785
908
  }
909
+ draggedScrollBar;
910
+ scrollBarOwnsGesture = false;
911
+ /**
912
+ * Thumb drag and track press on a ScrollBar / ScrollBarHorizontal with IsDraggable set (C# ProcessScrollBarGestures).
913
+ * A gesture that starts on the bar belongs to it until the next down: the content neither pans nor gets the tap.
914
+ * True when the gesture was handled here.
915
+ */
916
+ ProcessScrollBarGestures(args, apply) {
917
+ if (args.Type === "Down") {
918
+ this.draggedScrollBar = undefined;
919
+ this.scrollBarOwnsGesture = false;
920
+ const draggable = (b) => b && b.IsDraggable && b.BeginDrag && b.GetDragProgress ? b : undefined;
921
+ const v = draggable(this.scrollBar), h = draggable(this.scrollBarHorizontal);
922
+ if (!v && !h)
923
+ return false;
924
+ const x = apply.MappedLocation.X + apply.ChildOffset.X, y = apply.MappedLocation.Y + apply.ChildOffset.Y;
925
+ if (v && v.BeginDrag(x, y))
926
+ this.draggedScrollBar = v;
927
+ else if (h && h.BeginDrag(x, y))
928
+ this.draggedScrollBar = h;
929
+ if (!this.draggedScrollBar)
930
+ return false;
931
+ this.scrollBarOwnsGesture = true;
932
+ this.StopScrolling();
933
+ this.ApplyScrollBarDrag(x, y);
934
+ args.Event.Handled = true; // used: a touch on the bar is claimed, the page must not take the drag
935
+ return true;
936
+ }
937
+ if (!this.scrollBarOwnsGesture)
938
+ return false;
939
+ if (!this.draggedScrollBar)
940
+ return args.Type === "Tapped" || args.Type === "LongPressing"; // released already: only the tap that follows belongs to the bar
941
+ if (args.Type === "Panning") {
942
+ this.ApplyScrollBarDrag(apply.MappedLocation.X + apply.ChildOffset.X, apply.MappedLocation.Y + apply.ChildOffset.Y);
943
+ args.Event.Handled = true;
944
+ }
945
+ else if (args.Type === "Up") {
946
+ this.draggedScrollBar = undefined;
947
+ this.scrollBarLast = undefined; // push the idle state so an auto-hiding bar fades again
948
+ this.scrollBarHLast = undefined;
949
+ this.Repaint();
950
+ }
951
+ return true;
952
+ }
953
+ ApplyScrollBarDrag(x, y) {
954
+ const bar = this.draggedScrollBar;
955
+ if (!bar)
956
+ return;
957
+ const progress = bar.GetDragProgress(x, y);
958
+ const b = this.ContentOffsetBounds;
959
+ if (bar === this.scrollBarHorizontal)
960
+ this.ViewportOffsetX = this.ClampOffset(-progress * b.Width, this.offsetY, b, true).X;
961
+ else
962
+ this.ViewportOffsetY = this.ClampOffset(this.offsetX, -progress * b.Height, b, true).Y;
963
+ this.Repaint();
964
+ }
786
965
  ProcessGestures(args, apply) {
787
966
  const consumedDefault = this.BlockGesturesBelow ? this : null;
788
967
  const scale = this.RenderingScale;
789
968
  const e = args.Event;
969
+ if (this.ProcessScrollBarGestures(args, apply))
970
+ return this;
790
971
  if (args.Type === "Down") {
972
+ this.indexOrder = undefined; // a press drops an open ScrollToIndex
791
973
  this.hadDown = true;
792
974
  this.snapped = false;
793
975
  if (this.RespondsToGestures) {
@@ -801,15 +983,21 @@ export class SkiaScroll extends SkiaControl {
801
983
  const child = super.ProcessGestures(args, apply);
802
984
  if (child && child !== this)
803
985
  return child;
804
- if (!this.RespondsToGestures)
986
+ if (!this.RespondsToGestures || this.Orientation === "Neither")
987
+ return child ?? consumedDefault; // Neither: not used, a parent scroll may take it
988
+ // a vertical scroll leaves horizontal events (the sideways part of a diagonal swipe) to whatever is around it
989
+ if (e.Wheel.IsHorizontal && this.Orientation === "Vertical")
805
990
  return child ?? consumedDefault;
806
- return this.ApplyWheelScroll(e.Wheel.Delta) ? this : consumedDefault;
991
+ if (!this.ApplyWheelScroll(e.Wheel.Delta))
992
+ return consumedDefault; // at its edge: not used, the page may take it
993
+ e.Handled = true;
994
+ return this;
807
995
  }
808
996
  this.velocityY = e.Distance.Velocity.Y / scale;
809
997
  this.velocityX = e.Distance.Velocity.X / scale;
810
998
  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
999
  (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) {
1000
+ if (args.Type === "Panning" && this.RespondsToGestures && this.hadDown && this.Orientation !== "Neither") {
813
1001
  if (!this.IsUserPanning) {
814
1002
  // A child may own the pan (slider, nested horizontal scroll); ask once before taking over.
815
1003
  const childConsumed = super.ProcessGestures(args, apply);
@@ -860,6 +1048,12 @@ export class SkiaScroll extends SkiaControl {
860
1048
  const swipeThreshold = SkiaScroll.ThesholdSwipeOnUp * scale;
861
1049
  let vx = finalVelocity.X * this.ChangeVelocityScrolled;
862
1050
  let vy = finalVelocity.Y * this.ChangeVelocityScrolled;
1051
+ // a cancelled pointer (the browser took the pan to scroll the page, or a parent intercepted it) settles in place:
1052
+ // flinging from the few moves that arrived before the cancel would scroll the list while the page scrolls
1053
+ if (e.Type === "Cancelled") {
1054
+ vx = 0;
1055
+ vy = 0;
1056
+ }
863
1057
  let fling = false;
864
1058
  if (this.OverScrolled) {
865
1059
  const rest = this.ClampOffset(this.offsetX, this.offsetY, this.ContentOffsetBounds, true);
@@ -934,15 +1128,20 @@ export class SkiaScroll extends SkiaControl {
934
1128
  return true;
935
1129
  }
936
1130
  /**
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.
1131
+ * One wheel notch = WheelLineSize points, hard-clamped, animated over AutoScrollingSpeedMs. An event moves by its share
1132
+ * of a notch (Canvas.WheelDeltaPerNotch), so a touchpad's many small events scroll as far as the fingers moved (C#
1133
+ * aaa871c3); an event under half a notch moves the content at once (no glide). Events arriving while the previous one is still animating add onto its target, so a fast wheel spin
1134
+ * travels N steps instead of restarting from the barely-moved current offset.
940
1135
  */
941
1136
  ApplyWheelScroll(delta) {
942
- const step = SkiaScroll.WheelLineSize * -Math.sign(delta); // wheel down = content up = more negative offset
1137
+ const perNotch = this.Superview?.WheelDeltaPerNotch ?? 0;
1138
+ const lines = perNotch > 0 ? delta / perNotch : Math.sign(delta);
1139
+ const step = -SkiaScroll.WheelLineSize * lines; // wheel down = content up = more negative offset
943
1140
  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;
1141
+ // the target the running wheel / ScrollTo move lands on: Parameters.Destination is where its deceleration curve would
1142
+ // end if it ran on, beyond that target, so continuing from it overshot more with every event (a touchpad swipe flew away)
1143
+ const running = horizontal ? this.animatorFlingX : this.animatorFlingY, target = horizontal ? this.scrollToTargetX : this.scrollToTargetY;
1144
+ const base = running.IsRunning && target !== null ? target : horizontal ? this.offsetX : this.offsetY;
946
1145
  const b = this.ContentOffsetBounds;
947
1146
  const min = horizontal ? b.Left : b.Top, max = horizontal ? b.Right : b.Bottom;
948
1147
  if ((step < 0 && base <= min) || (step > 0 && base >= max))
@@ -952,7 +1151,18 @@ export class SkiaScroll extends SkiaControl {
952
1151
  x = base + step;
953
1152
  else
954
1153
  y = base + step;
955
- this.ScrollTo(x, y, this.AutoScrollingSpeedMs / 1000, true);
1154
+ // a notch glides over AutoScrollingSpeedMs; smaller events (a touchpad, a free-spinning or high-resolution wheel) come
1155
+ // as a stream and move the content at once, as the browser scrolls a page: easing each one over 600 ms kept the
1156
+ // content behind the fingers, a visible delay before it started to move
1157
+ const glide = perNotch <= 0 || Math.abs(delta) >= perNotch / 2;
1158
+ this.ScrollTo(x, y, glide ? this.AutoScrollingSpeedMs / 1000 : 0, true);
1159
+ // a restarted glide starts at the event, not at its first frame: a fast trackpad swipe sends a notch-sized event
1160
+ // before every frame, and a glide drawn at progress 0 on each of them left the content standing still until the
1161
+ // events stopped, then it jumped (DrawnUi.Rust dfe36fe). Never before the last frame, at most one frame back.
1162
+ if (glide && running.IsRunning) {
1163
+ const event = performance.now() * 1_000_000;
1164
+ running.StartFrameTimeNanos = running.LastFrameTimeNanos = Math.max(this.Superview?.FrameTimeNanos ?? 0, event - 16_666_667);
1165
+ }
956
1166
  return true;
957
1167
  }
958
1168
  }
@@ -11,6 +11,12 @@ export type ScrollBarDock = "End" | "Start";
11
11
  */
12
12
  export interface IScrollBar {
13
13
  SetScrollProgress(orientation: ScrollOrientation, progress: number, thumbSizeRatio: number, overscrollPts: number, isScrolling: boolean): void;
14
+ /** Optional desktop behavior: the bar takes the gesture that starts on it (thumb drag, track press). */
15
+ IsDraggable?: boolean;
16
+ /** Canvas pixels; true when the point is on the bar and a drag starts. */
17
+ BeginDrag?(x: number, y: number): boolean;
18
+ /** Scroll progress 0..1 for the pointer during a drag started by BeginDrag. */
19
+ GetDragProgress?(x: number, y: number): number;
14
20
  }
15
21
  /**
16
22
  * Mirrors DrawnUi SkiaScrollBar: thin rounded thumb over an optional track, docked at the right edge for vertical
@@ -41,6 +47,19 @@ export declare class SkiaScrollBar extends SkiaLayout implements IScrollBar {
41
47
  /** Fade out after scrolling stops. */
42
48
  AutoHide: boolean;
43
49
  HideDelaySecs: number;
50
+ /** Duration in seconds of the fade-out once HideDelaySecs has passed. */
51
+ HideDurationSecs: number;
52
+ /**
53
+ * Lets the user drag the thumb and press the track to jump there (desktop scroll bar behavior). The owning
54
+ * SkiaScroll then takes the whole gesture, from down to up, when it starts on the bar. Default false: the bar only
55
+ * indicates and every gesture passes through to the content.
56
+ */
57
+ IsDraggable: boolean;
58
+ /** Extra grab area in points on both sides of the bar, across the scrolling axis, so a thin bar is easy to hit with a mouse. */
59
+ GrabPadding: number;
60
+ private thumbOffsetPts;
61
+ private dragGrabPx;
62
+ private hasTravel;
44
63
  protected readonly thumb: SkiaShape;
45
64
  protected readonly track: SkiaShape;
46
65
  private hideTimer;
@@ -52,5 +71,13 @@ export declare class SkiaScrollBar extends SkiaLayout implements IScrollBar {
52
71
  protected ApplyOrientation(): void;
53
72
  SetScrollProgress(orientation: ScrollOrientation, progress: number, thumbSizeRatio: number, overscrollPts: number, isScrolling: boolean): void;
54
73
  private CancelHide;
74
+ /**
75
+ * Called by the owning scroll on touch down, coordinates in canvas pixels. True when the point is on the bar (track
76
+ * plus GrabPadding) and a drag starts: on the thumb it keeps the grab point, on the track the thumb jumps to center
77
+ * under the pointer.
78
+ */
79
+ BeginDrag(x: number, y: number): boolean;
80
+ /** Scroll progress 0..1 for the pointer position during a drag started by BeginDrag, coordinates in canvas pixels. */
81
+ GetDragProgress(x: number, y: number): number;
55
82
  protected OnDisposing(): void;
56
83
  }
@@ -1,4 +1,4 @@
1
- import { Colors, Thickness } from "../core/Types";
1
+ import { Colors, SKRect, Thickness } from "../core/Types";
2
2
  import { SkiaLayout } from "./SkiaLayout";
3
3
  import { SkiaShape } from "./SkiaShape";
4
4
  /**
@@ -49,6 +49,19 @@ export class SkiaScrollBar extends SkiaLayout {
49
49
  /** Fade out after scrolling stops. */
50
50
  AutoHide = true;
51
51
  HideDelaySecs = 1;
52
+ /** Duration in seconds of the fade-out once HideDelaySecs has passed. */
53
+ HideDurationSecs = 0.25;
54
+ /**
55
+ * Lets the user drag the thumb and press the track to jump there (desktop scroll bar behavior). The owning
56
+ * SkiaScroll then takes the whole gesture, from down to up, when it starts on the bar. Default false: the bar only
57
+ * indicates and every gesture passes through to the content.
58
+ */
59
+ IsDraggable = false;
60
+ /** Extra grab area in points on both sides of the bar, across the scrolling axis, so a thin bar is easy to hit with a mouse. */
61
+ GrabPadding = 8;
62
+ thumbOffsetPts = 0;
63
+ dragGrabPx = 0;
64
+ hasTravel = false;
52
65
  thumb = new SkiaShape();
53
66
  track = new SkiaShape();
54
67
  hideTimer = 0;
@@ -62,6 +75,7 @@ export class SkiaScrollBar extends SkiaLayout {
62
75
  this.HorizontalOptions = "Fill";
63
76
  this.VerticalOptions = "Fill";
64
77
  this.InputTransparent = true; // display only, gestures pass through
78
+ this.IsParentIndependent = true; // the bar's look (thumb squashed on overscroll) never changes the layout around it
65
79
  this.Opacity = 0; // hidden until the first scroll
66
80
  this.track.Type = "Rectangle";
67
81
  this.track.BackgroundColor = this.trackColor;
@@ -127,6 +141,8 @@ export class SkiaScrollBar extends SkiaLayout {
127
141
  thumbLen = Math.max(this.MinThumbSize / 2, thumbLen - Math.abs(overscrollPts)); // squash on bounce
128
142
  const travel = track - thumbLen;
129
143
  const offset = Math.max(0, Math.min(1, progress)) * travel;
144
+ this.thumbOffsetPts = offset;
145
+ this.hasTravel = travel > 0;
130
146
  if (Math.abs(thumbLen - this.lastThumbLen) > 0.5) {
131
147
  this.lastThumbLen = thumbLen;
132
148
  if (orientation === "Horizontal")
@@ -142,7 +158,7 @@ export class SkiaScrollBar extends SkiaLayout {
142
158
  this.CancelHide();
143
159
  this.Opacity = 1;
144
160
  if (this.AutoHide && !isScrolling) {
145
- this.hideTimer = window.setTimeout(() => { this.hideTimer = 0; this.hideAbort = new AbortController(); void this.FadeToAsync(0, 250, undefined, this.hideAbort.signal); }, this.HideDelaySecs * 1000);
161
+ this.hideTimer = window.setTimeout(() => { this.hideTimer = 0; this.hideAbort = new AbortController(); void this.FadeToAsync(0, Math.max(0, this.HideDurationSecs * 1000), undefined, this.hideAbort.signal); }, this.HideDelaySecs * 1000);
146
162
  }
147
163
  this.RepaintComposition();
148
164
  }
@@ -154,5 +170,38 @@ export class SkiaScrollBar extends SkiaLayout {
154
170
  this.hideAbort?.abort();
155
171
  this.hideAbort = undefined;
156
172
  }
173
+ /**
174
+ * Called by the owning scroll on touch down, coordinates in canvas pixels. True when the point is on the bar (track
175
+ * plus GrabPadding) and a drag starts: on the thumb it keeps the grab point, on the track the thumb jumps to center
176
+ * under the pointer.
177
+ */
178
+ BeginDrag(x, y) {
179
+ if (!this.IsDraggable || !this.hasTravel)
180
+ return false;
181
+ const scale = this.RenderingScale || 1;
182
+ const track = this.track.DrawingRect;
183
+ const pad = this.GrabPadding * scale;
184
+ const vertical = this.orientation !== "Horizontal";
185
+ const hit = vertical ? new SKRect(track.Left - pad, track.Top, track.Right + pad, track.Bottom) : new SKRect(track.Left, track.Top - pad, track.Right, track.Bottom + pad);
186
+ if (x < hit.Left || x > hit.Right || y < hit.Top || y > hit.Bottom)
187
+ return false;
188
+ // an auto-hidden bar that gets grabbed shows again right away: never drag something invisible
189
+ this.CancelHide();
190
+ this.Opacity = 1;
191
+ const pos = vertical ? y - track.Top : x - track.Left;
192
+ const thumbStart = this.thumbOffsetPts * scale, thumbLen = this.lastThumbLen * scale;
193
+ this.dragGrabPx = pos >= thumbStart && pos <= thumbStart + thumbLen ? pos - thumbStart : thumbLen / 2;
194
+ this.RepaintComposition();
195
+ return true;
196
+ }
197
+ /** Scroll progress 0..1 for the pointer position during a drag started by BeginDrag, coordinates in canvas pixels. */
198
+ GetDragProgress(x, y) {
199
+ const scale = this.RenderingScale || 1;
200
+ const track = this.track.DrawingRect;
201
+ const vertical = this.orientation !== "Horizontal";
202
+ const pos = (vertical ? y - track.Top : x - track.Left) - this.dragGrabPx;
203
+ const travel = (vertical ? track.Height : track.Width) - this.lastThumbLen * scale;
204
+ return travel > 0 ? Math.max(0, Math.min(1, pos / travel)) : 0;
205
+ }
157
206
  OnDisposing() { this.CancelHide(); super.OnDisposing(); }
158
207
  }
@@ -56,6 +56,11 @@ export declare class SkiaShaderCarousel extends SkiaCarousel {
56
56
  protected OnScrollProgressChanged(): void;
57
57
  /** Slides never move: the transition effect renders the change (C# AnimateVisibleChild no-op). */
58
58
  protected SlideOffset(_offset: SKPoint): SKPoint;
59
+ /**
60
+ * Slides all sit on the same rect, so only the selected one may take gestures: otherwise the topmost slide in the
61
+ * gesture list (the last on-screen one, a neighbour exactly one width away counts as on screen) caught every tap.
62
+ */
63
+ protected IsSlideHitTestable(index: number): boolean;
59
64
  ProcessGestures(args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): SkiaControl | null;
60
65
  /**
61
66
  * Gesture targeting (C#): one gesture moves at most one slide from the slide it started on; a flick moves one slide
@@ -145,6 +145,11 @@ export class SkiaShaderCarousel extends SkiaCarousel {
145
145
  }
146
146
  /** Slides never move: the transition effect renders the change (C# AnimateVisibleChild no-op). */
147
147
  SlideOffset(_offset) { return SKPoint.Empty; }
148
+ /**
149
+ * Slides all sit on the same rect, so only the selected one may take gestures: otherwise the topmost slide in the
150
+ * gesture list (the last on-screen one, a neighbour exactly one width away counts as on screen) caught every tap.
151
+ */
152
+ IsSlideHitTestable(index) { return index === this.SelectedIndex; }
148
153
  ProcessGestures(args, apply) {
149
154
  if (args.Type === "Down") {
150
155
  const interrupted = this.InTransition;