案例开源地址:https://atomgit.com/nutpi/rn_openharmony_dogimg
请添加图片描述

每个人都不一样

有人晚上刷手机喜欢深色模式,有人觉得浅色看着舒服。有人想收到推送提醒,有人嫌烦。

设置页面就是让用户按自己的习惯来。你提供选项,用户自己选。

做设置页面看起来简单,其实要考虑的东西不少:开关怎么做、颜色怎么跟着变、数据怎么存。这篇文章一个个说。

先看导入了什么

import {View, Text, TouchableOpacity, Switch, StyleSheet, Alert} from 'react-native';

从 react-native 导入基础组件。

Switch 是开关组件,就是那种左右滑动的开关,用来切换开/关状态。

Alert 是弹窗组件,用来显示确认对话框,比如"确定要清除缓存吗?"

import {Header, Card} from '../../components';

项目里封装的公共组件。

Card 用来包裹每组设置项,视觉上更整洁。

import {useStore} from '../../hooks';
import {getTheme} from '../../utils/store';

useStore 是自定义 Hook,用来获取全局状态。

getTheme 根据深色模式状态返回对应的颜色配置。

组件开头

export function SettingsPage() {
  const {darkMode} = useStore();

从全局状态里拿 darkMode,这是个布尔值,true 表示深色模式开启。

  const colors = getTheme(darkMode);

根据当前模式获取颜色配置。

如果 darkMode 是 true,返回深色主题的颜色;否则返回浅色主题的颜色。

后面所有需要颜色的地方都用 colors.xxx,这样切换主题时颜色会自动变。

  const {setState} = require('../../utils/store').appStore;

获取 setState 方法,用来更新全局状态。

用 require 而不是 import 是为了避免循环依赖。这不是最优雅的写法,但能用。

更好的做法是把 setState 也放到 useStore 的返回值里。

主题颜色长什么样

看看 store.ts 里的主题配置:

export const theme = {
  light: {
    primary: '#D2691E',
    background: '#f5f5f5',
    card: '#fff',

浅色主题的部分配置。

primary 是主题色,巧克力棕,用于按钮、开关等强调元素。

background 是页面背景色,浅灰色。

card 是卡片背景色,白色。

    text: '#333',
    textSecondary: '#666',
    textMuted: '#999',
    border: '#f0f0f0',
  },

文字和边框的颜色。

text 是主要文字,深灰色,用于标题和重要内容。

textSecondary 是次要文字,中灰色。

textMuted 是辅助文字,浅灰色,用于描述和提示。

border 是边框和分隔线的颜色。

深色主题的配置

  dark: {
    primary: '#E8A060',
    background: '#121212',
    card: '#1E1E1E',

深色主题不是简单地把颜色反过来。

主题色用了更亮的橙色(#E8A060),因为在深色背景上,原来的巧克力棕会显得太暗。

背景用 #121212 而不是纯黑。纯黑在 OLED 屏幕上虽然省电,但看起来太"硬"了。这个深灰色是 Material Design 推荐的深色模式背景。

    text: '#E0E0E0',
    textSecondary: '#B0B0B0',
    textMuted: '#808080',
    border: '#333',
  },
};

文字颜色反过来,用浅色。

但也不是纯白,纯白在深色背景上太刺眼。用浅灰色更舒服。

getTheme 函数

export const getTheme = (darkMode: boolean) => darkMode ? theme.dark : theme.light;

就一行代码,根据 darkMode 返回对应的主题对象。

组件里用 colors.text 这样的方式访问颜色,不用关心当前是什么模式。

这就是抽象的好处:把"当前是什么模式"这个细节藏起来,外面只管用颜色。

页面的整体结构

return (
  <View style={[s.container, {backgroundColor: colors.background}]}>
    <Header title="设置" />
    <View style={s.content}>
      ...
    </View>
  </View>
);

最外层是个 View,背景色用 colors.background

style={[s.container, {backgroundColor: colors.background}]}数组样式的写法。

第一个是静态样式,定义在 StyleSheet 里。

第二个是动态样式,根据主题变化。

React Native 会把数组里的样式合并,后面的覆盖前面的。

为什么要分静态和动态

const s = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#f5f5f5'},
  ...
});

StyleSheet.create 会对样式做优化,比如只传 ID 而不是整个对象到原生层。

但它只能处理静态值,不能处理运行时才知道的值(比如 colors.background)。

所以把不变的放 StyleSheet 里,会变的用内联对象。

通知设置区域

<Card>
  <Text style={[s.sectionTitle, {color: colors.textMuted}]}>通知设置</Text>

每组设置用 Card 包裹。

sectionTitle 是组标题,用辅助色(textMuted),字号小一些,起到分组的作用。

  <View style={[s.item, {borderBottomColor: colors.border}]}>
    <View style={s.itemInfo}>
      <Text style={[s.itemLabel, {color: colors.text}]}>推送通知</Text>
      <Text style={[s.itemDesc, {color: colors.textMuted}]}>接收新内容和活动通知</Text>
    </View>
    <Switch value={true} trackColor={{true: colors.primary}} />
  </View>
