ReviewOS

craft-native/craft

A Switch inside a ScrollView loses most taps: UIScrollView cancels the control's touch

#380
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

<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.ts passes 11/11 and drives those same testIDs through mountNativeScreen, so the signals, the @change handler and the derived ready are all correct.
  • switchChanged(_:) and the case "Switch" reconcile path both read correctly: a .valueChanged target, and isOn written 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.

9 days ago

Implemented on main in dc2ed05d. CraftNativeScrollView now disables delayed content touches and preserves UIControl tracking in touchesShouldCancel(in:), covering switches, sliders, and date pickers inside native ScrollViews. Added a one-tap iOS UI regression fixture and renderer contract assertions. The iOS simulator leg should close this once CI validates the tap path.

9 days ago

Fixed on main in dc2ed05d (fix(ios): preserve control taps inside native scroll views). CraftNativeScrollView now disables delayed content touches and keeps UIControl tracking from being cancelled; the native-screen fixture taps a Switch inside a ScrollView once and asserts the change. The focused contract suite and iOS package suite pass. This issue can be closed once the mobile CI/UI job confirms it.

9 days ago

Verified on main after the iOS 26.5 simulator run.

  • dc2ed05d keeps UIControl tracking intact inside CraftNativeScrollView.
  • The dedicated one-tap Switch UI test passes.
  • Full native-render simulator run passed: 178 unit tests and 4 UI tests.

Closing as fixed.

9 days ago

Related: #385. The same control on the same screen can also end up painted as a thumbless green capsule with its thumb stuck at the off position.

They are separate causes — this one is touch delivery, that one is the non-animated isOn echo landing mid-animation — but they compound. Because taps are swallowed here, a person taps repeatedly, and each of those taps starts a UIKit animation the next echo can interrupt. Fixing this one should make the other much harder to reach, without fixing it.

Sign in to comment on this issue.