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。
pnpm install nativewind tailwindcss
配置好 Babel 和 Metro。
// babel.config.js
module.exports = function (api) {
api.cache(true)
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
}
}
const { withNativeWind } = require('nativewind/metro')
module.exports = withNativeWind(config, { input: './src/global.css' })
创建 PostCSS 样式入口。
/* 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
那么这里,我已经把相关颜色都提取出来了。供你参考:
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
例如:
<View style={vars({ '--brand-color': 'red'})}>
{ // style: { color: 'red' } }
<Text className="text-[--brand-color]" />
</View>
借助这个特征,我们可以把上面的颜色定义都用这个方式从顶层传入。
// @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 如果你没有需求可省略。
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。例如:
export const RootProviders = ({ children }: { children: ReactNode }) => {
useColorScheme() // 为了对亮色/暗色进行监听
const currentThemeColors = getCurrentColors()!
return <View style={[styles.flex, currentThemeColors]}>{children}</View>
}
这样,在子代任何组件都可以直接使用相关的变量了。但是使用仍然不方便。我们还需要配置下 TailwindCSS 的 colors。
由于上面我们都用了前缀 --color
,我可以这样写一个 tailwindcss config 的包装函数。
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 中使用。
export default withUIKit(config)
这样,在 tailwindcss 中就可以直接使用这些颜色了。
使用
在组件中,可以直接使用这样的方式去设置颜色:
<View className={'bg-secondary-system-grouped-background'} />
但是总有时候我们不能直接使用类名,而是需要实际的变量。比如在做颜色过渡动画的时候。
我们来写一个 hook 去获取当前主题时的对应颜色。
export const useColor = (color: keyof typeof mergedLightColors) => {
const { colorScheme } = useColorScheme()
const colors = mergedColors[colorScheme || 'light']
return useMemo(() => rgbStringToRgb(colors[color]), [color, colors])
}
使用方式:
const redColor = useColor('red')
后记
此方案已从 Follow Mobile 项目中抽取为通用库,欢迎使用。