</Card>

一个设置项包含:

itemInfo:左边的信息区,包含标签和描述。

Switch:右边的开关。

描述文字告诉用户这个选项是干嘛的,不是所有选项都需要描述,但复杂的选项最好有。

Switch 组件详解

<Switch value={true} trackColor={{true: colors.primary}} />

value 是当前状态,true 表示开启,false 表示关闭。

trackColor 是轨道颜色。{true: colors.primary} 表示开启时用主题色,关闭时用默认颜色。

Switch 是 React Native 内置的组件,在 iOS 和 Android 上都有原生的外观。

iOS 上是圆形滑块,Android 上是 Material Design 风格。

显示设置区域

<Card>
  <Text style={[s.sectionTitle, {color: colors.textMuted}]}>显示设置</Text>
  <View style={[s.item, {borderBottomColor: colors.border}]}>
    <View style={s.itemInfo}>
      <Text style={[s.itemLabel, {color: colors.text}]}>自动播放</Text>
      <Text style={[s.itemDesc, {color: colors.textMuted}]}>自动播放视频和动图</Text>
    </View>
    <Switch value={false} trackColor={{true: colors.primary}} />
  </View>

自动播放选项,默认关闭。

有些用户流量有限,不想自动播放视频。给他们选择权。

深色模式开关

  <View style={[s.item, {borderBottomColor: colors.border}]}>
    <View style={s.itemInfo}>
      <Text style={[s.itemLabel, {color: colors.text}]}>深色模式</Text>
      <Text style={[s.itemDesc, {color: colors.textMuted}]}>使用深色主题</Text>
    </View>
    <Switch 
      value={darkMode} 
      onValueChange={toggleDarkMode} 
      trackColor={{true: colors.primary}} 
    />
  </View>
</Card>

这个开关是真正有功能的。

value={darkMode}:绑定全局状态,状态变了开关也跟着变。

onValueChange={toggleDarkMode}:用户切换时调用这个函数。

切换深色模式的函数

const toggleDarkMode = (value: boolean) => {
  setState({darkMode: value});
};

onValueChange 会把新的值传进来,true 或 false。

调用 setState 更新全局状态。

然后神奇的事情发生了:因为所有用到 colors 的组件都订阅了状态变化,它们会自动重新渲染,颜色就变了。

这就是响应式的威力。你不用手动去更新每个组件的颜色,改一个状态,所有依赖它的 UI 自动更新。

存储设置区域

<Card>
  <Text style={[s.sectionTitle, {color: colors.textMuted}]}>存储</Text>
  <TouchableOpacity 
    style={[s.item, {borderBottomColor: colors.border}]} 
    onPress={handleClearCache}
  >

清除缓存不是开关,是个操作,所以用 TouchableOpacity 而不是 Switch。

点击后执行 handleClearCache 函数。

    <View style={s.itemInfo}>
      <Text style={[s.itemLabel, {color: colors.text}]}>清除缓存</Text>
      <Text style={[s.itemDesc, {color: colors.textMuted}]}>清除图片和数据缓存</Text>
    </View>
    <Text style={s.arrow}></Text>
  </TouchableOpacity>
</Card>

右边用箭头 而不是开关,提示用户这是可点击的。

箭头是个常见的 UI 约定:有箭头表示点击后会有下一步操作。

清除缓存的处理

const handleClearCache = () => {
  Alert.alert('清除缓存', '确定要清除所有缓存数据吗?', [
    {text: '取消', style: 'cancel'},
    {text: '确定', onPress: () => Alert.alert('提示', '缓存已清除')},
  ]);
};

清除缓存是个危险操作,用户可能误点,所以要二次确认。

Alert.alert 的参数:

第一个是标题。

第二个是内容。

第三个是按钮数组。

Alert 按钮的配置

{text: '取消', style: 'cancel'},

取消按钮,style: 'cancel' 在 iOS 上会让按钮加粗,表示这是"安全"的选择。

{text: '确定', onPress: () => Alert.alert('提示', '缓存已清除')},

确定按钮,点击后执行 onPress 回调。

这里只是显示个提示,实际项目里应该真的去清除缓存:

onPress: async () => {
  await AsyncStorage.clear();
  // 或者清除图片缓存
  Alert.alert('提示', '缓存已清除');
}

账号设置区域

<Card>
  <Text style={[s.sectionTitle, {color: colors.textMuted}]}>账号</Text>
  <TouchableOpacity style={[s.item, {borderBottomColor: colors.border}]}>
    <View style={s.itemInfo}>
      <Text style={[s.itemLabel, {color: colors.text}]}>隐私政策</Text>
    </View>
    <Text style={s.arrow}></Text>
  </TouchableOpacity>

隐私政策和用户协议是法律要求的必备项。

没有描述文字,因为标题已经足够说明。

点击后应该跳转到对应的页面或打开网页:

onPress={() => Linking.openURL('https://example.com/privacy')}
  <TouchableOpacity style={[s.item, {borderBottomColor: colors.border}]}>
    <View style={s.itemInfo}>
      <Text style={[s.itemLabel, {color: colors.text}]}>用户协议</Text>
    </View>
    <Text style={s.arrow}></Text>
  </TouchableOpacity>
