React Native 暗黑模式適配方案

通過 React Native 中自帶的 Appearance 實現(xiàn)

Appearance 提供的 API

type ColorSchemeName = 'light' | 'dark' | null | undefined;

export namespace Appearance {
    type AppearancePreferences = {
        colorScheme: ColorSchemeName;
    };
    type AppearanceListener = (preferences: AppearancePreferences) => void;

    export function getColorScheme(): ColorSchemeName;
    export function addChangeListener(listener: AppearanceListener): void;
    export function removeChangeListener(listener: AppearanceListener): void;
}

export function useColorScheme(): ColorSchemeName;

考慮到項目都是通過 class 實現(xiàn),那么我們優(yōu)先研究非 Hook 方式如何實現(xiàn)。

這里有兩個問題需要注意:

  • 暗黑模式和正常模式之間來回切換
  • 暗黑模式和正常模式下顏色匹配邏輯

暗黑模式和正常模式之間來回切換

根據(jù)上面的 API,我們很容易用到 addChangeListener 方法來監(jiān)聽。但是這里有個問題需要考慮,如果 App 的暗黑模式只追隨系統(tǒng)變化,那么就簡單很多了,接下來只需要考慮,如何優(yōu)雅的實現(xiàn)即可。實際業(yè)務(wù)中有很多場景是根據(jù)當(dāng)前 App 的設(shè)置而定的。

Appearance 提供的 API 只能讀取狀態(tài),沒法修改。在實踐中我們發(fā)現(xiàn)在原生中修改暗黑模式的狀態(tài) RN 的 Appearance 響應(yīng),Android 可以做到,而 iOS 暫時沒有找到方法;

Android 通過獲取 RN 當(dāng)前的環(huán)境是可以修改

reactContext.getResources().getConfiguration().uiMode = UI_MODE_NIGHT_YES;

iOS 原生中修改 RCTRootView 的 overrideUserInterfaceStyle 屬性,或者遍歷當(dāng)前 RN 視圖進(jìn)行修改, RN 的 Appearance 是沒法響應(yīng)的。

React Native 內(nèi)部的實現(xiàn)可以參考 react-native-appearance

小結(jié)

如果 App 需要支持自定義切換暗黑模式(不追隨系統(tǒng)變化而變化),那么通過 React-Native 中 Appearance 暫時是無法實現(xiàn)的。

React Native 讀取原生自定義暗黑模式狀態(tài)

既然 RN 的暗黑模式只通過原生讀取,那么在 RN 中的狀態(tài)也只能自定義了,同樣上面兩個問題也需要解決。

  • 暗黑模式和正常模式之間來回切換
  • 暗黑模式和正常模式下顏色匹配邏輯

暗黑模式和正常模式之間來回切換

原生通知各個 RN 模板進(jìn)行變化即可,當(dāng)然為了避免各個模塊的子視圖做重復(fù)監(jiān)聽,可以通過 Provider 來實現(xiàn)。

以 iOS 為例,原生需要支持

  • 初始 RN 模塊時,提供暗黑模式狀態(tài)
  • 原生暗黑模式變化時,通知 RN 模塊
// 初始 RN 模塊時,提供暗黑模式狀態(tài)
NSMutableDictionary *initialProperties = [NSMutableDictionary dictionary];
initialProperties[@"isDark"] = @(false);
#ifdef __IPHONE_13_0
if (@available(iOS 13.0, *))
{
    BOOL isDark = UIApplication.sharedApplication.keyWindow.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
    initialProperties[@"isDark"] = @(isDark);
}
#endif
NSURL *jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"index.ios"
                                                withExtension:@"jsbundle"
                                                 subdirectory:@"bundle"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                    moduleName:@"XXXRNModuleName"
                                             initialProperties:initialProperties
                                                 launchOptions:nil];
// 原生暗黑模式變化時,通知 RN 模塊
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
{
    [super traitCollectionDidChange:previousTraitCollection];
    
#ifdef __IPHONE_13_0
    if (@available(iOS 13.0, *))
    {
        BOOL isDark = NO;
        if (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark)
        {
            isDark = YES;
        }
        NSDictionary *dict = @{@"isDark": @(isDark)};
        NSError *parseError = nil;
        NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dict
                                                           options:NSJSONWritingPrettyPrinted
                                                             error:&parseError];
        if (jsonData)
        {
            NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
            if (jsonString)
            {
                NSMutableDictionary *appProperties = [NSMutableDictionary dictionary];
                if (_rootView.appProperties)
                {
                    [appProperties addEntriesFromDictionary:_rootView.appProperties];
                }
                appProperties[@"isDark"] = @(isDark);
                _rootView.appProperties = appProperties;
                [_rootView.bridge enqueueJSCall:@"RCTDeviceEventEmitter"
                                         method:@"emit" args:@[@"onChangeDarkMode", jsonString]
                                     completion:nil];
            }
        }
    }
