在这里插入图片描述

今天我们用 React Native 实现一个随机颜色生成器,支持 HEX、RGB、HSL 三种格式显示,带有历史记录和相关色彩展示。

状态和动画设计

import React, { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native';

export const RandomColor: React.FC = () => {
  const [color, setColor] = useState('#4A90D9');
  const [history, setHistory] = useState<string[]>([]);
  const scaleAnim = useRef(new Animated.Value(1)).current;
  const rotateAnim = useRef(new Animated.Value(0)).current;

状态设计包含当前颜色、历史记录、两个动画值。

当前颜色color 是 HEX 格式的颜色字符串,比如 '#4A90D9'。初始值是蓝色。

历史记录history 是一个数组,存储最近 12 个生成的颜色。每次生成新颜色时,插入数组开头,保留前 12 个。

缩放动画scaleAnim 控制颜色框的缩放,生成新颜色时缩小再弹回,给用户反馈。

旋转动画rotateAnim 控制颜色框内圆环的旋转,从 0 到 1,后面会用插值映射到 0-360 度。

为什么用 HEX 格式存储颜色?因为 HEX 是最紧凑的格式,6 个字符就能表示 1600 万种颜色。RGB 需要 3 个数字,HSL 需要 3 个数字加单位,都比 HEX 长。存储用 HEX,显示时再转换成其他格式。

随机颜色生成

  const generate = () => {
    // 动画
    Animated.parallel([
      Animated.sequence([
        Animated.timing(scaleAnim, { toValue: 0.9, duration: 100, useNativeDriver: true }),
        Animated.spring(scaleAnim, { toValue: 1, friction: 3, useNativeDriver: true }),
      ]),
      Animated.timing(rotateAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
    ]).start(() => rotateAnim.setValue(0));

    const newColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
    setColor(newColor);
    setHistory([newColor, ...history.slice(0, 11)]);
  };

生成函数包含动画和颜色生成两部分。

并行动画Animated.parallel 让两个动画同时运行。

缩放动画:序列动画,先缩小到 90%(100ms),再弹回到 100%。friction: 3 让弹簧有明显的回弹效果。

旋转动画:从 0 到 1(300ms),完成后重置为 0。旋转一圈,给用户"刷新"的视觉反馈。

随机颜色算法

  • Math.random() * 16777215:生成 0 到 16777215 的随机浮点数
  • Math.floor(...):向下取整,得到整数
  • .toString(16):转换成 16 进制字符串
  • .padStart(6, '0'):补齐到 6 位,不足的前面补 0
  • '#' + ...:加上 # 前缀,得到 HEX 颜色

为什么是 16777215?因为 RGB 每个通道是 0-255,总共 256 × 256 × 256 = 16777216 种颜色。16777215 是最大值(对应 #FFFFFF 白色)。

为什么要 padStart(6, '0')?因为某些颜色的 16 进制表示不足 6 位。比如黑色是 0,转成 16 进制是 ‘0’,需要补齐成 ‘000000’。如果不补齐,'#0' 不是有效的颜色格式。

更新历史记录[newColor, ...history.slice(0, 11)] 把新颜色插入数组开头,保留前 11 个旧记录,总共 12 个。

对比度计算

  const getContrastColor = (hex: string) => {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? '#000' : '#fff';
  };

对比度函数计算背景色的亮度,返回黑色或白色文字。

提取 RGB 值

  • hex.slice(1, 3):提取第 2-3 位,红色通道
  • hex.slice(3, 5):提取第 4-5 位,绿色通道
  • hex.slice(5, 7):提取第 6-7 位,蓝色通道
  • parseInt(..., 16):把 16 进制字符串转成 10 进制整数

亮度计算(r * 299 + g * 587 + b * 114) / 1000

这是 YIQ 颜色空间的亮度公式。人眼对绿色最敏感(权重 587),对蓝色最不敏感(权重 114),红色居中(权重 299)。权重和是 1000,除以 1000 得到 0-255 的亮度值。

为什么用 YIQ 而不是简单的平均值?因为人眼对不同颜色的敏感度不同。简单平均 (r + g + b) / 3 会高估蓝色的亮度,低估绿色的亮度。YIQ 公式考虑了人眼的感知特性,计算出的亮度更准确。

阈值判断:亮度大于 128(中间值),背景是亮色,用黑色文字;否则背景是暗色,用白色文字。

HEX 转 RGB

  const hexToRgb = (hex: string) => {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    return `rgb(${r}, ${g}, ${b})`;
  };

HEX 转 RGB 很简单,提取三个通道的值,拼接成 rgb(r, g, b) 格式。

示例'#4A90D9''rgb(74, 144, 217)'

  • '4A' → 74
  • '90' → 144
  • 'D9' → 217

HEX 转 HSL

  const hexToHsl = (hex: string) => {
    let r = parseInt(hex.slice(1, 3), 16) / 255;
    let g = parseInt(hex.slice(3, 5), 16) / 255;
    let b = parseInt(hex.slice(5, 7), 16) / 255;
    const max = Math.max(r, g, b), min = Math.min(r, g, b);
    let h = 0, s = 0, l = (max + min) / 2;
    if (max !== min) {
      const d = max - min;
      s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
      switch (max) {
        case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
        case g: h = ((b - r) / d + 2) / 6; break;
        case b: h = ((r - g) / d + 4) / 6; break;
      }
    }
    return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
  };

HEX 转 HSL 比较复杂,需要先转成 RGB,再计算色相、饱和度、亮度。

归一化 RGB:把 0-255 的值除以 255,得到 0-1 的浮点数。后续计算都用归一化的值。

计算亮度 L(max + min) / 2,最大值和最小值的平均。

计算饱和度 S

  • 如果 max === min,说明 RGB 三个值相等,是灰色,饱和度为 0
  • 否则,d = max - min 是色彩的"跨度"
  • 如果 l > 0.5(亮色),s = d / (2 - max - min)
  • 否则(暗色),s = d / (max + min)

为什么饱和度的公式分两种情况?因为亮色和暗色的饱和度计算方式不同。亮色的饱和度受"离白色有多远"影响,暗色的饱和度受"离黑色有多远"影响。分两种情况能得到更准确的饱和度。

计算色相 H

  • 色相取决于哪个通道最大
  • 如果红色最大,h = ((g - b) / d + (g < b ? 6 : 0)) / 6
  • 如果绿色最大,h = ((b - r) / d + 2) / 6
  • 如果蓝色最大,h = ((r - g) / d + 4) / 6

为什么色相的公式这么复杂?因为色相是一个环形的值(0-360 度),需要根据 RGB 的相对大小计算在色环上的位置。公式中的 + 2+ 4 是偏移量,让不同通道的色相分布在色环的不同区域。(g < b ? 6 : 0) 处理红色通道的边界情况,确保色相在 0-1 范围内。

转换成标准格式

  • 色相:h * 360,转成 0-360 度
  • 饱和度:s * 100,转成 0-100%
  • 亮度:l * 100,转成 0-100%
  • 拼接成 hsl(h, s%, l%) 格式

旋转插值

  const rotate = rotateAnim.interpolate({
    inputRange: [0, 1],
    outputRange: ['0deg', '360deg'],
  });

插值把动画值 0-1 映射到旋转角度 0-360 度。圆环旋转一圈,给用户"刷新"的视觉反馈。

颜色框渲染

  return (
    <ScrollView style={styles.container}>
      <TouchableOpacity onPress={generate} activeOpacity={0.9}>
        <Animated.View
          style={[
            styles.colorBox,
            { backgroundColor: color, transform: [{ scale: scaleAnim }] },
          ]}
        >
          <Animated.View style={[styles.colorRing, { transform: [{ rotate }] }]}>
            <View style={styles.ringDot} />
          </Animated.View>
          <View style={styles.colorInfo}>
            <Text style={[styles.colorHex, { color: getContrastColor(color) }]}>
              {color.toUpperCase()}
            </Text>
            <Text style={[styles.colorRgb, { color: getContrastColor(color) }]}>
              {hexToRgb(color)}
            </Text>
            <Text style={[styles.colorHsl, { color: getContrastColor(color) }]}>
              {hexToHsl(color)}
            </Text>
          </View>
          <Text style={[styles.hint, { color: getContrastColor(color) }]}>
            点击生成新颜色
          </Text>
        </Animated.View>
      </TouchableOpacity>

颜色框是一个大的可点击区域,背景色是当前颜色。

背景色动态设置backgroundColor: color,背景色随状态变化。

缩放动画transform: [{ scale: scaleAnim }],点击时缩小再弹回。

旋转圆环

  • 虚线圆环,半透明白色
  • 圆环上有一个小圆点,位于顶部中心
  • 圆环旋转,小圆点跟着旋转

为什么用虚线圆环?因为虚线圆环看起来像"加载中"的指示器,配合旋转动画,给用户"正在生成"的视觉反馈。实线圆环太实,不够轻盈。

颜色信息

  • HEX 格式:大字号(36),粗体,醒目
  • RGB 格式:中字号(16),半透明
  • HSL 格式:小字号(14),更透明

文字颜色:用 getContrastColor(color) 计算,确保文字在任何背景色上都清晰可见。

提示文字:绝对定位在底部,提示用户可以点击。

按钮和历史记录

      <TouchableOpacity style={styles.btn} onPress={generate} activeOpacity={0.8}>
        <View style={styles.btnInner}>
          <Text style={styles.btnText}>🎨 随机颜色</Text>
        </View>
      </TouchableOpacity>

      {history.length > 0 && (
        <View style={styles.history}>
          <Text style={styles.historyTitle}>🕐 历史记录</Text>
          <View style={styles.historyGrid}>
            {history.map((c, i) => (
              <TouchableOpacity
                key={i}
                style={[styles.historyItem, { backgroundColor: c }]}
                onPress={() => setColor(c)}
              >
                <View style={styles.historyItemInner}>
                  <Text style={[styles.historyText, { color: getContrastColor(c) }]}>
                    {c.toUpperCase()}
                  </Text>
                </View>
              </TouchableOpacity>
            ))}
          </View>
        </View>
      )}

按钮:深蓝色背景,圆角,边框,蓝色阴影。点击时触发 generate 函数。

历史记录

  • 只在有记录时显示(history.length > 0
  • 网格布局,每个颜色占 31% 宽度(3 列),纵横比 1.5
  • 每个颜色块可点击,点击时把该颜色设为当前颜色

为什么历史记录可点击?因为用户可能想回到之前生成的颜色。点击历史记录,快速切换到该颜色,不需要重新生成。

为什么用 31% 而不是 33.33%?因为 31% × 3 = 93%,剩余 7% 用于间距(每个 1%,左右各 1%,中间 2 个间隙各 2%)。如果用 33.33%,没有间距,颜色块会紧贴在一起。

相关色彩

      <View style={styles.palette}>
        <Text style={styles.paletteTitle}>🎯 相关色彩</Text>
        <View style={styles.paletteRow}>
          {[0.2, 0.4, 0.6, 0.8, 1].map((opacity, i) => {
            const r = parseInt(color.slice(1, 3), 16);
            const g = parseInt(color.slice(3, 5), 16);
            const b = parseInt(color.slice(5, 7), 16);
            return (
              <View
                key={i}
                style={[
                  styles.paletteItem,
                  { backgroundColor: `rgba(${r},${g},${b},${opacity})` },
                ]}
              />
            );
          })}
        </View>
      </View>
    </ScrollView>
  );
};

相关色彩展示当前颜色的 5 个透明度变体:20%、40%、60%、80%、100%。

提取 RGB 值:从 HEX 颜色提取 RGB 三个通道的值。

生成 RGBA 颜色rgba(r, g, b, opacity),透明度从 0.2 到 1。

为什么显示透明度变体?因为透明度变体是最常用的"相关色彩"。设计师经常需要同一颜色的不同透明度版本,用于阴影、遮罩、渐变等。显示 5 个透明度变体,让用户快速预览效果。

为什么是 5 个而不是 10 个?因为 5 个刚好,既能展示从淡到浓的渐变,又不会太多占用空间。如果是 10 个,每个色块会很窄,不容易看清。

鸿蒙 ArkTS 对比:颜色转换

@State color: string = '#4A90D9'
@State history: string[] = []

generate() {
  const newColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')
  this.color = newColor
  this.history = [newColor, ...this.history.slice(0, 11)]
}

getContrastColor(hex: string): string {
  const r = parseInt(hex.slice(1, 3), 16)
  const g = parseInt(hex.slice(3, 5), 16)
  const b = parseInt(hex.slice(5, 7), 16)
  return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? '#000' : '#fff'
}

hexToRgb(hex: string): string {
  const r = parseInt(hex.slice(1, 3), 16)
  const g = parseInt(hex.slice(3, 5), 16)
  const b = parseInt(hex.slice(5, 7), 16)
  return `rgb(${r}, ${g}, ${b})`
}

ArkTS 中的颜色生成和转换逻辑完全一样,因为都是纯 JavaScript 算法。字符串方法(slicetoStringpadStart)、数学方法(parseIntMath.randomMath.floor)都是标准 API,跨平台通用。

样式定义:容器和颜色框

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23', padding: 16 },
  colorBox: {
    height: 280,
    borderRadius: 24,
    justifyContent: 'center',
    alignItems: 'center',
    marginBottom: 20,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 10 },
    shadowOpacity: 0.3,
    shadowRadius: 20,
    elevation: 15,
    overflow: 'hidden',
  },
  colorRing: {
    position: 'absolute',
    width: 200,
    height: 200,
    borderRadius: 100,
    borderWidth: 2,
    borderColor: 'rgba(255,255,255,0.3)',
    borderStyle: 'dashed',
  },
  ringDot: {
    position: 'absolute',
    top: -6,
    left: '50%',
    marginLeft: -6,
    width: 12,
    height: 12,
    borderRadius: 6,
    backgroundColor: 'rgba(255,255,255,0.8)',
  },

容器用深蓝黑色背景(#0f0f23),营造深色主题。

颜色框

  • 高度 280,大圆角 24
  • 黑色阴影,向下偏移 10 像素,模拟"悬浮"效果
  • overflow: 'hidden':裁剪超出边界的内容,确保圆角生效

旋转圆环

  • 绝对定位,居中
  • 圆形(200×200,圆角 100)
  • 虚线边框(borderStyle: 'dashed'),半透明白色

圆环小圆点

  • 绝对定位,顶部中心
  • left: '50%', marginLeft: -6:水平居中(50% 减去自身宽度的一半)
  • top: -6:向上偏移 6 像素,让圆点的中心在圆环边框上

为什么小圆点要向上偏移?因为小圆点的定位原点是左上角。如果 top: 0,圆点的上边缘在圆环边框上,圆点的中心在圆环内部。向上偏移 6 像素(圆点半径),让圆点的中心在圆环边框上,视觉上更准确。

样式定义:文字和按钮

  colorInfo: { alignItems: 'center' },
  colorHex: { fontSize: 36, fontWeight: '700', marginBottom: 8 },
  colorRgb: { fontSize: 16, opacity: 0.8, marginBottom: 4 },
  colorHsl: { fontSize: 14, opacity: 0.6 },
  hint: { position: 'absolute', bottom: 20, fontSize: 14, opacity: 0.6 },
  btn: {
    backgroundColor: '#1a1a3e',
    borderRadius: 16,
    marginBottom: 24,
    borderWidth: 1,
    borderColor: '#3a3a6a',
    shadowColor: '#4A90D9',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.3,
    shadowRadius: 10,
  },
  btnInner: { paddingVertical: 18, alignItems: 'center' },
  btnText: { color: '#fff', fontSize: 18, fontWeight: '600' },

颜色信息

  • HEX:字号 36,粗体 700,最醒目
  • RGB:字号 16,透明度 0.8,次要
  • HSL:字号 14,透明度 0.6,更次要

提示文字:绝对定位在底部,字号 14,透明度 0.6,不抢眼。

按钮:深蓝色背景,圆角 16,边框,蓝色阴影。阴影向下偏移 4 像素,模拟"按钮"效果。

样式定义:历史记录和色板

  history: {
    backgroundColor: '#1a1a3e',
    padding: 16,
    borderRadius: 16,
    marginBottom: 16,
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  historyTitle: { color: '#fff', fontSize: 16, fontWeight: '600', marginBottom: 16 },
  historyGrid: { flexDirection: 'row', flexWrap: 'wrap' },
  historyItem: {
    width: '31%',
    aspectRatio: 1.5,
    margin: '1%',
    borderRadius: 12,
    overflow: 'hidden',
  },
  historyItemInner: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  historyText: { fontSize: 11, fontWeight: '600' },
  palette: {
    backgroundColor: '#1a1a3e',
    padding: 16,
    borderRadius: 16,
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  paletteTitle: { color: '#fff', fontSize: 16, fontWeight: '600', marginBottom: 16 },
  paletteRow: { flexDirection: 'row' },
  paletteItem: { flex: 1, height: 50, marginHorizontal: 4, borderRadius: 8 },
});

历史记录和色板用相同的背景色、圆角、边框,保持视觉统一。

历史记录项

  • 宽度 31%,纵横比 1.5(宽高比 3:2)
  • 外边距 1%,形成间隙
  • 圆角 12,overflow: 'hidden' 确保圆角生效

历史记录文字:字号 11,很小,粗体 600。

色板项

  • flex: 1:平分剩余空间,5 个色块等宽
  • 高度 50,固定
  • 水平外边距 4,形成间隙
  • 圆角 8,比历史记录的圆角小

为什么色板项用 flex: 1 而历史记录用百分比?因为色板项的数量固定(5 个),用 flex: 1 平分空间最简单。历史记录项的数量不固定(最多 12 个),用百分比控制每行显示 3 个,更灵活。

小结

这个随机颜色工具展示了颜色格式转换和对比度计算的实现。HEX 转 RGB 很简单,HEX 转 HSL 需要复杂的数学计算。对比度计算用 YIQ 公式,考虑人眼对不同颜色的敏感度。历史记录和相关色彩让用户快速预览和选择颜色。


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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