Skip to content

Fix gesture hit-testing for transformed controls in virtualized/cached scrolls#303

Open
LennoxP90 wants to merge 1 commit into
taublast:mainfrom
LennoxP90:fix/gesture-hittest-transformed-virtualized
Open

Fix gesture hit-testing for transformed controls in virtualized/cached scrolls#303
LennoxP90 wants to merge 1 commit into
taublast:mainfrom
LennoxP90:fix/gesture-hittest-transformed-virtualized

Conversation

@LennoxP90

Copy link
Copy Markdown

PR: Fix gesture hit-testing for transformed controls in virtualized/cached scrolls

Fixes #302.

Problem

A control with a transform (ScaleX/ScaleY, Rotation*, skew, flip) renders correctly but pointer
events never reach its children — the transform is applied to rendering but ignored by gesture
hit-testing. This breaks the standard inverted-list pattern (a ScaleY="-1" SkiaScroll with
counter-flipped cells for a newest-at-bottom chat list) inside virtualization: cells render upright,
but taps on links/buttons inside a cell do nothing. See the linked issue for the full root-cause.

Fix

Gesture hit-testing only — no rendering changes:

  1. SkiaControl.EnsureRenderTransformMatrixForGestures() (new): recomputes RenderTransformMatrix
    from the current DrawingRect for transformed controls. Called before the inverse-map in
    ProcessGestures and in IsGestureForChild. Fixes the stale/Identity matrix on node-attached/cached
    controls and the stale pivot on virtualized controls (whose DrawingRect moves as they scroll).
  2. SkiaScroll.ProcessGesturesForPlane: map the gesture point and the forwarded MappedLocation
    by the scroll's transform (the virtualized planes path overrides ProcessGestures and never reaches
    the base entry inverse-map).
  3. SkiaControl.ProcessGestures (registered-listener branch): hit-test transformed controls with
    the entry-mapped MappedLocation (consistent with the rendering-tree branch); the non-transformed
    path is kept exactly as before.
  4. SkiaLabel.ProcessGestures: span (link) hit-testing uses the entry-mapped MappedLocation
    instead of the raw args.Event.Location.

Three files: SkiaControl.Shared.cs, SkiaScroll.Planes.cs, SkiaLabel.cs.

No regression for non-transformed UI

Changes 1–3 are gated on HasTransform; change 4 is identical to the previous behaviour when nothing
in the ancestry is transformed, because MappedLocation == args.Event.Location in that case (the root
sets MappedLocation = touchLocation and the entry inverse-map is a no-op without a transform). So a
normal, non-transformed list takes byte-identical code paths.

Testing

  • Android (net10): verified that tapping a URL link inside a cell of an inverted
    (ScaleY="-1") virtualized SkiaScroll now fires (opens the browser); without the change the tap is
    detected on the cell but never reaches the label's link span.
  • Non-transformed regression check: the same list with the transforms removed still routes taps
    correctly (the gated paths fall through to the original behaviour).
  • The change is platform-agnostic C#; I could not run the iOS/MacCatalyst matrix on a Windows-only host.

Notes / for discussion

  • EnsureRenderTransformMatrixForGestures() recomputes the matrix per gesture event for transformed
    controls (a single cheap matrix build). If preferred, this could be made dirty-tracked / cached and
    invalidated on transform or layout change — happy to adjust.
  • The registered-listener branch was previously inconsistent with the rendering-tree branch (raw vs
    mapped coordinates); this aligns them for transformed controls while leaving the common path intact.

…d scrolls

A control with a transform (ScaleX/ScaleY, Rotation*, skew, flip) renders correctly but pointer
events never reach its children — the transform is applied to rendering but ignored by gesture
hit-testing. This breaks the standard inverted-list pattern (a ScaleY=-1 SkiaScroll with
counter-flipped cells) inside virtualization: cells render upright, but taps on links/buttons
inside a cell do nothing.

Three causes, all in gesture hit-testing (no rendering changes):
- RenderTransformMatrix is stale at gesture time — ApplyTransforms stores it only when
  !NodeAttached, and a virtualized control's matrix pivot (built from DrawingRect at render time)
  goes stale as it scrolls. Add EnsureRenderTransformMatrixForGestures() to recompute it from the
  current DrawingRect for transformed controls, called before the inverse-map in ProcessGestures
  and in IsGestureForChild.
- The virtualized planes path (SkiaScroll.ProcessGesturesForPlane) bypasses the entry inverse-map
  and hit-tests/forwards raw coords. Map the gesture point and the forwarded MappedLocation by the
  scroll's transform.
- The registered-listener branch of SkiaControl.ProcessGestures and SkiaLabel.ProcessGestures
  (span/link hit-testing) used the raw args.Event.Location instead of the entry-mapped
  MappedLocation.

All changes are gated on HasTransform (or are identical to prior behaviour when nothing in the
ancestry is transformed, since MappedLocation == args.Event.Location in that case), so
non-transformed lists take byte-identical code paths. No features removed, no public API change
beyond one helper. Verified on Android with an inverted virtualized SkiaScroll (link taps now
fire; removing the transforms confirms the link was already tappable).
LennoxP90 added a commit to LennoxP90/DrawnUi that referenced this pull request Jun 4, 2026
Minimal fork patches enabling WFMC's chat-detail virtualized list with
RecyclingTemplate=Enabled (bounded-memory recycling) + MeasureVisible:

1. ViewsAdapter.InitializeSoft - refresh the immutable render snapshot on
   incremental collection change. It was only rebuilt on an ItemsSource REFERENCE
   change; appends to the same ObservableCollection left it frozen at the old
   length, so GetViewForIndex could not realise appended indices (the "only the
   first page renders" pagination bug).

2. SkiaLayout.ApplyAddChange (MeasureVisible, add-at-end) - re-kick background
   measurement from LastMeasuredIndex+1. The one-shot task idles after the initial
   set, so appended (LoadMore) rows were never measured. Cancel first so a stale
   still-flagged task does not dedup-skip the restart.

3. SkiaDrawnCell.ApplyBindingContext - re-measure on recycle (WasMeasured=false +
   InvalidateMeasureInternal). SetContent runs inside LockUpdate, suppressing its
   re-measure, so a recycled cell kept the donor message width (variable-width
   bubbles rendered wrong). Forces re-measure for the new content.

Verified emulator-5554, 914-msg thread: correct render + bounded memory
(~645MB@914, flat on re-scroll; vs ~1.3GB grow-only). Carries PRs taublast#301/taublast#303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tap/pointer gestures don't reach children of a transformed control in a virtualized scroll

1 participant