#endif
}

在 RN 模塊中,比較自然的想到統(tǒng)一監(jiān)聽原生的暗黑模式狀態(tài)變化以及通過 Provider 為子視圖提供統(tǒng)一的狀態(tài)

interface DarkModeProviderProps {
  isDark: boolean;
  children: ReactNode;
}

interface DarkModeProviderState {
  isDark: boolean;
}

let subscription: EmitterSubscription;
export class DarkModeProvider extends Component<DarkModeProviderProps, DarkModeProviderState>  {
  constructor(props: DarkModeProviderProps) {
    super(props);
    this.state = {
      isDark: props.isDark
    }
  }

  componentDidMount() {
    subscription = DeviceEventEmitter.addListener("onChangeDarkMode", (e) => {
      const jsonObj = JSON.parse(e);
      if (jsonObj) {
        this.setState({
          isDark: jsonObj.isDark
        })
      }
    });
  }

  render() {
    return (
      <DarkModeContext.Provider value={{'isDark': this.props.isDark}} {...this.props} />
    )
  }
}

export const DarkModeContext = React.createContext({'isDark': false});

接下來看一下業(yè)務(wù)的實現(xiàn)

class TestMain extends Component {
  static contextType = DarkModeContext;
  render() {
    const containerBackgroundColor = this.context.isDark ? '#0D0D0D' : '#F7F7F7';
    const contentContainerBackgroundColor = this.context.isDark ? '#1C1C1C' : '#FFFFFF';
    const titleColor = this.context.isDark ? '#F2F2F2' : '#262626';
    const subTitleColor = this.context.isDark ? '#BBBBBB' : '#8C8C8C';
    return (
      <View style={{...mainStyles.container, backgroundColor: containerBackgroundColor}}>
        <View style={{...mainStyles.contentContainer, backgroundColor: contentContainerBackgroundColor}}>
          <Text style={{...mainStyles.title, color: titleColor}}>
            {'大標(biāo)題'}
          </Text>
          <Text style={{...mainStyles.subTitle, color: subTitleColor}}>
            {'小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題'}
          </Text>
        </View>
      </View>
    )
  }
}

interface RootTestProps {
  isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
  render() {
    return (
        <DarkModeProvider isDark={this.props.isDark}>
          <TestMain />
        </DarkModeProvider>
    );
  }
};

通過上面的方式,業(yè)務(wù)需求也是可以實現(xiàn)的,只是方式有點難看。

img1
img2

接下來就是如果把實現(xiàn)方式變得優(yōu)雅一些

每個地方都來寫把暗黑模式和正常模式下的顏色很冗余,也不利于統(tǒng)一管理,比較容易想到就是封裝一個 DarkColorUtility 來統(tǒng)一管理顏色。這樣會遇到一個問題,在 DarkColorUtility 中如何獲取 DarkModeContext

根據(jù)發(fā)現(xiàn)只能通過 hook 的方式才能獲取 DarkModeContext,那么 TestMain 也只能改成 function 的方式,第一步優(yōu)化之后的效果

// 工具類方法
function darkModeColor(light: string, dark: string) {
  const context = useContext(DarkModeContext);
  if (context.isDark) {
    return dark;
  } else {
    return light;
  }
}

export class DarkColorUtility extends Component {
  static color_F7F7F7() {
    return darkModeColor('#F7F7F7', '#0D0D0D');
  }
  static color_FFFFFF() {
    return darkModeColor('#FFFFFF', '#1C1C1C');
  }
  static color_262626() {
    return darkModeColor('#262626', '#F2F2F2');
  }
  static color_8C8C8C() {
    return darkModeColor('#8C8C8C', '#BBBBBB');
  }
}

業(yè)務(wù)調(diào)整之后的方式

function TestMain() {
  return (
    <View style={{ ...mainStyles.container, backgroundColor: DarkColorUtility.color_F7F7F7() }}>
      <View style={{ ...mainStyles.contentContainer, backgroundColor: DarkColorUtility.color_FFFFFF() }}>
        <Text style={{ ...mainStyles.title, color: DarkColorUtility.color_262626() }}>
          {'大標(biāo)題'}
        </Text>
        <Text style={{ ...mainStyles.subTitle, color: DarkColorUtility.color_8C8C8C() }}>
          {'小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題'}
        </Text>
      </View>
    </View>
  )
}

