Sunstone Apps
← Back to blog

React Native Android

Disabling Android touch sounds in React Native without fighting Switch

A sound toggle looks like one setting. On Android, it can be two sound systems and two press targets pretending to be one control.

Published: 6 min read
OpenGraph preview image for this article. Disabling Android touch sounds in React Native without fighting Switch

The bug was not in the audio file.

We were adding a sound setting to Daily Sudoku: Offline Puzzle. The setting was supposed to mute short gameplay effects: correct placements, mistakes, completed units, auto-complete, and wins.

That part was the easy fix. The app already played game sounds through one helper, so the first version only needed a persisted soundEnabled setting and a guard before creating audio players.

Then Android exposed the real product requirement.

Native touch feedback is also sound

On a real device, turning off “Sound effects” still left an audible tap when the player touched the board or some buttons. That sound was not coming from our expo-audio effects. It was native Android touch feedback from Pressable and TouchableOpacity.

In code, those are different systems:

  • gameplay sound effects come from app-owned audio players;
  • Android touch feedback comes from native controls;
  • the user sees one setting and expects both to stop.

So the setting could not honestly mean only gameplay SFX. It had to mean app sound.

Centralize the decision

The fix was to stop threading sound flags through the board and controls. We added a sound manager near the app root, below settings, and made UI wrappers read from that source:

<SettingsProvider initial={bootstrap.settings}>
  <SoundProvider>
    <ThemedAppContent />
  </SoundProvider>
</SettingsProvider>

The wrappers own the Android-specific props:

<Pressable android_disableSound={!isAppSoundEnabled} {...props} />
<TouchableOpacity touchSoundDisabled={!isAppSoundEnabled} {...props} />

That put board cells, number pad buttons, modals, settings, shop cards, and post-game actions under the same decision.

The Switch trap

The remaining bug was subtler. The settings row had a Pressable for the label and a native Switch for the visual control. Tapping the label worked. Tapping directly on the switch toggled twice: once from the row and once from the native switch.

Trying to make the Switch passive with pointer props was not reliable enough. The dull fix was better:

  • make the whole row the only interactive element;
  • give that row accessibilityRole="switch" and accessibilityState;
  • replace the native Switch with a passive switch-looking indicator;
  • let the sound-aware wrapper handle native touch sound.

The UI still looks like a switch, but there is only one press target and one state transition.

The rule we kept

When a React Native setting controls all app sound feedback on Android, do not split gameplay audio and native touch feedback in the implementation while presenting one control to the user. Centralize the sound state, route touchables through wrappers, and avoid nested interactive switches that can fire a second change.

The boring version is the reliable one: one source of truth, one press target, one state change.

What the final component contract looks like

The final contract is intentionally small. The sound provider exposes whether app sound is enabled and a function for gameplay effects. UI components do not know how settings are stored, whether the global audio config is enabled, or which platform prop disables native sound. They only render AppPressable or AppTouchableOpacity.

That keeps the product decision in one place. If the wording changes from “Sound effects” to “App sounds”, the UI copy changes. If Android changes the prop shape in a future React Native version, the wrapper changes. The board, shop, modals, and post-game screens do not need to relearn the policy.

export const AppPressable = React.forwardRef<React.ComponentRef<typeof Pressable>, PressableProps>(
  function AppPressable({ android_disableSound, ...props }, ref) {
    const { isAppSoundEnabled } = useSoundManager();

    return (
      <Pressable
        ref={ref}
        android_disableSound={android_disableSound ?? !isAppSoundEnabled}
        {...props}
      />
    );
  },
);

The same idea applies to TouchableOpacity with touchSoundDisabled. The wrapper is not a style abstraction. It exists because a platform behavior became part of a product setting.

Why not keep the native Switch?

The native Switch is a good control when it owns the interaction. It becomes risky when it is nested inside another pressable row that also toggles the same state. You can try to coordinate handlers, but then the code starts depending on event behavior that differs across platforms and input methods.

For this setting, the row was already the best accessible target because it included the label and explanatory text. So the row became the control. The switch-shaped element became presentation. Accessibility still announces a switch because the row has accessibilityRole="switch" and accessibilityState={{ checked: value }}.

That distinction mattered: we did not remove switch semantics. We removed the second interactive native control.

Checklist for React Native Android sound toggles

When implementing a similar toggle, check these points before calling it done:

  • Does the setting disable app-owned audio players?
  • Does it disable Android native touch sound on Pressable?
  • Does it disable Android native touch sound on TouchableOpacity?
  • Are there nested interactive controls that can toggle twice?
  • Does the accessible element still expose switch semantics?
  • Was the behavior tested on a real Android device, not only in a simulator or web build?

The last point matters because this bug showed up where product quality is judged: on a phone, with real system feedback, using normal touch.

Testing the setting manually

The manual test is simple, but it needs Android hardware. Enable sound, tap a board cell, tap a number, open settings, and tap a few modal buttons. Then disable sound and repeat the same taps. The expected result is not only that custom SFX disappear; system tap feedback should disappear too.

It is worth testing with both finger and mouse when using an emulator. Mouse input can expose double-toggle bugs because it lands precisely on the visual control. Finger input can expose target-size problems because it is less precise. A setting row should survive both.

What not to abstract

Do not build a universal button system just because sound wrappers exist. The wrapper should solve the platform sound policy and get out of the way. Styling, layout, and product semantics should remain local unless the app already has a shared design primitive for them.

That restraint keeps the fix easy to review. The diff should say: these existing touch targets now respect the sound setting. It should not secretly redesign every button in the app.

Where this pattern should live

The sound-aware wrappers belong close to shared UI for the app, not inside the board. The board was only where the bug was first noticed. Once the product decision became “mute all app sound feedback,” every touch target became part of the policy.

That placement prevents a common failure mode: fixing the screen where the bug was reported and leaving the same native tap sound in modals, settings, shop cards, or post-game actions. A global setting should not depend on every feature owner remembering one Android prop.

Naming matters

The internal name should match the product behavior. Calling the state isGameplaySoundEnabled made sense when it only controlled SFX. After the decision changed, isAppSoundEnabled was more honest. That small rename reduces future mistakes because the type tells the next developer that this is broader than game audio.

The UI copy can still use friendly wording, but the code should preserve the invariant: one setting controls custom sound effects and native touch feedback.

The product wording should be tested too

The original label said “Sound effects,” but the behavior became broader than short gameplay sounds. That creates a copy decision. If users interpret the setting as all audible feedback, the implementation should match. If the app ever adds music or voice, the copy may need to split those controls.

For now, one setting is simpler because the app only has short effects and native touch feedback. The important part is that the help text and behavior do not contradict each other. A mute setting that still clicks feels broken, even when the code can explain why.

The emulator is not enough

This bug was easy to miss when testing only where native feedback was not obvious. Real Android devices, system sound settings, and precise emulator mouse input each revealed different parts of the problem. That is a good reminder for UI settings: test the behavior on the platform that owns the feedback.

Conclusion

A sound toggle is a product contract, not only an audio flag. On React Native Android, keeping that promise means controlling app-owned effects, native touch feedback, and switch semantics from one clear source of truth.