通過 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)的,只是方式有點難看。


接下來就是如果把實現(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)
- 推薦項目適配暗黑模式采用上面方案三