drawnui-react 0.1.0-preview.1 → 0.1.0-preview.10

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 (45) hide show
  1. package/README.md +87 -29
  2. package/SKIPPED.md +20 -15
  3. package/dist/controls/SkiaButton.d.ts +4 -1
  4. package/dist/controls/SkiaButton.js +20 -7
  5. package/dist/controls/SkiaCarousel.d.ts +6 -0
  6. package/dist/controls/SkiaCarousel.js +7 -1
  7. package/dist/controls/SkiaCheckbox.d.ts +0 -1
  8. package/dist/controls/SkiaCheckbox.js +0 -6
  9. package/dist/controls/SkiaEditor.js +8 -0
  10. package/dist/controls/SkiaLabel.d.ts +2 -0
  11. package/dist/controls/SkiaLabel.js +9 -0
  12. package/dist/controls/SkiaLayout.d.ts +10 -0
  13. package/dist/controls/SkiaLayout.js +64 -1
  14. package/dist/controls/SkiaRadioButton.js +1 -2
  15. package/dist/controls/SkiaScroll.d.ts +9 -0
  16. package/dist/controls/SkiaScroll.js +120 -13
  17. package/dist/controls/SkiaScrollBar.d.ts +27 -0
  18. package/dist/controls/SkiaScrollBar.js +51 -2
  19. package/dist/controls/SkiaShaderCarousel.d.ts +5 -0
  20. package/dist/controls/SkiaShaderCarousel.js +5 -0
  21. package/dist/controls/SkiaSwitch.d.ts +0 -1
  22. package/dist/controls/SkiaSwitch.js +0 -6
  23. package/dist/controls/SkiaToggle.d.ts +18 -4
  24. package/dist/controls/SkiaToggle.js +45 -3
  25. package/dist/core/Canvas.d.ts +82 -4
  26. package/dist/core/Canvas.js +255 -9
  27. package/dist/core/Gestures.d.ts +47 -1
  28. package/dist/core/Gestures.js +43 -1
  29. package/dist/core/SkiaControl.d.ts +30 -1
  30. package/dist/core/SkiaControl.js +59 -3
  31. package/dist/core/Styles.d.ts +51 -0
  32. package/dist/core/Styles.js +92 -0
  33. package/dist/core/Super.d.ts +10 -1
  34. package/dist/core/Super.js +20 -3
  35. package/dist/core/Types.d.ts +7 -2
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +1 -0
  38. package/dist/react/SkiaShell.js +5 -2
  39. package/dist/react/index.d.ts +22 -4
  40. package/dist/react/index.js +25 -4
  41. package/dist/react/reconciler.js +2 -0
  42. package/dist/vite/index.d.ts +74 -0
  43. package/dist/vite/index.js +204 -0
  44. package/package.json +35 -4
  45. package/PARITY.md +0 -287