</Card>

用户协议同理。

这两个页面内容通常是法务提供的,开发只需要展示。

设置项的样式

sectionTitle: {fontSize: 14, color: '#999', marginBottom: 12},

组标题用 14 号字,浅灰色。

比正文小,颜色浅,不抢眼但能起到分组作用。

marginBottom: 12 和下面的设置项隔开。

item: {
  flexDirection: 'row', 
  alignItems: 'center', 
  paddingVertical: 12, 
  borderBottomWidth: 1, 
  borderBottomColor: '#f0f0f0'
},

设置项横向布局(flexDirection: 'row'),垂直居中(alignItems: 'center')。

paddingVertical: 12 上下留白,让点击区域足够大。手指比较粗也能点到。

底部有 1 像素的分隔线。

itemInfo: {flex: 1},

信息区 flex: 1 占据除了开关/箭头之外的所有空间。

itemLabel: {fontSize: 16, color: '#333'},
itemDesc: {fontSize: 13, color: '#999', marginTop: 2},

标签是主要信息,16 号字,深色。

描述是次要信息,13 号字,浅色。

marginTop: 2 让描述和标签之间有点间距。

arrow: {fontSize: 18, color: '#ccc'},

箭头用浅灰色,不抢眼但能看到。

扩展:跟随系统主题

可以让 App 自动跟随系统的深色模式:

import {useColorScheme} from 'react-native';

function App() {
  const systemColorScheme = useColorScheme();

useColorScheme 是 React Native 提供的 Hook,返回系统当前的颜色模式。

返回值是 'light''dark'null(无法确定时)。

  useEffect(() => {
    if (followSystem) {
      setState({darkMode: systemColorScheme === 'dark'});
    }
  }, [systemColorScheme, followSystem]);

当系统模式变化时,自动更新 App 的模式。

followSystem 是另一个设置项,让用户选择是手动控制还是跟随系统。

扩展:设置的持久化

用户的设置应该保存下来,下次打开 App 还是同样的配置:

import AsyncStorage from '@react-native-async-storage/async-storage';

// 设置变化时保存
useEffect(() => {
  AsyncStorage.setItem('settings', JSON.stringify({
    darkMode,
    pushNotification,
    autoPlay,
  }));
}, [darkMode, pushNotification, autoPlay]);

每次设置变化,就保存到本地存储。

JSON.stringify 把对象转成字符串,因为 AsyncStorage 只能存字符串。

// App 启动时读取
useEffect(() => {
  AsyncStorage.getItem('settings').then(data => {
    if (data) {
      const settings = JSON.parse(data);
      setState(settings);
    }
  });
}, []);

App 启动时读取保存的设置,恢复用户的配置。

空依赖数组 [] 表示只在组件挂载时执行一次。

扩展:更多设置项

可以根据需要加更多设置:

语言设置:切换 App 的显示语言,需要配合 i18n 库。

字体大小:让视力不好的用户能调大字体。

图片质量:在流量有限时选择加载低质量图片。

仅 WiFi 加载:移动网络下不自动加载图片。

震动反馈:点击按钮时是否震动。

扩展:数据驱动的设置列表

当设置项很多时,可以用数据驱动的方式:

const SETTINGS = [
  {
    title: '通知',
    items: [
      {key: 'push', label: '推送通知', desc: '接收新内容通知', type: 'switch'},
      {key: 'sound', label: '提示音', type: 'switch'},
    ],
  },
  {
    title: '显示',
    items: [
      {key: 'darkMode', label: '深色模式', type: 'switch'},
      {key: 'fontSize', label: '字体大小', type: 'select', options: ['小', '中', '大']},
    ],
  },
];

把设置项定义成数据,然后用 map 渲染。

加新设置只需要往数组里加对象,不用改渲染代码。

这种方式在设置项很多时特别有用,代码更好维护。

扩展:退出登录

如果 App 有登录功能,设置页面通常还有退出登录按钮:

<TouchableOpacity style={s.logoutBtn} onPress={handleLogout}>
  <Text style={s.logoutText}>退出登录</Text>
</TouchableOpacity>

退出登录也是危险操作,要二次确认:

const handleLogout = () => {
  Alert.alert('退出登录', '确定要退出当前账号吗?', [
    {text: '取消', style: 'cancel'},
    {text: '退出', style: 'destructive', onPress: doLogout},
  ]);
};

style: 'destructive' 在 iOS 上会让按钮变成红色,表示这是个危险操作。

小结

设置页面的实现要点:

  • Switch 组件用于开关类设置,value 绑定状态,onValueChange 更新状态
  • Alert 弹窗用于危险操作的二次确认
  • 主题系统定义两套颜色,用 getTheme 获取当前主题
  • 动态样式用数组合并静态和动态样式
  • 分组展示用 Card 和标题组织设置项
  • 持久化用 AsyncStorage 保存用户的设置

设置页面是用户个性化 App 的入口,做好了能提升用户满意度。

下一篇讲关于我们页面,展示 App 和团队的信息。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