Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swmansion.rnscreens.gamma.common.colorscheme

internal enum class ColorScheme {
INHERIT,
LIGHT,
DARK,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.swmansion.rnscreens.gamma.common.colorscheme

import android.content.res.Configuration
import android.view.View
import android.view.ViewParent
import kotlin.properties.Delegates

internal typealias OnUiNightModeResolvedCallback = (nightMode: Int) -> Unit

internal class ColorSchemeCoordinator :
ColorSchemeProviding,
ColorSchemeListener {
internal var colorScheme: ColorScheme by Delegates.observable(ColorScheme.INHERIT) { _, oldValue, newValue ->
if (oldValue != newValue) {
applyResolvedColorScheme()
}
}
private var parentProvider: ColorSchemeProviding? = null
private var systemUiNightMode: Int = Configuration.UI_MODE_NIGHT_NO
private var lastAppliedUiNightMode: Int? = null
private val childListeners = mutableListOf<ColorSchemeListener>()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe mutableSet ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we usually used list instead of a set for a small amount of entities but I guess that after we renamed onAttachedToWindow to setup the intention might not be as clear and somebody might call setup multiple times and create duplicates. We can use a set or maybe just throw an error if setup is called when parentProvider is not null? Not sure what's better here.

cc @kkafar

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that setup looks risky with internal access modifier, so as you already mentioned, maybe it would be possible to handle it when we don't expect it to be called subsequent times

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's throw an error in case setup is called multiple times before teardown is called

Copy link
Copy Markdown
Contributor Author

@kligarski kligarski Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used separate property because I can't determine in other way whether setup has been run (callback might be nullable, parent might also be null if we're top-level provider).

3f91fba

private var isSetUp = false

/**
* Callback invoked when color scheme changes. It should be used to adapt
* view's appearance to the current mode.
*
* The [Int] parameter represents the resolved night mode value. It can be
* one of [Configuration.UI_MODE_NIGHT_UNDEFINED], [Configuration.UI_MODE_NIGHT_NO]
* or [Configuration.UI_MODE_NIGHT_YES].
*
* This callback is invoked only if the value has changed. The change is propagated
* in top-down order.
*/
internal var onUiNightModeResolved: OnUiNightModeResolvedCallback? = null

override fun getResolvedUiNightMode(): Int =
when (colorScheme) {
ColorScheme.LIGHT -> Configuration.UI_MODE_NIGHT_NO
ColorScheme.DARK -> Configuration.UI_MODE_NIGHT_YES
ColorScheme.INHERIT ->
parentProvider?.getResolvedUiNightMode()
?: systemUiNightMode
}

/**
* Initializes the color scheme resolution for the given [hostView]
* with [onUiNightModeResolvedCallback].
*
* Must be called after the view is attached to the window.
* Must not be called again without calling [teardown] first.
*/
internal fun setup(
hostView: View,
onUiNightModeResolvedCallback: OnUiNightModeResolvedCallback?,
) {
check(!isSetUp) {
"[RNScreens] ColorSchemeCoordinator's setup method must not be called again without calling teardown() first."
}

systemUiNightMode =
hostView.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
parentProvider = findParentColorSchemeProvider(hostView)
parentProvider?.addColorSchemeListener(this)
onUiNightModeResolved = onUiNightModeResolvedCallback
isSetUp = true

// Reset last applied value so the initial callback is always invoked after setup.
// This is necessary because `colorScheme` could've been set before `setup` method
// is called.
lastAppliedUiNightMode = null

applyResolvedColorScheme()
}

internal fun teardown() {
parentProvider?.removeColorSchemeListener(this)
onUiNightModeResolved = null
parentProvider = null
lastAppliedUiNightMode = null
isSetUp = false
}

internal fun onConfigurationChanged(configuration: Configuration?) {
systemUiNightMode = configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) ?: Configuration.UI_MODE_NIGHT_UNDEFINED
applyResolvedColorScheme()
}

override fun onParentUiNightModeChanged() {
if (colorScheme == ColorScheme.INHERIT) {
applyResolvedColorScheme()
}
}

override fun addColorSchemeListener(listener: ColorSchemeListener) {
childListeners.add(listener)
}

