iOS TextBox caret seek gesture - #23920
Conversation
…sture Holding the space bar puts the iOS keyboard into trackpad mode, but under the Skia renderer dragging did not move the caret: none of the three UITextInput floating-cursor selectors were implemented, so the gesture never reached the managed TextBox. Adds TextBox.ProcessCaretDragGesture, a platform-agnostic caret-drag state machine in Uno.UI, and implements begin/update/endFloatingCursorAtPoint on both invisible native proxies to feed it. The gesture previews the caret while dragging and commits the selection exactly once on End, so a whole drag raises a single SelectionChanged rather than one per callback. Offsets are cumulative from the gesture origin, matching how UIKit reports the pan translation. base is deliberately not called: UIKit would map the point against the proxy's system-font layout, which bears no relation to the rendered text. Hit-testing runs managed-side against the Skia glyphs instead, and native selection changes are ignored for the duration of the gesture. Fixes #23871 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNXnVDJ22kXAgQL2zNWseC
The caret previewed during a caret-drag now uses SelectionHighlightColor instead of the Foreground, so it reads as a transient selection affordance rather than an ordinary caret. The gesture forces a thumbless, non-blinking caret while it runs, but never put the previous mode back. On the Android and Desktop touch conventions, where a tap leaves an insertion handle, that handle was lost for good after a drag. Related to #23871 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNXnVDJ22kXAgQL2zNWseC
|
Claude finished @MartinZikmund's task in 7m 28s —— View job Code Review
SummaryVerdict: ship (minor findings only) The design is solid. The core ideas are all correct:
Findings by severity
Cross-cutting notes
|
TextBox seek gestureTextBox caret seek gesture
Drops the redundant Min/Max around the vertical clamp bounds, discards the ignored End result explicitly, and retimes the blink test — fewer iterations while frozen, more headroom for the resume check so a loaded CI shard does not turn it flaky. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNXnVDJ22kXAgQL2zNWseC
|
Claude finished @MartinZikmund's task in 3m 25s —— View job Follow-up Review (after 44760a4)
Verdict: ship All three applied findings from the initial review are correctly addressed in 44760a4. No new issues introduced. Applied fixes verified
Declined finding — refutation acceptedFinding #1 (dispose Cross-cutting — no issues
|
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-23920/wasm-skia-net9/index.html |
Reverts the SelectionHighlightColor tint from 43fa17f — the dragged caret keeps the ordinary Foreground brush. The caret-mode restore from that commit stays: it fixes the insertion handle being lost after a drag on the Android and Desktop touch conventions. Adds autoscrolling, which the gesture previously skipped. With text wider than the viewport the previewed caret could walk off-screen, because the hit-test runs against the full text layout while UpdateScrolling only ever looked at the committed selection. It now follows the preview index. The anchor and hit-test both work in text coordinates, which are independent of the scroll offset, so scrolling mid-gesture cannot skew the mapping. Related to #23871 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNXnVDJ22kXAgQL2zNWseC
|
Claude finished @MartinZikmund's task in 1m 15s —— View job Follow-up Review (after new push)
Verdict: ship (one nit) One code-quality finding carried forward from the
I prepared the fix locally ( // Before
IsActive = extension?.ProcessCaretDragGesture(...) == true;
// After
IsActive = extension?.ProcessCaretDragGesture(...) ?? false;Everything else from the previous two rounds remains ship-ready — no new issues introduced in this push. |
|
Claude finished @MartinZikmund's task in 3m 0s —— View job Code Review
Verdict: ship (two nits) Full review pass completed across all 9 changed files (4 new, 5 modified). The design is sound and all findings from the previous two rounds have been correctly addressed. Two new nits surfaced in the
I applied both fixes locally ( Cross-cutting — no new issues All previously confirmed items remain correct:
|
Replaces `== true` with `?? false` on both ProcessCaretDragGesture call sites, clearing the CodeQL SimplifyBooleanExpression warning. Same semantics — null still means the gesture was declined. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNXnVDJ22kXAgQL2zNWseC
|
Claude finished @MartinZikmund's task in 1m 2s —— View job Follow-up Review (after 6073e99)
Verdict: ship The single-commit change in Applied fix verified
Cross-cutting — no issuesAll findings from the three prior review rounds have been resolved or declined with accepted justification:
No |
| public void Begin(InvisibleTextBoxViewExtension? extension, CGPoint point) | ||
| { | ||
| _origin = point; | ||
| IsActive = extension?.ProcessCaretDragGesture(TextBox.CaretDragPhase.Begin, default) ?? false; |
There was a problem hiding this comment.
Same == true on a bool? as line 38 (flagged by CodeQL). The null-coalescing form is cleaner and removes the SimplifyBooleanExpression warning:
| IsActive = extension?.ProcessCaretDragGesture(TextBox.CaretDragPhase.Begin, default) ?? false; | |
| IsActive = extension?.ProcessCaretDragGesture(TextBox.CaretDragPhase.Begin, default) ?? false; |
| // While a caret drag is running, UIKit's own selection updates are computed against the proxy's | ||
| // system-font layout, which bears no relation to the Skia-rendered text. The managed side owns | ||
| // the caret for the duration of the gesture. | ||
| internal bool IsCaretDragActive => _owner?.TextBox?.IsCaretDragActive == true; |
There was a problem hiding this comment.
Same == true pattern on a bool? (the null-conditional chain makes the type nullable). Same fix as the two lines in InvisibleTextBoxFloatingCursor.cs:
| internal bool IsCaretDragActive => _owner?.TextBox?.IsCaretDragActive == true; | |
| internal bool IsCaretDragActive => _owner?.TextBox?.IsCaretDragActive ?? false; |
There was a problem hiding this comment.
🟡 Not ready to approve
Caret blinking may not reliably resume after ending a caret-drag gesture (e.g., when the pre-drag caret mode was hidden), due to incomplete timer restart logic.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds support for the iOS “floating cursor” (spacebar trackpad) caret-seek gesture when using the Skia TextBox implementation, by routing UIKit floating-cursor callbacks into a managed caret-drag preview/commit flow.
Changes:
- Introduces a managed caret-drag state machine (
Begin/Update/End/Cancel) that previews caret position without committing selection until gesture end. - Hooks UIKit floating cursor callbacks (
Begin/Update/EndFloatingCursor) in the invisible iOS proxy text controls and suppresses native→managed selection sync during the drag. - Adds Skia runtime tests covering preview behavior, commit semantics, clamping/scroll-follow, and focus-cancel behavior.
File summaries
| File | Description |
|---|---|
| src/Uno.UI/UI/Xaml/Controls/TextBox/TextBoxView.skia.cs | Cancels an in-flight caret drag when the TextBox loses focus. |
| src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.skia.cs | Renders/scolls based on caret-drag preview index while drag is active. |
| src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.CaretDrag.skia.cs | Implements caret-drag gesture phases with preview+commit behavior and caret-mode handling. |
| src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs | Exposes RenderCaret getter so tests can validate rendered caret index. |
| src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_TextBox.skia.cs | Adds runtime tests for caret-drag gesture behavior. |
| src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/Controls/TextBox/SinglelineInvisibleTextBoxView.cs | Overrides UIKit floating cursor callbacks and suppresses native selection sync during caret drag. |
| src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/Controls/TextBox/MultilineInvisibleTextBoxView.cs | Same as single-line proxy, for multiline UITextView-based proxy. |
| src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/Controls/TextBox/InvisibleTextBoxViewExtension.cs | Adds managed forwarding helpers for caret-drag gestures and exposes caret-drag active state. |
| src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/Controls/TextBox/InvisibleTextBoxFloatingCursor.cs | Translates UIKit floating cursor points into cumulative offsets for managed gesture processing. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| // Restored before committing so the selection transitions in SelectPartial see the real mode. | ||
| if (previousMode is { } mode) | ||
| { | ||
| CaretMode = mode; | ||
| } | ||
|
|
||
| if (CaretMode is CaretDisplayMode.ThumblessCaretShowing) | ||
| { | ||
| _timer.Start(); // resume blinking | ||
| } |
There was a problem hiding this comment.
🟡 Not ready to approve
Caret blinking may not resume if the gesture starts while the caret is in the hidden blink phase, leaving the caret stuck invisible after the drag ends.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Comments suppressed due to low confidence (1)
src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.CaretDrag.skia.cs:155
- EndCaretDrag stops the caret blink timer during the gesture but only restarts it when restoring to ThumblessCaretShowing. If the drag begins while the caret is currently in ThumblessCaretHidden (mid-blink), restoring that mode leaves the timer stopped and the caret can remain hidden indefinitely after the gesture ends.
if (CaretMode is CaretDisplayMode.ThumblessCaretShowing)
{
_timer.Start(); // resume blinking
}
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-23920/wasm-skia-net9/index.html |
|
The build 224946 found UI Test snapshots differences: Details
|
|
Tested on device (iPhone 14 Pro, iOS 26). Single-line works well — the caret tracks the gesture accurately. Multiline is where it breaks down. The invisible proxy view is sized once when entry starts and never updated afterwards, so as the Two commits from #23909 address exactly this, and I confirmed they cherry-pick cleanly onto your branch:
Happy to push them onto your branch if that's easier. |
ajpinedam
left a comment
There was a problem hiding this comment.
Comments of my findngs above
Fixes #23871
PR Type:
✨ Feature
What changed? 🚀
Caret seek gesture on software keyboard is now supported
ScreenRecording_07-30-2026.10-52-15_1.mp4
PR Checklist ✅
Screenshots Compare Test Runresults.