React Native 实践：Colors

开始编写 Follow Mobile 已经过去一个月了，想想也该沉淀点什么东西了。

这篇文章首先来讲讲 Follow Mobile 的颜色体系。

开始之前需要知道的是 Follow Mobile 是使用 React Native 开发的并且使用了 Expo 框架。

准备条件

由于 React Native 并没有官方支持 web 中 className 的写法，为了适应 web 中方便快捷的 TailwindCSS 原子类名，我们需要借助 NativeWind 工具。这是一个能让 React Native app 中也使用一部分 TailwindCSS 能力的编译器。通过 babel plugin 对 React Native 中的基础组件进行包装，在 runtime 中对 className 进行翻译到 React Native style 对象来实现类似效果。

NativewindCSS 内部也借助 TailwindCSS 进行翻译，在配置 TailwindCSS 时基本和 Web 中一致。

我们安装 NativewindCSS。

Copy
pnpm install nativewind tailwindcss

配置好 Babel 和 Metro。

Copy
// babel.config.js
module.exports = function (api) {
api.cache(true)
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
}
}

Copy
const { withNativeWind } = require('nativewind/metro')
module.exports = withNativeWind(config, { input: './src/global.css' })

创建 PostCSS 样式入口。

Copy
/* src/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

在 dev server 启动之后，会自动生成 nativewind-env.d.ts 文件以提供类型支持。至此，准备工作已经完成。

选择颜色体系

一个 app 起步阶段，或许一套完善的设计规范是不可少的。颜色定义对于一个 app 来说也是重中之重。在开始开发 app 时，我想要打造一个很 apple 味道的 app。

可惜，React Native 毕竟是一个跨段开发框架，并没有提供太多的 native 组件和样式。多数我们只能去模拟 native 样式或者借助社区的 native 模块。对于组件，这是后话了，这里我先说说颜色。

apple 有一套非常规范的颜色体系，在 Color | Apple Developer Documentation。使用这套定义，搬到 NativeWind 中。

可能在文档中并不好看到所有颜色的值，apple 提供了 figma，在这里还可以看到大部分的 native 组件使用的颜色。

https://www.figma.com/community/file/1385659531316001292/ios-18-and-ipados-18

那么这里，我已经把相关颜色都提取出来了。供你参考：

Copy
const lightPalette = {
red: '255 59 48',
orange: '255 149 0',
yellow: '255 204 0',
green: '52 199 89',
mint: '0 199 190',
teal: '48 176 190',
cyan: '50 173 200',
blue: '0 122 255',
indigo: '88 86 214',
purple: '175 82 222',
pink: '255 45 85',
brown: '162 132 94',
gray: '142 142 147',
gray2: '172 172 178',
gray3: '199 199 204',
gray4: '209 209 214',
gray5: '229 229 234',
gray6: '242 242 247',
}
const darkPalette = {
red: '255 69 58',
orange: '255 175 113',
yellow: '255 214 10',
green: '48 209 88',
mint: '99 230 226',
teal: '64 200 244',
cyan: '100 210 255',
blue: '10 132 255',
indigo: '94 92 230',
purple: '191 90 242',
pink: '255 55 95',
brown: '172 142 104',
gray: '142 142 147',
gray2: '99 99 102',
gray3: '72 72 74',
gray4: '58 58 60',
gray5: '44 44 46',
gray6: '28 28 30',
}

export const lightVariants = {
// UIKit Colors

placeholderText: '199 199 204',
separator: '84 84 86 0.34',
opaqueSeparator: '84 84 86 0.34',
nonOpaqueSeparator: '198 198 200',
link: '0 122 255',

systemBackground: '255 255 255',
secondarySystemBackground: '242 242 247',
tertiarySystemBackground: '255 255 255',

// Grouped
systemGroupedBackground: '242 242 247',
secondarySystemGroupedBackground: '255 255 255',
tertiarySystemGroupedBackground: '242 242 247',

// System Colors
systemFill: '120 120 128 0.2',
secondarySystemFill: '120 120 128 0.16',
tertiarySystemFill: '120 120 128 0.12',
quaternarySystemFill: '120 120 128 0.08',

// Text Colors
label: '0 0 0',
text: '0 0 0',
secondaryLabel: '60 60 67 0.6',
tertiaryLabel: '60 60 67 0.3',
quaternaryLabel: '60 60 67 0.18',
}
export const darkVariants = {
// UIKit Colors

placeholderText: '122 122 122',
separator: '56 56 58 0.6',
opaqueSeparator: '56 56 58 0.6',
nonOpaqueSeparator: '84 84 86',
link: '10 132 255',
systemBackground: '0 0 0',
secondarySystemBackground: '28 28 30',
tertiarySystemBackground: '44 44 46',

// Grouped
systemGroupedBackground: '0 0 0',
secondarySystemGroupedBackground: '28 28 30',
tertiarySystemGroupedBackground: '44 44 46',

// System Colors
systemFill: '120 120 128 0.36',
secondarySystemFill: '120 120 128 0.32',
tertiarySystemFill: '120 120 128 0.24',
quaternarySystemFill: '120 120 128 0.19',

// Text Colors
label: '255 255 255',
text: '255 255 255',
secondaryLabel: '235 235 245 0.6',
tertiaryLabel: '235 235 245 0.3',
quaternaryLabel: '235 235 245 0.18',
}

分别对应亮色和暗色下的普通颜色和系统变量颜色。

使用 NativeWind 变量注入

NativeWind 有个特征可以实现类似 Web 中的 CSS variable。

https://www.nativewind.dev/api/vars

例如：

Copy
<View style={vars({ '--brand-color': 'red'})}>
{ // style: { color: 'red' } }
<Text className="text-[--brand-color]" />
</View>

借助这个特征，我们可以把上面的颜色定义都用这个方式从顶层传入。

Copy

// @ts-expect-error
const IS_DOM = typeof ReactNativeWebView !== 'undefined'

const varPrefix = '--color'

const buildVars = (_vars: Record<string, string>) => {
const cssVars = {} as Record<`${typeof varPrefix}-${string}`, string>
for (const [key, value] of Object.entries(_vars)) {
cssVars[`${varPrefix}-${key}`] = value
}

return IS_DOM ? cssVars : vars(cssVars)
}

上面这个函数为了兼容 react-native-web 如果你没有需求可省略。

Copy
const mergedLightColors = {
...lightVariants,
...lightPalette,
}
const mergedDarkColors = {
...darkVariants,
...darkPalette,
}
const mergedColors = {
light: mergedLightColors,
dark: mergedDarkColors,
}

export const colorVariants = {
light: buildVars(lightVariants),
dark: buildVars(darkVariants),
}
export const palette = {
// iOS color palette https://developer.apple.com/design/human-interface-guidelines/color
light: buildVars(lightPalette),
dark: buildVars(darkPalette),
}

export const getCurrentColors = () => {
const colorScheme = Appearance.getColorScheme() || 'light'

return StyleSheet.compose(
colorVariants[colorScheme],
palette[colorScheme],
) as StyleProp<ViewStyle>
}

然后在顶层包一层 View。例如：

Copy
export const RootProviders = ({ children }: { children: ReactNode }) => {
useColorScheme() // 为了对亮色/暗色进行监听
const currentThemeColors = getCurrentColors()!

return <View style={[styles.flex, currentThemeColors]}>{children}</View>
}

这样，在子代任何组件都可以直接使用相关的变量了。但是使用仍然不方便。我们还需要配置下 TailwindCSS 的 colors。

由于上面我们都用了前缀 --color，我可以这样写一个 tailwindcss config 的包装函数。

Copy
import { Config } from 'tailwindcss'

const configColors = {
// Palette colors
red: 'rgb(var(--color-red) / <alpha-value>)',
orange: 'rgb(var(--color-orange) / <alpha-value>)',
yellow: 'rgb(var(--color-yellow) / <alpha-value>)',
green: 'rgb(var(--color-green) / <alpha-value>)',
mint: 'rgb(var(--color-mint) / <alpha-value>)',
teal: 'rgb(var(--color-teal) / <alpha-value>)',
cyan: 'rgb(var(--color-cyan) / <alpha-value>)',
blue: 'rgb(var(--color-blue) / <alpha-value>)',
indigo: 'rgb(var(--color-indigo) / <alpha-value>)',
purple: 'rgb(var(--color-purple) / <alpha-value>)',
pink: 'rgb(var(--color-pink) / <alpha-value>)',
brown: 'rgb(var(--color-brown) / <alpha-value>)',
gray: {
DEFAULT: 'rgb(var(--color-gray) / <alpha-value>)',
2: 'rgb(var(--color-gray2) / <alpha-value>)',
3: 'rgb(var(--color-gray3) / <alpha-value>)',
4: 'rgb(var(--color-gray4) / <alpha-value>)',
5: 'rgb(var(--color-gray5) / <alpha-value>)',
6: 'rgb(var(--color-gray6) / <alpha-value>)',
},

// System colors

'placeholder-text': 'rgb(var(--color-placeholderText) / <alpha-value>)',
separator: 'rgb(var(--color-separator) / <alpha-value>)',
'opaque-separator': 'rgba(var(--color-opaqueSeparator))',
'non-opaque-separator': 'rgba(var(--color-nonOpaqueSeparator))',
link: 'rgb(var(--color-link) / <alpha-value>)',

// Backgrounds
'system-background': 'rgb(var(--color-systemBackground) / <alpha-value>)',
'secondary-system-background':
'rgb(var(--color-secondarySystemBackground) / <alpha-value>)',
'tertiary-system-background':
'rgb(var(--color-tertiarySystemBackground) / <alpha-value>)',
'system-grouped-background':
'rgb(var(--color-systemGroupedBackground) / <alpha-value>)',
'secondary-system-grouped-background':
'rgb(var(--color-secondarySystemGroupedBackground) / <alpha-value>)',
'tertiary-system-grouped-background':
'rgb(var(--color-tertiarySystemGroupedBackground) / <alpha-value>)',
// System fills
'system-fill': 'rgba(var(--color-systemFill))',
'secondary-system-fill': 'rgba(var(--color-secondarySystemFill))',
'tertiary-system-fill': 'rgba(var(--color-tertiarySystemFill))',
'quaternary-system-fill': 'rgba(var(--color-quaternarySystemFill))',

// Text colors
label: 'rgb(var(--color-text) / <alpha-value>)',
text: 'rgb(var(--color-text) / <alpha-value>)',
'secondary-label': 'rgba(var(--color-secondaryLabel))',
'tertiary-label': 'rgba(var(--color-tertiaryLabel))',
'quaternary-label': 'rgba(var(--color-quaternaryLabel))',
}
export const withUIKit = (config: Config) => {
config.theme = config.theme || {}
config.theme.extend = config.theme.extend || {}
config.theme.extend.colors = config.theme.extend.colors || {}
config.theme.extend.colors = {
...config.theme.extend.colors,
...configColors,
}
return config
}

然后直接在 tailwind.config.ts 中使用。

Copy
export default withUIKit(config)

这样，在 tailwindcss 中就可以直接使用这些颜色了。

使用

在组件中，可以直接使用这样的方式去设置颜色：

Copy
<View className={'bg-secondary-system-grouped-background'} />

但是总有时候我们不能直接使用类名，而是需要实际的变量。比如在做颜色过渡动画的时候。

我们来写一个 hook 去获取当前主题时的对应颜色。

Copy
export const useColor = (color: keyof typeof mergedLightColors) => {
const { colorScheme } = useColorScheme()
const colors = mergedColors[colorScheme || 'light']
return useMemo(() => rgbStringToRgb(colors[color]), [color, colors])
}

使用方式：

Copy
const redColor = useColor('red')

后记

此方案已从 Follow Mobile 项目中抽取为通用库，欢迎使用。

此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/tech/react-native-uikit-colors