ReviewOS

craft-native/craft

fix(native): a Switch loses its thumb for half a second after every toggle

#416
Closed glennmichael123 opened this 7 days ago ·
7 days ago

What happens

A native Switch draws correctly at rest — same size, shape and track colour as UISwitch in Settings on the same OS (measured: 63.0x28.0pt, track rgb(197,197,199), identical to the system control). The defect is in the transition: for roughly half a second after a tap, the thumb is not drawn at all.

Captured at 5fps on an iPhone 17 Pro simulator, toggling a switch from off to on (frame numbers are capture indices):

framewhat is on screen
033off: grey track, thumb left. Correct.
034track green, thumb gone, a soft white blob detaching at the top-left
035track fully green, thumb a faint outline, white notches cut out of the right edge
036as above, thumb essentially invisible
037thumb back on the right. Correct.

Measured rather than eyeballed. Counting green pixels in the switch's rectangle, a settled on-switch is ~7,000. Frames 034-036 measure 11,000 and 13,000 — more green than a fully-on switch. That is only possible if the thumb is missing, because a correctly drawn thumb occupies ~5,000px of the track at every point along its travel. No frame of a correctly animating switch can exceed the settled value, so this is not an artifact of sampling mid-animation.

The same capture against the system control

The identical method, same simulator, same OS, same 5fps burst, toggling Auto-Play Animated Images in Settings > Accessibility > Motion:

system :  0 … 0,  3,  7,  7,  7 …     peak 7,411   (settled 7,139)
native  :  0 … 0, 11, 13, 13,  7 …    peak 13,000  (settled 7,000)

The system control's green count rises to the settled value and stops. Its thumb is visible in every frame of the crossing. The native one overshoots the settled value by 1.8x for three frames, which is the thumb being absent.

That difference does not depend on how the control was driven, which matters because the two were driven differently: synthetic taps do not register on Settings' switches, so the system one was toggled by dragging while the app's was tapped. A drag shows the thumb under the finger and a tap animates it across, but in both the thumb covers part of the track throughout, so neither can exceed its settled green. One does.

What it is not

  • Not app styling. The markup is <Switch :value="..." @value-change="..." :accessibilityLabel="..." /> with no class and no style, so onTintColor, thumbTintColor and trackColor are all nil and UIKit's own colours apply.
  • Not the app's state handling. It reproduces on a handler that sets the bound value synchronously and never touches the network, so there is no window where the script echoes a stale value back.
  • Not setOn(_:animated:) snapping. switchUserValues holds the tapped value for 0.35s and the echo matches it, so the guard in the "Switch" case skips setOn entirely.
  • Not clipping. clipsToBounds is only set for overflow: hidden, scroll views, labels with a corner radius, image views and per-corner rounding. A bare Switch is none of those.

What would help

A thumb that disappears mid-animation looks like the control being recreated rather than updated — a freshly made UISwitch adopting the new value would explain a missing thumb during the swap better than any property change. If the reconciler replaces the node on a prop update instead of mutating it in place, reusing it would fix this and remove a needless allocation per toggle.

I have not proven that, and it is a guess at the mechanism rather than a diagnosis. The reproduction and the measurements above are the solid part.

7 days ago

Investigated on an iPhone 17 Pro simulator running iOS 26.5. The visual effect is reproducible, but it also occurs on a plain UISwitch with no Craft renderer, styling, value handler, or reconciliation. Using the same real tap and recording method, the plain UIKit switch reached 13,477 green pixels during tracking versus 7,623 settled; the Craft switch reached 14,868 versus 7,636. Both recordings show the enlarged translucent thumb. This does not establish whether UIKit intends the effect, but it rules out Craft node replacement as a necessary cause. A Settings drag is not an equivalent control experiment for this tap animation.

I tested guarding repeated tint/thumb-color writes as well: the effect remained, so that speculative production patch was discarded.

Pushed 198add5 with a test-only plain UIKit comparison, actual controlled-value echoes through MUTATE for both switch fixtures, bidirectional real taps, and identity/color-restoration assertions. The comparison is injected only into the generated test app, not the shipped templates. Reproduce with CRAFT_ONLY_TESTING=NativeRenderUITests/NativeRenderUITests/testUnmodifiedUIKitSwitchRespondsToTheSameTap and compare with testSwitchTapChangesUIKitStateAndDeliversValueChange; use simctl recordVideo to inspect intermediate frames. Settled-value assertions alone do not validate animation pixels.

All seven selected iOS simulator regressions passed. Also green: 796 SDK tests (8 intentional skips), 233 mobile builder tests, 394 release tests, typecheck, package verification, pinned Zig formatting, and lint (0 errors).

