xChar
·a month ago

开始编写 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 项目中抽取为通用库,欢迎使用。

https://github.com/Innei/react-native-uikit-colors

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


Loading comments...