A <Switch> that has been turned on can end up painted as a solid green capsule with no thumb, with a crescent notch on the left where the thumb should have been in the off position.
Seen on a real iPhone, not the simulator — the capture has signal bars and a battery reading — on HQ.training's policy-consent screen (Agree.stx), in dark mode.
What it looks like
Two <Switch> rows, one above the other, identical markup apart from the bound signal:
- on: a filled green capsule. No white thumb anywhere. A darker crescent bitten out of the left edge — the outline of a thumb still sitting at the off position, filled in the track's own colour.
- off, directly below it: completely normal. White thumb, grey track.
So the track repainted to the on colour and the thumb never moved.
Neither switch passes any styling. The markup is bare on both:
<Switch :value="agreeHealth" :disabled="loading || !offer || !!busy"
@change="agreeHealth.set(!!$event.value)" testID="agree-health" />No trackColor, no thumbColor, no ios_backgroundColor — so the difference between the two is only on versus off.
Where it comes from
CraftNativeScreen.swift, the Switch reconcile:
case "Switch":
let toggle = result as! UISwitch
if let value = props["value"] as? Bool ?? props["checked"] as? Bool {
// UIKit has already changed the value before emitting the
// event. Echoing it through the script must not restart its
// native animation.
if toggle.isOn != value { toggle.isOn = value }
}isOn is assigned directly. setOn(_:animated:) does not appear anywhere in the file.
The intent reads as correct — an echo of a value UIKit already applied should not kick off a second animation. But assigning .isOn while UIKit's own tap-driven animation is still running cancels the thumb's layer animation in place rather than completing it. The track has already been repainted by UIKit; the thumb is left where it was and takes the track's fill. That is precisely the artifact above, including which side the crescent is on.
I have not instrumented this, so treat the mechanism as a strong reading of the evidence rather than a proven one. What would settle it: log toggle.isOn, toggle.layer.animationKeys() and the thumb layer's position at the moment of the echo.
Why it shows up here in particular
This screen is also where #380 bites: a Switch inside a ScrollView loses most of its taps, so a person has to tap repeatedly before it flips. Every one of those taps starts a UIKit animation that the next echo can interrupt, which is a good way to find this state and a poor experience either way. The two are separate causes — touch delivery versus state echo — but they compound, and they are the same control.
Suggested direction
Leave UIKit alone when the change came from the user's own tap, and reconcile only when the value genuinely diverges from what the control already shows — setOn(value, animated: false) after any in-flight animation has been allowed to finish, rather than a bare assignment mid-flight.
Worth a UI test that taps a Switch once and asserts the thumb's final position, not only isOn: the value is right in this state, and only the paint is wrong, so an isOn assertion passes while the control looks broken.