這一步其實已經(jīng)差不多了,在實際開發(fā)用很多同學(xué)其實不怎么喜歡用 StyleSheet 來創(chuàng)建 style,比如

function TestMain() {
  return (
    <View style={{ flex: 1, backgroundColor: DarkColorUtility.color_F7F7F7() }}>
      <View style={{ flex: 1, marginTop: 16, padding: 16, backgroundColor: DarkColorUtility.color_FFFFFF() }}>
        <Text style={{ fontSize: 20, marginBottom: 8, color: DarkColorUtility.color_262626() }}>
          {'大標(biāo)題'}
        </Text>
        <Text style={{ fontSize: 16, lineHeight: 20, color: DarkColorUtility.color_8C8C8C() }}>
          {'小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題'}
        </Text>
      </View>
    </View>
  )
}

這樣在實際調(diào)試中比較方法,不需要在 StyleSheet 中業(yè)務(wù)中來回找,同時在那些特別復(fù)雜的界面命名的負(fù)擔(dān)也是很重的。

不過考慮到還是有很多同學(xué)喜歡用 StyleSheet,那么就繼續(xù)思考,怎樣在 StyleSheet 中寫 DarkColorUtility 中的工具方法。

為了在 StyleSheet 中直接使用 DarkColorUtility 中的工具方法,發(fā)現(xiàn)只能對 StyleSheet 進(jìn)行重新封裝了。這里我們就直接參考 react-native-dynamic 的實現(xiàn)。

function parseStylesFor(styles, mode) {
    const newStyles = {};
    let containsDynamicValues = false;
    for (const i in styles) {
        const style = styles[i];
        const newStyle = {};
        for (const i in style) {
            const value = style[i];
            if (value instanceof DynamicValue) {
                containsDynamicValues = true;
                newStyle[i] = value[mode];
            }
            else {
                newStyle[i] = value;
            }
        }
        newStyles[i] = newStyle;
    }
    if (!containsDynamicValues && process.env.NODE_ENV !== 'production') {
        console.warn('A DynamicStyleSheet was used without any DynamicValues. Consider replacing with a regular StyleSheet.');
    }
    return newStyles;
}
export class DynamicStyleSheet {
    constructor(styles) {
        this.dark = StyleSheet.create(parseStylesFor(styles, 'dark'));
        this.light = StyleSheet.create(parseStylesFor(styles, 'light'));
    }
}
export const useDynamicStyleSheet = useDynamicValue;

DynamicStyleSheet 使用的 DynamicValue 相當(dāng)于 DarkColorUtility。

export class DynamicValue {
    constructor(light, dark) {
        this.light = light;
        this.dark = dark;
    }
}

業(yè)務(wù)效果如下

const mainDynamicStyles = new DynamicStyleSheet({
  container: {
    flex: 1,
    backgroundColor: DarkMode.color_F7F7F7()
  },
  contentContainer: {
    flex: 1,
    marginTop: 16,
    padding: 16,
    backgroundColor: DarkMode.color_FFFFFF()
  },
  title: {
    fontSize: 20,
    marginBottom: 8,
    color: DarkMode.color_262626()
  },
  subTitle: {
    fontSize: 16,
    lineHeight: 20,
    color: DarkMode.color_8C8C8C()
  }
});

function TestMain() {
  const styles = useDynamicValue(mainDynamicStyles);
  return (
    <View style={styles.container}>
      <View style={styles.contentContainer}>
        <Text style={styles.title}>
          {'大標(biāo)題'}
        </Text>
        <Text style={styles.subTitle}>
          {'小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題'}
        </Text>
      </View>
    </View>
  )
}

幾種方案對比

方案一:業(yè)務(wù)通過 class 實現(xiàn)

// 統(tǒng)一的工具方法,便于業(yè)務(wù)使用
function darkModeColor(light: string, dark: string, isDark: boolean = false) {
  if (isDark) {
    return dark;
  } else {
    return light;
  }
}
export class DarkColorUtility extends Component {
  static color_F7F7F7(isDark: boolean = false) {
    return darkModeColor('#F7F7F7', '#0D0D0D', isDark);
  }
  static color_FFFFFF(isDark: boolean = false) {
    return darkModeColor('#FFFFFF', '#1C1C1C', isDark);
  }
  static color_262626(isDark: boolean = false) {
    return darkModeColor('#262626', '#F2F2F2', isDark);
  }
  static color_8C8C8C(isDark: boolean = false) {
    return darkModeColor('#8C8C8C', '#BBBBBB', isDark);
  }
}


