React Native 暗黑模式适配方案
通过 React Native 中自带的 Appearance 实现
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 实现,那么我们优先研究非 Hook 方式如何实现。
这里有两个问题需要注意:
- 暗黑模式和正常模式之间来回切换
- 暗黑模式和正常模式下颜色匹配逻辑
暗黑模式和正常模式之间来回切换
根据上面的 API,我们很容易用到 addChangeListener
方法来监听。但是这里有个问题需要考虑,如果 App 的暗黑模式只追随系统变化,那么就简单很多了,接下来只需要考虑,如何优雅的实现即可。实际业务中有很多场景是根据当前 App 的设置而定的。
Appearance 提供的 API 只能读取状态,没法修改。在实践中我们发现在原生中修改暗黑模式的状态 RN 的 Appearance 响应,Android 可以做到,而 iOS 暂时没有找到方法;
Android 通过获取 RN 当前的环境是可以修改
reactContext.getResources().getConfiguration().uiMode = UI_MODE_NIGHT_YES;
iOS 原生中修改 RCTRootView 的 overrideUserInterfaceStyle 属性,或者遍历当前 RN 视图进行修改, RN 的 Appearance 是没法响应的。
React Native 内部的实现可以参考 react-native-appearance
小结
如果 App 需要支持自定义切换暗黑模式(不追随系统变化而变化),那么通过 React-Native 中 Appearance 暂时是无法实现的。
React Native 读取原生自定义暗黑模式状态
既然 RN 的暗黑模式只通过原生读取,那么在 RN 中的状态也只能自定义了,同样上面两个问题也需要解决。
- 暗黑模式和正常模式之间来回切换
- 暗黑模式和正常模式下颜色匹配逻辑
暗黑模式和正常模式之间来回切换
原生通知各个 RN 模板进行变化即可,当然为了避免各个模块的子视图做重复监听,可以通过 Provider
来实现。
以 iOS 为例,原生需要支持
- 初始 RN 模块时,提供暗黑模式状态
- 原生暗黑模式变化时,通知 RN 模块
// 初始 RN 模块时,提供暗黑模式状态
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 模块中,比较自然的想到统一监听原生的暗黑模式状态变化以及通过 Provider 为子视图提供统一的状态
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});
接下来看一下业务的实现
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}}>
{'大标题'}
</Text>
<Text style={{...mainStyles.subTitle, color: subTitleColor}}>
{'小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题'}
</Text>
</View>
</View>
)
}
}
interface RootTestProps {
isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
render() {
return (
<DarkModeProvider isDark={this.props.isDark}>
<TestMain />
</DarkModeProvider>
);
}
};
通过上面的方式,业务需求也是可以实现的,只是方式有点难看。
img1 img2接下来就是如果把实现方式变得优雅一些
每个地方都来写把暗黑模式和正常模式下的颜色很冗余,也不利于统一管理,比较容易想到就是封装一个 DarkColorUtility
来统一管理颜色。这样会遇到一个问题,在 DarkColorUtility
中如何获取 DarkModeContext
根据发现只能通过 hook 的方式才能获取 DarkModeContext
,那么 TestMain
也只能改成 function
的方式,第一步优化之后的效果
// 工具类方法
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');
}
}
业务调整之后的方式
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() }}>
{'大标题'}
</Text>
<Text style={{ ...mainStyles.subTitle, color: DarkColorUtility.color_8C8C8C() }}>
{'小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题'}
</Text>
</View>
</View>
)
}
这一步其实已经差不多了,在实际开发用很多同学其实不怎么喜欢用 StyleSheet
来创建 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() }}>
{'大标题'}
</Text>
<Text style={{ fontSize: 16, lineHeight: 20, color: DarkColorUtility.color_8C8C8C() }}>
{'小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题'}
</Text>
</View>
</View>
)
}
这样在实际调试中比较方法,不需要在 StyleSheet
中业务中来回找,同时在那些特别复杂的界面命名的负担也是很重的。
不过考虑到还是有很多同学喜欢用 StyleSheet
,那么就继续思考,怎样在 StyleSheet
中写 DarkColorUtility
中的工具方法。
为了在 StyleSheet
中直接使用 DarkColorUtility
中的工具方法,发现只能对 StyleSheet
进行重新封装了。这里我们就直接参考 react-native-dynamic 的实现。
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
相当于 DarkColorUtility
。
export class DynamicValue {
constructor(light, dark) {
this.light = light;
this.dark = dark;
}
}
业务效果如下
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}>
{'大标题'}
</Text>
<Text style={styles.subTitle}>
{'小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题'}
</Text>
</View>
</View>
)
}
几种方案对比
方案一:业务通过 class 实现
// 统一的工具方法,便于业务使用
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);
}
}
// 业务实现例子
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)}}>
{'大标题'}
</Text>
<Text style={{...mainStyles.subTitle, color: DarkColorUtility.color_8C8C8C(this.context.isDark)}}>
{'小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题'}
</Text>
</View>
</View>
)
}
}
interface RootTestProps {
isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
render() {
return (
<DarkModeProvider isDark={this.props.isDark}>
<TestMain />
</DarkModeProvider>
);
}
};
方案二:业务通过 function 实现,同时不用 StyleSheet
来创建 style
// 统一的工具方法,便于业务使用
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');
}
}
// 业务实现例子
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() }}>
{'大标题'}
</Text>
<Text style={{ fontSize: 16, lineHeight: 20, color: DarkColorUtility.color_8C8C8C() }}>
{'小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题'}
</Text>
</View>
</View>
)
}
interface RootTestProps {
isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
render() {
return (
<DarkModeProvider isDark={this.props.isDark}>
<TestMain />
</DarkModeProvider>
);
}
};
方案三:业务通过 function 实现,同时也要用 StyleSheet
来创建 style
,借助 react-native-dynamic 来实现
// 统一的工具方法,便于业务使用
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');
}
}
// 业务实现列子
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}>
{'大标题'}
</Text>
<Text style={styles.subTitle}>
{'小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题小标题'}
</Text>
</View>
</View>
)
}
interface RootTestProps {
isDark: boolean;
}
export default class RootTest extends Component<RootTestProps> {
render() {
return (
<DarkModeProvider isDark={this.props.isDark}>
<TestMain />
</DarkModeProvider>
);
}
};
总结
- 推荐项目尽量通过 Hook 的方式来实现
- 推荐项目适配暗黑模式采用上面方案三