Skip to content

feat(Tabs, Android): handle colorScheme (dark mode)#3723

Merged
t0maboro merged 15 commits intomainfrom
@kligarski/tabs-color-scheme-android
Mar 10, 2026
Merged

feat(Tabs, Android): handle colorScheme (dark mode)#3723
t0maboro merged 15 commits intomainfrom
@kligarski/tabs-color-scheme-android

Conversation

@kligarski
Copy link
Copy Markdown
Contributor

@kligarski kligarski commented Mar 4, 2026

Description

Adds support for colorScheme prop for TabsHost on Android.

This is a follow-up to #3716 and it follows RFC-0996.

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

test_color_scheme_android.mp4

Test plan

Run single-feature-tests/test-tabs-color-scheme.tsx.

To test theme propagation you can use this test:

Theme propagation test
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,
  },
});

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • Ensured that CI passes

@kligarski kligarski self-assigned this Mar 4, 2026
@kligarski kligarski added type:feature New feature or request platform:android Issue related to Android part of the library area:tabs Issue related to bottom tabs labels Mar 4, 2026
@kligarski kligarski requested a review from Copilot March 4, 2026 12:30
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds Android support for the TabsHost colorScheme prop (dark mode), including a reusable native color-scheme propagation mechanism that mirrors iOS trait propagation, and updates the single-feature test to run on Android.

Changes:

  • Adds colorScheme to the public TabsHostProps API for Android (and positions it as a general prop).
  • Implements Android-side colorScheme handling in TabsHost via a reusable ColorSchemeCoordinator + listener/provider interfaces.
  • Updates the test-tabs-color-scheme single-feature test to run on Android and adjusts layout/safe-area behavior accordingly.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/fabric/tabs/TabsHostNativeComponent.ts Exposes colorScheme in the native props interface as a general prop.
src/components/tabs/TabsHost.types.ts Documents colorScheme as supported on Android + iOS (general section).
apps/src/tests/single-feature-tests/tabs/test-tabs-color-scheme.tsx Runs the scenario on Android and adjusts padding/safe-area config for Android layout.
android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt Wires colorScheme prop from JS to native TabsHost.
android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt Applies resolved night mode to Material theme + tab appearance; integrates coordinator lifecycle hooks.
android/src/main/java/com/swmansion/rnscreens/gamma/common/colorscheme/* Introduces reusable coordinator + interfaces/enums for night-mode resolution/propagation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

@kligarski kligarski marked this pull request as ready for review March 4, 2026 14:30
Copy link
Copy Markdown
Contributor

@t0maboro t0maboro left a comment

Choose a reason for hiding this comment

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

Looks good. If possible, could this wait for 1-2 more days until we'll ship #3722 ?

@kligarski
Copy link
Copy Markdown
Contributor Author

Looks good. If possible, could this wait for 1-2 more days until we'll ship #3722 ?

Sure!

Copy link
Copy Markdown
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

Good job.

I've left few remarks. Please answer them.

@kligarski kligarski requested a review from Copilot March 10, 2026 10:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

@kligarski kligarski requested review from kkafar and t0maboro March 10, 2026 11:14
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

Copy link
Copy Markdown
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

Let's apply the last few remarks and proceed.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/components/tabs/TabsHost.ios.tsx:60

  • TabsHost.ios.tsx no longer forwards ios?.colorScheme to the native component. If you decide to preserve backward compatibility for the previous iOS-only API, you’ll need to map ios?.colorScheme into the new base colorScheme prop when props.colorScheme isn’t provided, otherwise existing apps will silently stop applying the theme override.
      ref={componentNodeRef}
      {...filteredBaseProps}
      // iOS-specific
      controlNavigationStateInJS={controlNavigationStateInJS}
      layoutDirection={direction}
      tabBarControllerMode={ios?.tabBarControllerMode}
      tabBarMinimizeBehavior={ios?.tabBarMinimizeBehavior}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

@t0maboro
Copy link
Copy Markdown
Contributor

As discussed internally, I'm merging it to have this API in the PRs related to Tabs stabilization

@t0maboro t0maboro merged commit dc15e96 into main Mar 10, 2026
10 of 11 checks passed
@t0maboro t0maboro deleted the @kligarski/tabs-color-scheme-android branch March 10, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:tabs Issue related to bottom tabs platform:android Issue related to Android part of the library type:feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants