banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

React Native 実践:色

開始執筆 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 項目中抽取為通用庫,歡迎使用。

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

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。