// 業(yè)務(wù)實現(xiàn)例子
class TestMain extends Component {
  static contextType = DarkModeContext;
  render() {
    return (
      <View style={{...mainStyles.container, backgroundColor: DarkColorUtility.color_F7F7F7(this.context.isDark)}}>
        <View style={{...mainStyles.contentContainer, backgroundColor: DarkColorUtility.color_FFFFFF(this.context.isDark)}}>
          <Text style={{...mainStyles.title, color: DarkColorUtility.color_262626(this.context.isDark)}}>
            {'大標(biāo)題'}
          </Text>
          <Text style={{...mainStyles.subTitle, color: DarkColorUtility.color_8C8C8C(this.context.isDark)}}>
            {'小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題'}
          </Text>
        </View>
      </View>
    )
  }
}
interface RootTestProps {
  isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
  render() {
    return (
        <DarkModeProvider isDark={this.props.isDark}>
          <TestMain />
        </DarkModeProvider>
    );
  }
};

方案二:業(yè)務(wù)通過 function 實現(xiàn),同時不用 StyleSheet 來創(chuàng)建 style

// 統(tǒng)一的工具方法,便于業(yè)務(wù)使用
function darkModeColor(light: string, dark: string) {
  const context = useContext(DarkModeContext);
  if (context.isDark) {
    return dark;
  } else {
    return light;
  }
}
export class DarkColorUtility extends Component {
  static color_F7F7F7() {
    return darkModeColor('#F7F7F7', '#0D0D0D');
  }
  static color_FFFFFF() {
    return darkModeColor('#FFFFFF', '#1C1C1C');
  }
  static color_262626() {
    return darkModeColor('#262626', '#F2F2F2');
  }
  static color_8C8C8C() {
    return darkModeColor('#8C8C8C', '#BBBBBB');
  }
}

// 業(yè)務(wù)實現(xiàn)例子
function TestMain() {
  return (
    <View style={{ flex: 1, backgroundColor: DarkColorUtility.color_F7F7F7() }}>
      <View style={{ flex: 1, marginTop: 16, padding: 16, backgroundColor: DarkColorUtility.color_FFFFFF() }}>
        <Text style={{ fontSize: 20, marginBottom: 8, color: DarkColorUtility.color_262626() }}>
          {'大標(biāo)題'}
        </Text>
        <Text style={{ fontSize: 16, lineHeight: 20, color: DarkColorUtility.color_8C8C8C() }}>
          {'小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題'}
        </Text>
      </View>
    </View>
  )
}
interface RootTestProps {
  isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
  render() {
    return (
        <DarkModeProvider isDark={this.props.isDark}>
          <TestMain />
        </DarkModeProvider>
    );
  }
};

方案三:業(yè)務(wù)通過 function 實現(xiàn),同時也要用 StyleSheet 來創(chuàng)建 style,借助 react-native-dynamic 來實現(xiàn)

// 統(tǒng)一的工具方法,便于業(yè)務(wù)使用
export class DarkMode {
  static color_F7F7F7() {
    return new DynamicValue('#F7F7F7', '#0D0D0D');
  }
  static color_FFFFFF() {
    return new DynamicValue('#FFFFFF', '#1C1C1C');
  }
  static color_262626() {
    return new DynamicValue('#262626', '#F2F2F2');
  }
  static color_8C8C8C() {
    return new DynamicValue('#8C8C8C', '#BBBBBB');
  }
}

// 業(yè)務(wù)實現(xiàn)列子
const mainDynamicStyles = new DynamicStyleSheet({
  container: {
    flex: 1,
    backgroundColor: DarkMode.color_F7F7F7()
  },
  contentContainer: {
    flex: 1,
    marginTop: 16,
    padding: 16,
    backgroundColor: DarkMode.color_FFFFFF()
  },
  title: {
    fontSize: 20,
    marginBottom: 8,
    color: DarkMode.color_262626()
  },
  subTitle: {
    fontSize: 16,
    lineHeight: 20,
    color: DarkMode.color_8C8C8C()
  }
});
function TestMain() {
  const styles = useDynamicValue(mainDynamicStyles);
  return (
    <View style={styles.container}>
      <View style={styles.contentContainer}>
        <Text style={styles.title}>
          {'大標(biāo)題'}
        </Text>
        <Text style={styles.subTitle}>
          {'小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題小標(biāo)題'}
        </Text>
      </View>
    </View>
  )
}
interface RootTestProps {
  isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
  render() {
    return (
        <DarkModeProvider isDark={this.props.isDark}>
          <TestMain />
        </DarkModeProvider>
    );
  }
};

總結(jié)

  • 推薦項目盡量通過 Hook 的方式來實現(xiàn)
  • 推薦項目適配暗黑模式采用上面方案三
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容