Leaving this open for a decision on whether Craft should provide a visual workaround for the native UIKit behavior. No production animation fix or release is being claimed.

6 days ago

The requester approved an opt-in workaround and parked #420. Implemented ios_solid: true as an iOS-only solid UIControl variant; ordinary Switch controls remain UISwitch. This is not a claim that UIKit itself was fixed.

The option draws an opaque thumb without glass/fade/scale animations, supports tap/drag and accessibility/keyboard activation, retains state across value/color mutations, and handles disabled, RTL, light/dark and controlled/uncontrolled states. Changing the style itself replaces the control; callers should supply value when switching styles. Android ignores this iOS-only option.

Commits: bfe5af0 (4 files: implementation, unit tests, SDK type, docs) and e0a3fc4 (3 files: real tap/drag fixture, UI test and contract coverage).

Validation so far: Swift application/test targets compile; 313 renderer contract tests, 132 iOS builder tests, 398 release tests, typecheck, 43 package checks and formatting passed. Initial lint passed with zero errors / 27 existing warnings; a later lint rerun hit a host glob timeout. A real STX 0.2.720 compile preserved __stx_ios_solid="dHJ1ZQ==" as a boolean prop. Native simulator acceptance is still pending: the original simulator lacked its testmanagerd socket, and its reboot did not finish; a new disposable simulator is booting. SDK attempts encountered existing unrelated five-second Android subprocess timeouts. No assertion or timeout has been weakened.

Leaving open: do not treat this as simulator-verified or published until the native regression and release evidence are available.

6 days ago

Native simulator update for e0a3fc4: all three new solid-switch unit tests passed on iPhone 17 Pro / iOS 26.5, including opaque-thumb pixel checks across 11 drag positions in both layout directions and light/dark appearance. The real UI regression also passed: taps both ways, drags both ways, controlled-value echoes, disabled tap rejection, and restoring/tapping the default UISwitch.

The selected run recorded 7 passing tests and 1 failing existing test (testSwitchInsideScrollViewTakesOneTap). I rebuilt the pre-change source and fixture and ran that test successfully, then restored the exact pushed sources and ran it successfully too, without changing assertions or timing. This suggests intermittency, not a demonstrated production regression; the original failure is retained rather than treating the first run as green.

Evidence retained locally:

  • /tmp/craft-416-solid-fresh.xcresult
  • /tmp/craft-416-baseline-scroll.xcresult
  • /tmp/craft-416-current-scroll.xcresult

Final lint rerun passed (0 errors, 27 existing warnings). The full SDK rerun was not green: 792 passed, 8 skipped, 7 failed, with timeout failures in unchanged Android subprocess and iOS recorder fixtures. No test was weakened.

Full main CI / Mobile E2E and focused iOS CI remain pending. The implementation is on main, not yet released; leaving this issue open until publication can name a usable version. #420 remains parked.

6 days ago

Focused remote iOS acceptance is now green at e0a3fc4: all 3 NativeSolidSwitchUnitTests passed with 0 failures. Verified the actual completed job log, not only the check badge: https://github.com/craft-native/craft/actions/runs/40531685603/job/122285910567 . This focused job covers state/identity, disabled accessibility, and opaque drag rendering; real tap/drag UI coverage passed locally as recorded above, and the full Mobile E2E run is still pending.

Main now includes the separately approved #423 merge (0da4ba7). The clean merged checkout passes 313 renderer contract tests and 133 iOS builder tests. Full CI 40534430166 and Mobile E2E 40534430017 are pending; the superseded pre-merge main runs were cancelled by concurrency, not passed. No release or issue closure yet.

6 days ago

Closing as implemented and verified on main, NOT published, following the requester-approved opt-in workaround and their new release policy: accumulate at least 5 closed issues (ideally up to 10) before releasing.

Use __stx_ios_solid="dHJ1ZQ==" on Switch for the iOS opaque-thumb UIControl. The default remains UISwitch; this does not claim UIKit itself was fixed. Android is unchanged.

Implementation bfe5af0 (4 files) and regression coverage e0a3fc4 (3 files) are included in current main 0da4ba7. Focused remote acceptance run 40531685603 has completed successfully; its UIKit job executed all 3 new solid-switch unit tests with zero failures. The real tap/drag/controlled-echo/disabled/native-fallback UI regression passed locally. The existing ScrollView test failed once, then passed unchanged on both the baseline and pushed code; that limitation remains documented above. Merged-main contract and builder suites passed 313 and 133 tests respectively.

Full main CI 40534430166 and Mobile E2E 40534430017 remain pending; the broader local SDK timeout failures are documented above. Those are not claimed green and publication remains gated. No version bump, tag, or release has been made for this workaround.

Sign in to comment on this issue.