@@ -87,7 +87,11 @@ export class SkiaLayout extends SkiaControl {
87
87
  */
88
88
  ApplyIncrementalChange(old, items) {
89
89
  const n = items.length, o = old.length;
90
- if (n <= o || o === 0)
90
+ if (o === 0)
91
+ return false;
92
+ if (n === o)
93
+ return this.ApplyReorderChange(old, items);
94
+ if (n < o)
91
95
  return false;
92
96
  const k = n - o;
93
97
  const scale = this.RenderingScale, gap = this.Spacing * scale, w = this.measuredWidthPx;
@@ -143,6 +147,65 @@ export class SkiaLayout extends SkiaControl {
143
147
  }
144
148
  return false;
145
149
  }
150
+ /**
151
+ * Same items in another order — a drag-to-reorder, or any code writing a permuted array. DrawnUi handles the
152
+ * matching `ObservableCollection.Move` by keeping the structure and only rebinding the contexts, because its
153
+ * arrange re-flows each row from its bound view (`HandleStructurePreservingMove` / `ApplyMoveChange`, 2026-09-08).
154
+ * The measured heights here are index-keyed and authoritative, so they travel with their items instead: nothing is
155
+ * remeasured, the extent cannot change, and the scroll keeps its offset. Rebuilding instead would reset
156
+ * `MeasureVisible` to "exact for the first screen, estimated after" and throw a scrolled list back towards the top.
157
+ * Returns false when the two arrays are not a permutation of each other, so a real content change still rebuilds.
158
+ */
159
+ ApplyReorderChange(old, items) {
160
+ const n = items.length;
161
+ if (this.MeasureItemsStrategy === "MeasureAll" && this.itemHeights.length !== n)
162
+ return false;
163
+ if (this.MeasureItemsStrategy === "MeasureVisible" && this.mvHeights.length !== n)
164
+ return false;
165
+ const moved = [];
166
+ for (let i = 0; i < n; i++)
167
+ if (old[i] !== items[i])
168
+ moved.push(i);
169
+ if (moved.length === 0) {
170
+ this.ChildrenFactory.UpdateItems(items);
171
+ return true;
172
+ } // same order, new array reference
173
+ // every slot that changed must hold an item that was in one of those slots before, otherwise this is not a reorder
174
+ const free = new Map();
175
+ for (const i of moved) {
176
+ const q = free.get(old[i]);
177
+ if (q)
178
+ q.push(i);
179
+ else
180
+ free.set(old[i], [i]);
181
+ }
182
+ const from = new Int32Array(n);
183
+ for (let i = 0; i < n; i++)
184
+ from[i] = i;
185
+ for (const i of moved) {
186
+ const q = free.get(items[i]);
187
+ if (!q || q.length === 0)
188
+ return false;
189
+ from[i] = q.shift();
190
+ }
191
+ if (this.MeasureItemsStrategy === "MeasureAll") {
192
+ const was = this.itemHeights;
193
+ this.itemHeights = Array.from({ length: n }, (_, i) => was[from[i]]);
194
+ }
195
+ else if (this.MeasureItemsStrategy === "MeasureVisible") {
196
+ const was = this.mvHeights, heights = new Float64Array(n);
197
+ for (let i = 0; i < n; i++)
198
+ heights[i] = was[from[i]];
199
+ this.mvHeights = heights;
200
+ // an item that was never measured can land inside the exact prefix: rebuild it over the leading measured run
201
+ this.mvPrefix = new Float64Array(n + 1);
202
+ this.mvMeasured = 0;
203
+ this.MvExtendPrefix(this.Spacing * this.RenderingScale);
204
+ }
205
+ // MeasureFirst has one height for every row, so a reorder is a pure rebind there (same as the C# note).
206
+ this.ChildrenFactory.UpdateItems(items);
207
+ return true;
208
+ }
146
209
  /** Factory creating one cell (DrawnUi DataTemplate). Cells receive the item as BindingContext. */
147
210
  get ItemTemplate() { return this.itemTemplate; }
148
211
  set ItemTemplate(value) {
@@ -37,8 +37,7 @@ export class SkiaRadioButton extends SkiaToggle {
37
37
  return table[this.UsingControlStyle]?.[name];
38
38
  }
39
39
  CreateDefaultContent() {
40
- if (this.MinimumHeightRequest < 0)
41
- this.MinimumHeightRequest = 24;
40
+ this.SetMinimumContentHeight(24);
42
41
  const s = this.usingStyle;
43
42
  const size = s === "Unset" ? 18 : 20;
44
43
  const box = new SkiaLayout();
@@ -218,6 +218,15 @@ export declare class SkiaScroll extends SkiaControl {
218
218
  /** DrawnUi DecelerationRatio: FrictionScrolled / 100, floored at 0.1 friction. */
219
219
  private get DecelerationRatio();
220
220
  private ResetPan;
221
+ private draggedScrollBar?;
222
+ private scrollBarOwnsGesture;
223
+ /**
224
+ * Thumb drag and track press on a ScrollBar / ScrollBarHorizontal with IsDraggable set (C# ProcessScrollBarGestures).
225
+ * A gesture that starts on the bar belongs to it until the next down: the content neither pans nor gets the tap.
226
+ * True when the gesture was handled here.
227
+ */
228
+ protected ProcessScrollBarGestures(args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): boolean;
229
+ private ApplyScrollBarDrag;
221
230
  ProcessGestures(args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): SkiaControl | null;
222
231
  private Bounce;
223
232
  /** DrawnUi StartToFlingFrom + PrepareToFlingAfterInitialized: cut the curve so it stops exactly at the edge. */
@@ -121,7 +121,7 @@ export class SkiaScroll extends SkiaControl {
121
121
  }
122
122
  /** C# UpdateScrollBarIndicator: pushes progress / thumb ratio / overscroll / scrolling state when any changed. */
123
123
  UpdateScrollBarIndicator() {
124
- const isScrolling = this.IsScrolling || this.IsUserPanning;
124
+ const isScrolling = this.IsScrolling || this.IsUserPanning || !!this.draggedScrollBar;
125
125
  const scale = this.RenderingScale;
126
126
  const viewportH = this.DrawingRect.Height / scale, viewportW = this.DrawingRect.Width / scale;
127
127
  const extentH = this.ContentSize.Units.Height + this.HeaderExtentPts + this.FooterExtentPts, extentW = this.ContentSize.Units.Width + this.HeaderExtentPts + this.FooterExtentPts;
@@ -560,6 +560,15 @@ export class SkiaScroll extends SkiaControl {
560
560
  const width = Math.max(0, this.ContentSize.Units.Width + (this.Orientation === "Horizontal" ? extra : 0) - viewportW);
561
561
  const height = Math.max(0, this.ContentSize.Units.Height + (this.Orientation !== "Horizontal" ? extra : 0) - viewportH);
562
562
  this.ContentOffsetBounds = new SKRect(-width, -height, 0, 0);
563
+ // C# InitializeViewport: pull an offset the new bounds no longer contain back inside them, here where
564
+ // the bounds change. Shrinking content (fewer items) otherwise leaves the viewport parked past its end
565
+ // with nothing on screen. Skipped under a live pan, a fling / bounce or pull-to-refresh, where being
566
+ // past the edge is intentional (rubber band, refresh indicator).
567
+ if (!this.IsUserPanning && !this.IsScrolling && !this.isRefreshing) {
568
+ const c = this.ClampOffset(this.offsetX, this.offsetY, this.ContentOffsetBounds, true);
569
+ this.ViewportOffsetX = c.X;
570
+ this.ViewportOffsetY = c.Y;
571
+ }
563
572
  this.ArrangeContent();
564
573
  this.CheckLoadMore();
565
574
  }
@@ -637,9 +646,18 @@ export class SkiaScroll extends SkiaControl {
637
646
  if (this.RefreshEnabled && this.refreshIndicator && this.refreshIndicator.IsVisible && (this.OverScrolled || this.isRefreshing))
638
647
  this.refreshIndicator.Render(ctx);
639
648
  this.UpdateScrollBarIndicator();
640
- for (const bar of [this.scrollBar, this.scrollBarHorizontal])
641
- if (bar && bar.IsVisible && bar.Opacity > 0)
642
- bar.Render(ctx);
649
+ for (const bar of [this.scrollBar, this.scrollBarHorizontal]) {
650
+ if (!bar || !bar.IsVisible || bar.Opacity <= 0)
651
+ continue;
652
+ // the bar is parent-independent: its thumb resizing (squash on a bounce) invalidates the bar alone, so it is
653
+ // re-measured here on draw instead of dragging the scroll and every ancestor through a measure each frame
654
+ if (bar.NeedMeasure) {
655
+ const r = this.DrawingRect;
656
+ bar.Measure(r.Width, r.Height, this.RenderingScale);
657
+ bar.Arrange(r, bar.WidthRequest, bar.HeightRequest, this.RenderingScale);
658
+ }
659
+ bar.Render(ctx);
660
+ }
643
661
  canvas.restoreToCount(saved);
644
662
  }
645
663
  OnScrolled() {
@@ -754,13 +772,35 @@ export class SkiaScroll extends SkiaControl {
754
772
  return;
755
773
  const i = Math.max(0, Math.min(items.length - 1, index));
756
774
  const scale = this.RenderingScale;
757
- const layoutTopPts = (layout.DrawingRect.Top - this.content.DrawingRect.Top) / scale;
758
- let target = layoutTopPts + layout.GetItemOffsetPixels(i) / scale;
775
+ const horizontal = this.Orientation === "Horizontal";
776
+ const viewport = (horizontal ? this.DrawingRect.Width : this.DrawingRect.Height) / scale;
777
+ // Item start and size in points, relative to the content start along the scroll axis.
778
+ let itemStart, itemSize;
779
+ if (!horizontal && layout.Type === "Column" && layout.Split <= 1) {
780
+ // The virtualized list: cells past the viewport are not realized, the structure knows their offsets.
781
+ itemStart = (layout.DrawingRect.Top - this.content.DrawingRect.Top + layout.GetItemOffsetPixels(i)) / scale;
782
+ itemSize = (layout.GetItemOffsetPixels(i + 1) - layout.GetItemOffsetPixels(i)) / scale;
783
+ }
784
+ else {
785
+ // A templated Row / Wrap / Grid realizes every item (C# non-list layouts are not virtualized): read the cell's
786
+ // arranged rect, like C# reads the structure cell's Destination.
787
+ const cell = layout.ChildrenFactory.GetViewForIndex(i);
788
+ if (!cell)
789
+ return;
790
+ const r = cell.DrawingRect, c = this.content.DrawingRect;
791
+ itemStart = (horizontal ? r.Left - c.Left : r.Top - c.Top) / scale;
792
+ itemSize = (horizontal ? r.Width : r.Height) / scale;
793
+ }
794
+ let target = itemStart;
759
795
  if (option === "End")
760
- target -= this.DrawingRect.Height / scale - layout.GetItemOffsetPixels(i + 1) / scale + layout.GetItemOffsetPixels(i) / scale;
796
+ target -= viewport - itemSize;
761
797
  else if (option === "Center")
762
- target -= this.DrawingRect.Height / scale / 2;
763
- this.ScrollTo(this.offsetX, -target, animate ? this.ScrollingSpeedMs / 1000 : 0, true);
798
+ target -= (viewport - itemSize) / 2;
799
+ const time = animate ? this.ScrollingSpeedMs / 1000 : 0;
800
+ if (horizontal)
801
+ this.ScrollTo(-target, this.offsetY, time, true);
802
+ else
803
+ this.ScrollTo(this.offsetX, -target, time, true);
764
804
  }
765
805
  StopAnimators() {
766
806
  this.flingEdgeX = null;
@@ -783,10 +823,68 @@ export class SkiaScroll extends SkiaControl {
783
823
  this.panningLastDelta = SKPoint.Empty;
784
824
  this.panningCurrentOffsetPts = new SKPoint(this.offsetX, this.offsetY);
785
825
  }
826
+ draggedScrollBar;
827
+ scrollBarOwnsGesture = false;
828
+ /**
829
+ * Thumb drag and track press on a ScrollBar / ScrollBarHorizontal with IsDraggable set (C# ProcessScrollBarGestures).
830
+ * A gesture that starts on the bar belongs to it until the next down: the content neither pans nor gets the tap.
831
+ * True when the gesture was handled here.
832
+ */
833
+ ProcessScrollBarGestures(args, apply) {
834
+ if (args.Type === "Down") {
835
+ this.draggedScrollBar = undefined;
836
+ this.scrollBarOwnsGesture = false;
837
+ const draggable = (b) => b && b.IsDraggable && b.BeginDrag && b.GetDragProgress ? b : undefined;
838
+ const v = draggable(this.scrollBar), h = draggable(this.scrollBarHorizontal);
839
+ if (!v && !h)
840
+ return false;
841
+ const x = apply.MappedLocation.X + apply.ChildOffset.X, y = apply.MappedLocation.Y + apply.ChildOffset.Y;
842
+ if (v && v.BeginDrag(x, y))
843
+ this.draggedScrollBar = v;
844
+ else if (h && h.BeginDrag(x, y))
845
+ this.draggedScrollBar = h;
846
+ if (!this.draggedScrollBar)
847
+ return false;
848
+ this.scrollBarOwnsGesture = true;
849
+ this.StopScrolling();
850
+ this.ApplyScrollBarDrag(x, y);
851
+ args.Event.Handled = true; // used: a touch on the bar is claimed, the page must not take the drag
852
+ return true;
853
+ }
854
+ if (!this.scrollBarOwnsGesture)
855
+ return false;
856
+ if (!this.draggedScrollBar)
857
+ return args.Type === "Tapped" || args.Type === "LongPressing"; // released already: only the tap that follows belongs to the bar
858
+ if (args.Type === "Panning") {
859
+ this.ApplyScrollBarDrag(apply.MappedLocation.X + apply.ChildOffset.X, apply.MappedLocation.Y + apply.ChildOffset.Y);
860
+ args.Event.Handled = true;
861
+ }
862
+ else if (args.Type === "Up") {
863
+ this.draggedScrollBar = undefined;
864
+ this.scrollBarLast = undefined; // push the idle state so an auto-hiding bar fades again
865
+ this.scrollBarHLast = undefined;
866
+ this.Repaint();
867
+ }
868
+ return true;
869
+ }
870
+ ApplyScrollBarDrag(x, y) {
871
+ const bar = this.draggedScrollBar;
872
+ if (!bar)
873
+ return;
874
+ const progress = bar.GetDragProgress(x, y);
875
+ const b = this.ContentOffsetBounds;
876
+ if (bar === this.scrollBarHorizontal)
877
+ this.ViewportOffsetX = this.ClampOffset(-progress * b.Width, this.offsetY, b, true).X;
878
+ else
879
+ this.ViewportOffsetY = this.ClampOffset(this.offsetX, -progress * b.Height, b, true).Y;
880
+ this.Repaint();
881
+ }
786
882
  ProcessGestures(args, apply) {
787
883
  const consumedDefault = this.BlockGesturesBelow ? this : null;
788
884
  const scale = this.RenderingScale;
789
885
  const e = args.Event;
886
+ if (this.ProcessScrollBarGestures(args, apply))
887
+ return this;
790
888
  if (args.Type === "Down") {
791
889
  this.hadDown = true;
792
890
  this.snapped = false;
@@ -801,15 +899,18 @@ export class SkiaScroll extends SkiaControl {
801
899
  const child = super.ProcessGestures(args, apply);
802
900
  if (child && child !== this)
803
901
  return child;
804
- if (!this.RespondsToGestures)
805
- return child ?? consumedDefault;
806
- return this.ApplyWheelScroll(e.Wheel.Delta) ? this : consumedDefault;
902
+ if (!this.RespondsToGestures || this.Orientation === "Neither")
903
+ return child ?? consumedDefault; // Neither: not used, a parent scroll may take it
904
+ if (!this.ApplyWheelScroll(e.Wheel.Delta))
905
+ return consumedDefault; // at its edge: not used, the page may take it
906
+ e.Handled = true;
907
+ return this;
807
908
  }
808
909
  this.velocityY = e.Distance.Velocity.Y / scale;
809
910
  this.velocityX = e.Distance.Velocity.X / scale;
810
911
  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
912
  (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) {
913
+ if (args.Type === "Panning" && this.RespondsToGestures && this.hadDown && this.Orientation !== "Neither") {
813
914
  if (!this.IsUserPanning) {
814
915
  // A child may own the pan (slider, nested horizontal scroll); ask once before taking over.
815
916
  const childConsumed = super.ProcessGestures(args, apply);
@@ -860,6 +961,12 @@ export class SkiaScroll extends SkiaControl {
860
961
  const swipeThreshold = SkiaScroll.ThesholdSwipeOnUp * scale;
861
962
  let vx = finalVelocity.X * this.ChangeVelocityScrolled;
862
963
  let vy = finalVelocity.Y * this.ChangeVelocityScrolled;
964
+ // a cancelled pointer (the browser took the pan to scroll the page, or a parent intercepted it) settles in place:
965
+ // flinging from the few moves that arrived before the cancel would scroll the list while the page scrolls
966
+ if (e.Type === "Cancelled") {
967
+ vx = 0;
968
+ vy = 0;
969
+ }
863
970
  let fling = false;
864
971
  if (this.OverScrolled) {
865
972
  const rest = this.ClampOffset(this.offsetX, this.offsetY, this.ContentOffsetBounds, true);
@@ -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;
@@ -14,7 +14,6 @@ export declare class SkiaSwitch extends SkiaToggle {
14
14
  private static readonly DefaultTrack;
15
15
  private static readonly MaterialOutline;
16
16
  protected StyleDefault(name: "ColorThumbOn" | "ColorFrameOn" | "ColorThumbOff" | "ColorFrameOff"): Color | undefined;
17
- private SetContentSize;
18
17
  protected CreateDefaultContent(): void;
19
18
  /** C# GetThumbPosForOn: track width minus thumb width and margins, in points. */
20
19
  private ThumbPosForOn;
@@ -25,12 +25,6 @@ export class SkiaSwitch extends SkiaToggle {
25
25
  };
26
26
  return table[s]?.[name];
27
27
  }
28
- SetContentSize(w, h) {
29
- if (this.WidthRequest < 0)
30
- this.WidthRequest = w;
31
- if (this.HeightRequest < 0)
32
- this.HeightRequest = h;
33
- }
34
28
  CreateDefaultContent() {
35
29
  const frame = new SkiaShape();
36
30
  frame.Tag = "Frame";
@@ -5,11 +5,14 @@ import { type Color, type ScaledSize } from "../core/Types";
5
5
  import { SkiaLayout } from "./SkiaLayout";
6
6
  /**
7
7
  * Mirrors DrawnUi SkiaToggle: base of SkiaSwitch / SkiaCheckbox / SkiaRadioButton. Tapped flips IsToggled,
8
- * `Toggled` reports user changes, `ControlStyle` selects the look the default content is built with (once, at first
9
- * measure, like C# CreateDefaultContent). Colors left unset take the style defaults (C# SetStyleDefault).
8
+ * `Toggled` reports user changes, `ControlStyle` selects the look the default content is built with (at first
9
+ * measure, like C# CreateDefaultContent; a later change rebuilds it, C# RebuildDefaultContent). Colors left unset
10
+ * take the style defaults (C# SetStyleDefault).
10
11
  */
11
12
  export declare abstract class SkiaToggle extends SkiaLayout {
12
- ControlStyle: PrebuiltControlStyle;
13
+ private controlStyle;
14
+ get ControlStyle(): PrebuiltControlStyle;
15
+ set ControlStyle(v: PrebuiltControlStyle);
13
16
  /** Fires on every IsToggled change; C# fires only on user/external changes, not on DefaultValue application. */
14
17
  Toggled?: (sender: SkiaToggle, value: boolean) => void;
15
18
  DefaultValue: boolean;
@@ -37,10 +40,21 @@ export declare abstract class SkiaToggle extends SkiaLayout {
37
40
  /** Per-style color defaults (C# SetStyleDefault); undefined = the SkiaToggle default. */
38
41
  protected StyleDefault(_name: "ColorThumbOn" | "ColorFrameOn" | "ColorThumbOff" | "ColorFrameOff"): Color | undefined;
39
42
  protected OnToggledChanged(): void;
40
- /** Builds the look once (C# CreateDefaultContent) — subclasses add their views here. */
43
+ /** Builds the look (C# CreateDefaultContent) — subclasses add their views here; called again after a rebuild. */
41
44
  protected abstract CreateDefaultContent(): void;
42
45
  /** Pushes IsToggled + colors into the views (C# ApplyProperties). */
43
46
  abstract ApplyProperties(): void;
47
+ /** Size requests the style pinned (C# SetDefaultContentSize / SetDefaultMinimumContentSize); a rebuild releases only these. */
48
+ private stylePinned;
49
+ /** C# SetDefaultContentSize: the style's size, only where the app left the request unset. */
50
+ protected SetContentSize(w: number, h: number): void;
51
+ /** C# SetDefaultMinimumContentSize (height only): the style's minimum, only where the app left it unset. */
52
+ protected SetMinimumContentHeight(h: number): void;
53
+ /**
54
+ * C# RebuildDefaultContent: drops the content built for the previous style and the sizes it pinned (the app's own
55
+ * requests stay), so the next Measure builds the current style. No-op before the first build.
56
+ */
57
+ RebuildDefaultContent(): void;
44
58
  /** Content (and the size requests the style sets) must exist before Measure reads WidthRequest/HeightRequest. */
45
59
  Measure(widthConstraint: number, heightConstraint: number, scale: number): ScaledSize;
46
60
  protected DefaultAccessibilityCanInteract(): boolean;
@@ -3,11 +3,19 @@ import { Colors } from "../core/Types";
3
3
  import { SkiaLayout } from "./SkiaLayout";
4
4
  /**
5
5
  * Mirrors DrawnUi SkiaToggle: base of SkiaSwitch / SkiaCheckbox / SkiaRadioButton. Tapped flips IsToggled,
6
- * `Toggled` reports user changes, `ControlStyle` selects the look the default content is built with (once, at first
7
- * measure, like C# CreateDefaultContent). Colors left unset take the style defaults (C# SetStyleDefault).
6
+ * `Toggled` reports user changes, `ControlStyle` selects the look the default content is built with (at first
7
+ * measure, like C# CreateDefaultContent; a later change rebuilds it, C# RebuildDefaultContent). Colors left unset
8
+ * take the style defaults (C# SetStyleDefault).
8
9
  */
9
10
  export class SkiaToggle extends SkiaLayout {
10
- ControlStyle = "Unset";
11
+ controlStyle = "Unset";
12
+ get ControlStyle() { return this.controlStyle; }
13
+ set ControlStyle(v) {
14
+ if (this.controlStyle === v)
15
+ return;
16
+ this.controlStyle = v;
17
+ this.RebuildDefaultContent();
18
+ }
11
19
  /** Fires on every IsToggled change; C# fires only on user/external changes, not on DefaultValue application. */
12
20
  Toggled;
13
21
  DefaultValue = false;
@@ -48,6 +56,40 @@ export class SkiaToggle extends SkiaLayout {
48
56
  this.Toggled?.(this, this.isToggled);
49
57
  this.NotifyAccessibility();
50
58
  }
59
+ /** Size requests the style pinned (C# SetDefaultContentSize / SetDefaultMinimumContentSize); a rebuild releases only these. */
60
+ stylePinned = [];
61
+ /** C# SetDefaultContentSize: the style's size, only where the app left the request unset. */
62
+ SetContentSize(w, h) {
63
+ if (this.WidthRequest < 0) {
64
+ this.WidthRequest = w;
65
+ this.stylePinned.push("WidthRequest");
66
+ }
67
+ if (this.HeightRequest < 0) {
68
+ this.HeightRequest = h;
69
+ this.stylePinned.push("HeightRequest");
70
+ }
71
+ }
72
+ /** C# SetDefaultMinimumContentSize (height only): the style's minimum, only where the app left it unset. */
73
+ SetMinimumContentHeight(h) {
74
+ if (this.MinimumHeightRequest < 0) {
75
+ this.MinimumHeightRequest = h;
76
+ this.stylePinned.push("MinimumHeightRequest");
77
+ }
78
+ }
79
+ /**
80
+ * C# RebuildDefaultContent: drops the content built for the previous style and the sizes it pinned (the app's own
81
+ * requests stay), so the next Measure builds the current style. No-op before the first build.
82
+ */
83
+ RebuildDefaultContent() {
84
+ if (!this.contentCreated)
85
+ return;
86
+ this.Children = [];
87
+ for (const p of this.stylePinned)
88
+ this[p] = -1;
89
+ this.stylePinned = [];
90
+ this.contentCreated = false;
91
+ this.Update();
92
+ }
51
93
  /** Content (and the size requests the style sets) must exist before Measure reads WidthRequest/HeightRequest. */
52
94
  Measure(widthConstraint, heightConstraint, scale) {
53
95
  if (!this.contentCreated) {