Skip to content

Commit dc15e96

Browse files
authored
feat(Tabs, Android): handle colorScheme (dark mode) (#3723)
## Description Adds support for `colorScheme` prop for `TabsHost` on Android. This is a follow-up to #3716 and it follows [RFC-0996](https://github.com/software-mansion/react-native-screens-labs/blob/main-issue-tracker/rfcs/0996-rtl-and-dark-mode.md). Color scheme will be propagated down the hierarchy via custom solution that mimics trait propagation on iOS. This solution is meant to be reusable so that it can be later used in Stack v5 and Split. ## Changes - handle `colorScheme` for Android - `ColorSchemeProviding`, `ColorSchemeListener` interfaces - reusable `ColorSchemeCoordinator` - use `ColorSchemeCoordinator` in `TabsHost` - adapt single feature test to Android ## Before & after - visual documentation ### Before N/A - tabs would use system's or react-native's color scheme. ### After https://github.com/user-attachments/assets/a6eba14f-33e4-4418-8a49-fd2cf02a3c57 ## Test plan Run `single-feature-tests/test-tabs-color-scheme.tsx`. To test theme propagation you can use this test: <details> <summary>Theme propagation test</summary> ```tsx import { Appearance, ColorSchemeName, Platform, ScrollView, StyleSheet, Text, View, } from 'react-native'; import { Scenario } from '../../shared/helpers'; import { createAutoConfiguredTabs } from '../../shared/tabs'; import React, { useEffect, useState } from 'react'; import { SettingsPicker } from '../../../shared'; import { TabsHostProps } from 'react-native-screens'; import useTabsConfigState from '../../shared/hooks/tabs-config'; const SCENARIO: Scenario = { name: 'Color Scheme', key: 'test-tabs-color-scheme', details: 'Tests how tabs handle system, React Native and prop color scheme.', platforms: ['android', 'ios'], AppComponent: App, }; export default SCENARIO; type InnerTabsParamList = { Config: undefined; Nested: undefined; }; type TabsParamList = { Config: undefined; Nested: undefined; }; // Inner tabs (one level deep) function InnerConfigScreen() { const [config, dispatch] = useTabsConfigState<InnerTabsParamList>(); return ( <ScrollView style={styles.container} contentContainerStyle={styles.content}> <View style={styles.section}> <Text style={styles.heading}>Inner TabsHost color scheme</Text> <SettingsPicker<NonNullable<TabsHostProps['colorScheme']>> label={'colorScheme'} value={config.colorScheme ?? 'inherit'} onValueChange={function (value: TabsHostProps['colorScheme']): void { dispatch({ type: 'tabBar', config: { colorScheme: value, }, }); }} items={['inherit', 'light', 'dark']} /> </View> </ScrollView> ); } function InnerNestedScreen() { return ( <View style={styles.containerCenter}> <Text>Innermost screen</Text> </View> ); } const InnerTabs = createAutoConfiguredTabs<InnerTabsParamList>({ Config: InnerConfigScreen, Nested: InnerNestedScreen, }); // Outer tabs function NestedScreen() { return ( <InnerTabs.Provider> <InnerTabs.Autoconfig /> </InnerTabs.Provider> ); } function ConfigScreen() { const [config, dispatch] = useTabsConfigState<TabsParamList>(); const [reactColorScheme, setReactColorScheme] = useState<ColorSchemeName>('unspecified'); useEffect(() => { dispatch({ type: 'tabScreen', tabKey: 'Config', config: { safeAreaConfiguration: { edges: { bottom: true, }, }, }, }); dispatch({ type: 'tabScreen', tabKey: 'Nested', config: { safeAreaConfiguration: { edges: { bottom: true, }, }, }, }); }, [dispatch]); useEffect(() => { Appearance.setColorScheme(reactColorScheme); }, [reactColorScheme]); return ( <ScrollView style={styles.container} contentContainerStyle={styles.content}> <View style={styles.section}> <Text> There are 3 sources of color scheme, in ascending order of precedence: system, React Native and our property on TabsHost. </Text> </View> <View style={styles.section}> <Text style={styles.heading}>System color scheme</Text> <Text> Switch system color scheme via quick settings in notification drawer (Android/iOS) or Cmd+Shift+A (iOS simulator). </Text> </View> <View style={styles.section}> <Text style={styles.heading}>React Native's color scheme</Text> <SettingsPicker<ColorSchemeName> label={'colorScheme'} value={reactColorScheme} onValueChange={function (value: ColorSchemeName): void { setReactColorScheme(value); }} items={['unspecified', 'light', 'dark']} /> </View> <View style={styles.section}> <Text style={styles.heading}>Outer TabsHost color scheme</Text> <SettingsPicker<NonNullable<TabsHostProps['colorScheme']>> label={'colorScheme'} value={config.colorScheme ?? 'inherit'} onValueChange={function (value: TabsHostProps['colorScheme']): void { dispatch({ type: 'tabBar', config: { colorScheme: value, }, }); }} items={['inherit', 'light', 'dark']} /> </View> </ScrollView> ); } const Tabs = createAutoConfiguredTabs<TabsParamList>({ Config: ConfigScreen, Nested: NestedScreen, }); export function App() { return ( <Tabs.Provider> <Tabs.Autoconfig /> </Tabs.Provider> ); } const styles = StyleSheet.create({ container: { flex: 1, }, containerCenter: { flex: 1, alignItems: 'center', justifyContent: 'center', }, content: { padding: 20, paddingTop: Platform.OS === 'android' ? 60 : undefined, }, heading: { fontSize: 24, fontWeight: 'bold', marginBottom: 5, }, section: { marginBottom: 10, }, }); ``` </details> ## Checklist - [x] Included code example that can be used to test this change. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] Ensured that CI passes
1 parent 2c8c666 commit dc15e96

File tree

12 files changed

+237
-51
lines changed

12 files changed

+237
-51
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.swmansion.rnscreens.gamma.common.colorscheme
2+
3+
internal enum class ColorScheme {
4+
INHERIT,
5+
LIGHT,
6+
DARK,
7+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.swmansion.rnscreens.gamma.common.colorscheme
2+
3+
import android.content.res.Configuration
4+
import android.view.View
5+
import android.view.ViewParent
6+
import kotlin.properties.Delegates
7+
8+
internal typealias OnUiNightModeResolvedCallback = (nightMode: Int) -> Unit
9+
10+
internal class ColorSchemeCoordinator :
11+
ColorSchemeProviding,
12+
ColorSchemeListener {
13+
internal var colorScheme: ColorScheme by Delegates.observable(ColorScheme.INHERIT) { _, oldValue, newValue ->
14+
if (oldValue != newValue) {
15+
applyResolvedColorScheme()
16+
}
17+
}
18+
private var parentProvider: ColorSchemeProviding? = null
19+
private var systemUiNightMode: Int = Configuration.UI_MODE_NIGHT_NO
20+
private var lastAppliedUiNightMode: Int? = null
21+
private val childListeners = mutableListOf<ColorSchemeListener>()
22+
private var isSetUp = false
23+
24+
/**
25+
* Callback invoked when color scheme changes. It should be used to adapt
26+
* view's appearance to the current mode.
27+
*
28+
* The [Int] parameter represents the resolved night mode value. It can be
29+
* one of [Configuration.UI_MODE_NIGHT_UNDEFINED], [Configuration.UI_MODE_NIGHT_NO]
30+
* or [Configuration.UI_MODE_NIGHT_YES].
31+
*
32+
* This callback is invoked only if the value has changed. The change is propagated
33+
* in top-down order.
34+
*/
35+
internal var onUiNightModeResolved: OnUiNightModeResolvedCallback? = null
36+
37+
override fun getResolvedUiNightMode(): Int =
38+
when (colorScheme) {
39+
ColorScheme.LIGHT -> Configuration.UI_MODE_NIGHT_NO
40+
ColorScheme.DARK -> Configuration.UI_MODE_NIGHT_YES
41+
ColorScheme.INHERIT ->
42+
parentProvider?.getResolvedUiNightMode()
43+
?: systemUiNightMode
44+
}
45+
46+
/**
47+
* Initializes the color scheme resolution for the given [hostView]
48+
* with [onUiNightModeResolvedCallback].
49+
*
50+
* Must be called after the view is attached to the window.
51+
* Must not be called again without calling [teardown] first.
52+
*/
53+
internal fun setup(
54+
hostView: View,
55+
onUiNightModeResolvedCallback: OnUiNightModeResolvedCallback?,
56+
) {
57+
check(!isSetUp) {
58+
"[RNScreens] ColorSchemeCoordinator's setup method must not be called again without calling teardown() first."
59+
}
60+
61+
systemUiNightMode =
62+
hostView.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
63+
parentProvider = findParentColorSchemeProvider(hostView)
64+
parentProvider?.addColorSchemeListener(this)
65+
onUiNightModeResolved = onUiNightModeResolvedCallback
66+
isSetUp = true
67+
68+
// Reset last applied value so the initial callback is always invoked after setup.
69+
// This is necessary because `colorScheme` could've been set before `setup` method
70+
// is called.
71+
lastAppliedUiNightMode = null
72+
73+
applyResolvedColorScheme()
74+
}
75+
76+
internal fun teardown() {
77+
parentProvider?.removeColorSchemeListener(this)
78+
onUiNightModeResolved = null
79+
parentProvider = null
80+
lastAppliedUiNightMode = null
81+
isSetUp = false
82+
}
83+
84+
internal fun onConfigurationChanged(configuration: Configuration?) {
85+
systemUiNightMode = configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) ?: Configuration.UI_MODE_NIGHT_UNDEFINED
86+
applyResolvedColorScheme()
87+
}
88+
89+
override fun onParentUiNightModeChanged() {
90+
if (colorScheme == ColorScheme.INHERIT) {
91+
applyResolvedColorScheme()
92+
}
93+
}
94+
95+
override fun addColorSchemeListener(listener: ColorSchemeListener) {
96+
childListeners.add(listener)
97+
}
98+
99+
override fun removeColorSchemeListener(listener: ColorSchemeListener) {
100+
childListeners.remove(listener)
101+
}
102+
103+
private fun applyResolvedColorScheme() {
104+
val resolved = getResolvedUiNightMode()
105+
if (resolved == lastAppliedUiNightMode) return
106+
lastAppliedUiNightMode = resolved
107+
108+
onUiNightModeResolved?.invoke(resolved)
109+
childListeners.forEach { it.onParentUiNightModeChanged() }
110+
}
111+
112+
private fun findParentColorSchemeProvider(hostView: View): ColorSchemeProviding? {
113+
var current: ViewParent? = hostView.parent
114+
while (current != null) {
115+
if (current is ColorSchemeProviding) return current
116+
current = current.parent
117+
}
118+
return null
119+
}
120+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.swmansion.rnscreens.gamma.common.colorscheme
2+
3+
fun interface ColorSchemeListener {
4+
fun onParentUiNightModeChanged()
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.swmansion.rnscreens.gamma.common.colorscheme
2+
3+
interface ColorSchemeProviding {
4+
fun getResolvedUiNightMode(): Int // UI_MODE_NIGHT_YES, UI_MODE_NIGHT_NO or UI_MODE_NIGHT_UNDEFINED
5+
6+
fun addColorSchemeListener(listener: ColorSchemeListener)
7+
8+
fun removeColorSchemeListener(listener: ColorSchemeListener)
9+
}

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import com.facebook.react.uimanager.ThemedReactContext
1717
import com.google.android.material.R
1818
import com.google.android.material.bottomnavigation.BottomNavigationView
1919
import com.swmansion.rnscreens.BuildConfig
20+
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme
21+
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorSchemeCoordinator
22+
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorSchemeListener
23+
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorSchemeProviding
2024
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
2125
import com.swmansion.rnscreens.gamma.helpers.ViewFinder
2226
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
@@ -35,6 +39,7 @@ class TabsHost(
3539
) : FrameLayout(reactContext),
3640
TabsScreenDelegate,
3741
SafeAreaProvider,
42+
ColorSchemeProviding,
3843
View.OnLayoutChangeListener {
3944
/**
4045
* All container updates should go through instance of this class.
@@ -123,6 +128,7 @@ class TabsHost(
123128

124129
private val containerUpdateCoordinator = ContainerUpdateCoordinator()
125130
private val specialEffectsHandler = SpecialEffectsHandler()
131+
private val colorSchemeCoordinator = ColorSchemeCoordinator()
126132

127133
private val wrappedContext =
128134
ContextThemeWrapper(
@@ -161,8 +167,6 @@ class TabsHost(
161167
internal val currentFocusedTab: TabsScreenFragment
162168
get() = checkNotNull(tabsScreenFragments.find { it.tabsScreen.isFocusedTab }) { "[RNScreens] No focused tab present" }
163169

164-
private var lastAppliedUiMode: Int? = null
165-
166170
private var isLayoutEnqueued: Boolean = false
167171

168172
private var interfaceInsetsChangeListener: SafeAreaView? = null
@@ -185,6 +189,8 @@ class TabsHost(
185189
}
186190
}
187191

192+
internal var colorScheme: ColorScheme by colorSchemeCoordinator::colorScheme
193+
188194
private fun <T> updateNavigationMenuIfNeeded(
189195
oldValue: T,
190196
newValue: T,
@@ -230,6 +236,11 @@ class TabsHost(
230236
checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
231237
"[RNScreens] Nullish fragment manager - can't run container operations"
232238
}
239+
240+
colorSchemeCoordinator.setup(this) { uiNightMode ->
241+
applyDayNightUiMode(uiNightMode)
242+
}
243+
233244
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
234245
// On Paper the children are not yet attached here.
235246
containerUpdateCoordinator.let {
@@ -239,6 +250,11 @@ class TabsHost(
239250
}
240251
}
241252

253+
override fun onDetachedFromWindow() {
254+
super.onDetachedFromWindow()
255+
colorSchemeCoordinator.teardown()
256+
}
257+
242258
internal fun mountReactSubviewAt(
243259
tabsScreen: TabsScreen,
244260
index: Int,
@@ -392,34 +408,34 @@ class TabsHost(
392408

393409
override fun onConfigurationChanged(newConfig: Configuration?) {
394410
super.onConfigurationChanged(newConfig)
395-
396-
newConfig?.let {
397-
applyDayNightUiModeIfNeeded(it.uiMode and Configuration.UI_MODE_NIGHT_MASK)
398-
}
411+
colorSchemeCoordinator.onConfigurationChanged(newConfig)
399412
}
400413

401-
private fun applyDayNightUiModeIfNeeded(uiMode: Int) {
402-
if (uiMode != lastAppliedUiMode) {
403-
// update the appearance when user toggles between dark/light mode
404-
when (uiMode) {
405-
Configuration.UI_MODE_NIGHT_YES -> {
406-
wrappedContext.setTheme(R.style.Theme_Material3_Dark_NoActionBar)
407-
}
408-
409-
Configuration.UI_MODE_NIGHT_NO -> {
410-
wrappedContext.setTheme(R.style.Theme_Material3_Light_NoActionBar)
411-
}
414+
private fun applyDayNightUiMode(uiMode: Int) {
415+
// update the appearance when user toggles between dark/light mode
416+
when (uiMode) {
417+
Configuration.UI_MODE_NIGHT_YES -> {
418+
wrappedContext.setTheme(R.style.Theme_Material3_Dark_NoActionBar)
419+
}
412420

413-
else -> {
414-
wrappedContext.setTheme(R.style.Theme_Material3_DayNight_NoActionBar)
415-
}
421+
Configuration.UI_MODE_NIGHT_NO -> {
422+
wrappedContext.setTheme(R.style.Theme_Material3_Light_NoActionBar)
416423
}
417424

418-
appearanceCoordinator.updateTabAppearance(wrappedContext, this)
419-
lastAppliedUiMode = uiMode
425+
else -> {
426+
wrappedContext.setTheme(R.style.Theme_Material3_DayNight_NoActionBar)
427+
}
420428
}
429+
430+
appearanceCoordinator.updateTabAppearance(wrappedContext, this)
421431
}
422432

433+
override fun getResolvedUiNightMode() = colorSchemeCoordinator.getResolvedUiNightMode()
434+
435+
override fun addColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.addColorSchemeListener(listener)
436+
437+
override fun removeColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.removeColorSchemeListener(listener)
438+
423439
private fun forceSubtreeMeasureAndLayoutPass() {
424440
measure(
425441
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package com.swmansion.rnscreens.gamma.tabs.host
22

33
import android.view.View
4+
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
45
import com.facebook.react.module.annotations.ReactModule
56
import com.facebook.react.uimanager.ThemedReactContext
67
import com.facebook.react.uimanager.ViewGroupManager
78
import com.facebook.react.uimanager.ViewManagerDelegate
89
import com.facebook.react.uimanager.annotations.ReactProp
910
import com.facebook.react.viewmanagers.RNSTabsHostAndroidManagerDelegate
1011
import com.facebook.react.viewmanagers.RNSTabsHostAndroidManagerInterface
12+
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme
1113
import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo
1214
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostNativeFocusChangeEvent
1315
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen
@@ -81,6 +83,18 @@ class TabsHostViewManager :
8183
view.nativeContainerBackgroundColor = value
8284
}
8385

86+
override fun setColorScheme(
87+
view: TabsHost,
88+
value: String?,
89+
) {
90+
when (value) {
91+
"inherit" -> view.colorScheme = ColorScheme.INHERIT
92+
"light" -> view.colorScheme = ColorScheme.LIGHT
93+
"dark" -> view.colorScheme = ColorScheme.DARK
94+
else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid color scheme: $value.")
95+
}
96+
}
97+
8498
companion object {
8599
const val REACT_CLASS = "RNSTabsHostAndroid"
86100
}

apps/src/tests/single-feature-tests/tabs/test-tabs-color-scheme.tsx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Appearance,
33
ColorSchemeName,
4+
Platform,
45
ScrollView,
56
StyleSheet,
67
Text,
@@ -11,14 +12,14 @@ import { Scenario } from '../../shared/helpers';
1112
import { createAutoConfiguredTabs } from '../../shared/tabs';
1213
import React, { useEffect, useState } from 'react';
1314
import { SettingsPicker } from '../../../shared';
14-
import type { TabsHostPropsIOS } from 'react-native-screens';
15+
import type { TabsHostProps } from 'react-native-screens';
1516
import useTabsConfigState from '../../shared/hooks/tabs-config';
1617

1718
const SCENARIO: Scenario = {
1819
name: 'Color Scheme',
1920
key: 'test-tabs-color-scheme',
2021
details: 'Tests how tabs handle system, React Native and prop color scheme.',
21-
platforms: ['ios'],
22+
platforms: ['android', 'ios'],
2223
AppComponent: App,
2324
};
2425

@@ -34,6 +35,21 @@ function ConfigScreen() {
3435
const [reactColorScheme, setReactColorScheme] =
3536
useState<ColorSchemeName>('unspecified');
3637

38+
// TODO: Tabs.Autoconfig should allow initial prop configuration.
39+
useEffect(() => {
40+
dispatch({
41+
type: 'tabScreen',
42+
tabKey: 'Config',
43+
config: {
44+
safeAreaConfiguration: {
45+
edges: {
46+
bottom: true,
47+
},
48+
},
49+
},
50+
});
51+
}, [dispatch]);
52+
3753
useEffect(() => {
3854
Appearance.setColorScheme(reactColorScheme);
3955
}, [reactColorScheme]);
@@ -69,18 +85,14 @@ function ConfigScreen() {
6985

7086
<View style={styles.section}>
7187
<Text style={styles.heading}>TabsHost color scheme</Text>
72-
<SettingsPicker<NonNullable<TabsHostPropsIOS.TabsHostColorScheme>>
88+
<SettingsPicker<NonNullable<TabsHostProps['colorScheme']>>
7389
label={'colorScheme'}
74-
value={config.ios?.colorScheme ?? 'inherit'}
75-
onValueChange={function (
76-
value: TabsHostPropsIOS.TabsHostColorScheme,
77-
): void {
90+
value={config.colorScheme ?? 'inherit'}
91+
onValueChange={function (value: TabsHostProps['colorScheme']): void {
7892
dispatch({
7993
type: 'tabBar',
8094
config: {
81-
ios: {
82-
colorScheme: value,
83-
},
95+
colorScheme: value,
8496
},
8597
});
8698
}}
@@ -123,6 +135,7 @@ const styles = StyleSheet.create({
123135
},
124136
content: {
125137
padding: 20,
138+
paddingTop: Platform.OS === 'android' ? 60 : undefined,
126139
},
127140
heading: {
128141
fontSize: 24,

src/components/tabs/TabsHost.ios.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ function TabsHost(props: TabsHostProps) {
5454
ref={componentNodeRef}
5555
{...filteredBaseProps}
5656
// iOS-specific
57-
colorScheme={ios?.colorScheme}
5857
controlNavigationStateInJS={controlNavigationStateInJS}
5958
layoutDirection={direction}
6059
tabBarControllerMode={ios?.tabBarControllerMode}

0 commit comments

Comments
 (0)