override fun removeColorSchemeListener(listener: ColorSchemeListener) {
childListeners.remove(listener)
}

private fun applyResolvedColorScheme() {
val resolved = getResolvedUiNightMode()
if (resolved == lastAppliedUiNightMode) return
lastAppliedUiNightMode = resolved

onUiNightModeResolved?.invoke(resolved)
childListeners.forEach { it.onParentUiNightModeChanged() }
}

private fun findParentColorSchemeProvider(hostView: View): ColorSchemeProviding? {
var current: ViewParent? = hostView.parent
while (current != null) {
if (current is ColorSchemeProviding) return current
current = current.parent
}
return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.swmansion.rnscreens.gamma.common.colorscheme

fun interface ColorSchemeListener {
fun onParentUiNightModeChanged()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.swmansion.rnscreens.gamma.common.colorscheme

interface ColorSchemeProviding {
fun getResolvedUiNightMode(): Int // UI_MODE_NIGHT_YES, UI_MODE_NIGHT_NO or UI_MODE_NIGHT_UNDEFINED

fun addColorSchemeListener(listener: ColorSchemeListener)

fun removeColorSchemeListener(listener: ColorSchemeListener)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.R
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.swmansion.rnscreens.BuildConfig
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorSchemeCoordinator
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorSchemeListener
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorSchemeProviding
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
import com.swmansion.rnscreens.gamma.helpers.ViewFinder
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
Expand All @@ -35,6 +39,7 @@ class TabsHost(
) : FrameLayout(reactContext),
TabsScreenDelegate,
SafeAreaProvider,
ColorSchemeProviding,
View.OnLayoutChangeListener {
/**
* All container updates should go through instance of this class.
Expand Down Expand Up @@ -123,6 +128,7 @@ class TabsHost(

private val containerUpdateCoordinator = ContainerUpdateCoordinator()
private val specialEffectsHandler = SpecialEffectsHandler()
private val colorSchemeCoordinator = ColorSchemeCoordinator()

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

private var lastAppliedUiMode: Int? = null

private var isLayoutEnqueued: Boolean = false

private var interfaceInsetsChangeListener: SafeAreaView? = null
Expand All @@ -185,6 +189,8 @@ class TabsHost(
}
}

internal var colorScheme: ColorScheme by colorSchemeCoordinator::colorScheme

private fun <T> updateNavigationMenuIfNeeded(
oldValue: T,
newValue: T,
Expand Down Expand Up @@ -230,6 +236,11 @@ class TabsHost(
checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
"[RNScreens] Nullish fragment manager - can't run container operations"
}

colorSchemeCoordinator.setup(this) { uiNightMode ->
applyDayNightUiMode(uiNightMode)
}

if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// On Paper the children are not yet attached here.
containerUpdateCoordinator.let {
Expand All @@ -239,6 +250,11 @@ class TabsHost(
}
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
colorSchemeCoordinator.teardown()
}

internal fun mountReactSubviewAt(
tabsScreen: TabsScreen,
index: Int,
Expand Down Expand Up @@ -392,34 +408,34 @@ class TabsHost(

override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)

newConfig?.let {
applyDayNightUiModeIfNeeded(it.uiMode and Configuration.UI_MODE_NIGHT_MASK)
}
colorSchemeCoordinator.onConfigurationChanged(newConfig)
}

private fun applyDayNightUiModeIfNeeded(uiMode: Int) {
if (uiMode != lastAppliedUiMode) {
// update the appearance when user toggles between dark/light mode
when (uiMode) {
Configuration.UI_MODE_NIGHT_YES -> {
wrappedContext.setTheme(R.style.Theme_Material3_Dark_NoActionBar)
}

Configuration.UI_MODE_NIGHT_NO -> {
wrappedContext.setTheme(R.style.Theme_Material3_Light_NoActionBar)
}
private fun applyDayNightUiMode(uiMode: Int) {
// update the appearance when user toggles between dark/light mode
when (uiMode) {
Configuration.UI_MODE_NIGHT_YES -> {
wrappedContext.setTheme(R.style.Theme_Material3_Dark_NoActionBar)
}

else -> {
wrappedContext.setTheme(R.style.Theme_Material3_DayNight_NoActionBar)
}
Configuration.UI_MODE_NIGHT_NO -> {
wrappedContext.setTheme(R.style.Theme_Material3_Light_NoActionBar)
}

appearanceCoordinator.updateTabAppearance(wrappedContext, this)
lastAppliedUiMode = uiMode
else -> {
wrappedContext.setTheme(R.style.Theme_Material3_DayNight_NoActionBar)
}
}

appearanceCoordinator.updateTabAppearance(wrappedContext, this)
}

override fun getResolvedUiNightMode() = colorSchemeCoordinator.getResolvedUiNightMode()

override fun addColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.addColorSchemeListener(listener)

override fun removeColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.removeColorSchemeListener(listener)

private fun forceSubtreeMeasureAndLayoutPass() {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.swmansion.rnscreens.gamma.tabs.host

import android.view.View
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.RNSTabsHostAndroidManagerDelegate
import com.facebook.react.viewmanagers.RNSTabsHostAndroidManagerInterface
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme
import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostNativeFocusChangeEvent
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen
Expand Down Expand Up @@ -81,6 +83,18 @@ class TabsHostViewManager :
view.nativeContainerBackgroundColor = value
}

override fun setColorScheme(
view: TabsHost,
value: String?,
) {
when (value) {
"inherit" -> view.colorScheme = ColorScheme.INHERIT
"light" -> view.colorScheme = ColorScheme.LIGHT
"dark" -> view.colorScheme = ColorScheme.DARK
else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid color scheme: $value.")
}
}

companion object {
const val REACT_CLASS = "RNSTabsHostAndroid"
}
Expand Down
33 changes: 23 additions & 10 deletions apps/src/tests/single-feature-tests/tabs/test-tabs-color-scheme.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Appearance,
ColorSchemeName,
Platform,
ScrollView,
StyleSheet,
Text,
Expand All @@ -11,14 +12,14 @@ import { Scenario } from '../../shared/helpers';
import { createAutoConfiguredTabs } from '../../shared/tabs';
import React, { useEffect, useState } from 'react';
import { SettingsPicker } from '../../../shared';
import type { TabsHostPropsIOS } from 'react-native-screens';
import type { 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: ['ios'],
platforms: ['android', 'ios'],
AppComponent: App,
};

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

// TODO: Tabs.Autoconfig should allow initial prop configuration.
useEffect(() => {
dispatch({
type: 'tabScreen',
tabKey: 'Config',
config: {
safeAreaConfiguration: {
edges: {
bottom: true,
},
},
},
});
}, [dispatch]);

useEffect(() => {
Appearance.setColorScheme(reactColorScheme);
}, [reactColorScheme]);
Expand Down Expand Up @@ -69,18 +85,14 @@ function ConfigScreen() {

<View style={styles.section}>
<Text style={styles.heading}>TabsHost color scheme</Text>
<SettingsPicker<NonNullable<TabsHostPropsIOS.TabsHostColorScheme>>
<SettingsPicker<NonNullable<TabsHostProps['colorScheme']>>
label={'colorScheme'}
value={config.ios?.colorScheme ?? 'inherit'}
onValueChange={function (
value: TabsHostPropsIOS.TabsHostColorScheme,
): void {
value={config.colorScheme ?? 'inherit'}
onValueChange={function (value: TabsHostProps['colorScheme']): void {
dispatch({
type: 'tabBar',
config: {
ios: {
colorScheme: value,
},
colorScheme: value,
},
});
}}
Expand Down Expand Up @@ -123,6 +135,7 @@ const styles = StyleSheet.create({
},
content: {
padding: 20,
paddingTop: Platform.OS === 'android' ? 60 : undefined,
},
heading: {
fontSize: 24,
Expand Down
1 change: 0 additions & 1 deletion src/components/tabs/TabsHost.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ function TabsHost(props: TabsHostProps) {
ref={componentNodeRef}
{...filteredBaseProps}
// iOS-specific
colorScheme={ios?.colorScheme}
controlNavigationStateInJS={controlNavigationStateInJS}
layoutDirection={direction}
tabBarControllerMode={ios?.tabBarControllerMode}
Expand Down
Loading
Loading