<Switch> inside a <ScrollView> swallows most taps: a person has to tap it repeatedly before it flips.
Confirmed by a person clicking with a mouse on the simulator, not only by automated taps — the first report came from automation, and a human then reproduced it by hand: "i have to spam it in order for it to turn green".
Where it bites
HQ.training's policy-consent gate (Agree.stx) is two <Switch> rows, and the app will not let anyone past it until both are on. Every account that must accept a new policy version meets this screen straight after sign-in, so an unreliable switch there reads as "the app is stuck at a wall". Settings → Offline has the same shape (Download over Cellular), as does anything else that puts a control in a scrolling screen — which is nearly every screen.
Environment
- craft-native 0.0.247 (also seen on the 0.0.242 shell)
- iOS 26.5 simulators, iPhone Air and iPhone 17 Pro, both light mode
renderer: 'native', screens compiled into the committed bundle
Reproduce
Any native screen of this shape:
<ScrollView class="flex-1">
<View class="flex-row gap-4 items-center px-4 py-3">
<Text class="flex-1" :text="label" />
<Switch :value="agreed" @change="agreed.set(!!$event.value)" />
</View>
</ScrollView>Tap the switch once, normally. Most taps do nothing. Tapping repeatedly eventually flips it.
It is not the script side
- The two switches on HQ's consent screen are byte-identical in markup apart from the signal name, the label and the testID, and both behave the same way.
tests/unit/native-agree.test.tspasses 11/11 and drives those same testIDs throughmountNativeScreen, so the signals, the@changehandler and the derivedreadyare all correct.switchChanged(_:)and thecase "Switch"reconcile path both read correctly: a.valueChangedtarget, andisOnwritten back only when it differs. When the event does arrive it is handled properly. The problem is that the event usually never arrives.
Mechanism
CraftNativeScrollView is a plain UIScrollView subclass (CraftNativeScreen.swift:3610) and never touches UIKit's content-touch delivery. Across every iOS template:
$ grep -rn 'delaysContentTouches\|canCancelContentTouches\|touchesShouldCancel' dist/ios/templates/*.swift
(no matches)So it runs the UIKit defaults, delaysContentTouches = true and canCancelContentTouches = true. The scroll view holds a touch while it decides whether the gesture is a scroll, and cancels the control's tracking on the slightest movement. A UISwitch is a UIControl that has to track the touch to emit .valueChanged, so most taps are cancelled before it ever fires — which is exactly the "tap it several times and it eventually works" symptom, and why a perfectly still, quick tap occasionally succeeds.
Suggested fix
UIKit's own pattern, the one UITableView uses, on CraftNativeScrollView:
override init(frame: CGRect) {
super.init(frame: frame)
delaysContentTouches = false
...
}
override func touchesShouldCancel(in view: UIView) -> Bool {
// A control inside the scroll view must keep the touch it is tracking;
// cancelling it is what makes a UISwitch need several taps.
if view is UIControl { return false }
return super.touchesShouldCancel(in: view)
}Slider and DatePicker are UIControls reached the same way and should benefit from the same change. Worth a UI test that taps a Switch inside a ScrollView exactly once and asserts it flipped — the current XCTests appear to exercise switches outside a scrolling container, which is why this survived.