把手机变成调色盘:用 ArkUI 搓一个带放大镜效果的“UI 灵感色卡取色器”

零、前言

这周二,我们团队的 UI 设计师丢给我一张不知道从哪个设计网站上扒下来的赛博朋克风插画,对我说:"新版本的 App 主题色,我想要这种感觉的渐变蓝紫和一种特殊的橙色作为点缀。你看着取一下色,写到全局变量里。"

我看着那张色彩斑斓、光影复杂的图,陷入了沉默。

"你想要的到底是哪个蓝?哪个紫?图里有大概一万种过渡色。"

设计师轻描淡写地说:"就是那种......看着让人觉得很有科技感,又不太刺眼的蓝。你用取色器吸一下嘛。"

其实在电脑上,开个 Photoshop 或者用拾色器工具吸一下,也就是分分钟的事。但我当时正在回家的地铁上,手里只有一部测试用的手机。我试图在手机上找个能从图片里精准提取十六进制颜色代码(Hex)的工具,结果下了一圈应用市场的软件,差点把手机砸了。

有的应用点开就是一个长达 30 秒的摇一摇广告;有的应用取色全靠瞎点,手指按上去,直接把想要看的像素点给挡住了,取出来的色跟盲盒一样;更过分的是,有个软件说支持生成专属色卡,我刚选了三个颜色,它弹出一个框:【解锁高级调色盘,请开通 9.9 元包月会员】。

作为一个每天和 DevEco Studio 厮杀的程序员,被这种几十行代码就能实现的功能给拿捏,简直是奇耻大辱。

"行,我不用你的软件了。老子自己写一个。"

回到家,我打开电脑,建了一个 HarmonyOS 的工程。今天这篇开发日记,我就完整复盘一下,我是怎么用一个晚上的时间,在华为手机模拟器上,纯用 ArkUI 搓出一个带"悬浮放大镜"效果、支持无延迟取色、还能一键保存成灵感色卡的UI 灵感取色器

里面涉及了 Canvas 像素级操作、RGB 与 Hex 转换算法、还有触摸事件的坐标处理。干货很多,不整虚的,咱们直接看代码是怎么一步步成型的。


一、 核心难题:手指太粗,像素太小

在手机上做取色工具,第一个要解决的不是怎么拿到颜色,而是"反人类的交互"。

在电脑上,鼠标的指针只有几个像素大,指哪打哪。但在手机上,我们是用手指操作的。当你把手指按在屏幕上想要选取某一条很细的光晕颜色时,你的手指会把那个区域遮挡得严严实实。你根本不知道自己当前选中的到底是偏蓝色,还是偏紫色。

这就是传统手机取色软件的通病:胖手指效应(Fat Finger Problem)

怎么解决?我们需要一个"悬浮放大镜"。

当你的手指按在屏幕上时,在手指正上方(偏移大约 80 到 100 像素的位置),凭空出现一个悬浮窗。这个悬浮窗实时显示你手指当前按压位置的颜色,并且把十六进制代码(比如 #FF0055)打在上面。这样,你看着悬浮窗,拖动手指,就能实现像素级的精准定位。

在 ArkUI 里,要实现这个效果,我们需要用 Stack(层叠布局)作为页面的根容器。

底层铺上我们的取色画板,顶层放一个绝对定位的圆形组件。只要监听底层画板的 onTouch 事件,拿到手指的 XY 坐标,然后把圆形组件的 position 绑定到 (X, Y - 100) 的位置,这个悬浮放大镜就搞定了。


二、 怎么从屏幕上"偷"走像素?

解决了交互问题,接下来是最硬核的部分:怎么通过坐标拿到具体的颜色?

如果我们在页面上放一个 <Image> 组件,在 ArkUI 中,普通的图片组件渲染在视图层,你是很难直接写一句 image.getColor(x, y) 来拿到颜色的。如果要强行拿,你需要把图片转成 PixelMap,然后去读取底层的 ArrayBuffer,再根据坐标计算偏移量。这套流程非常繁琐,而且如果在 onTouchMove 这种一秒钟触发 60 次的高频事件里去做 ArrayBuffer 的同步读取,手机大概率会卡成幻灯片。

正解是:用 Canvas 逃课。

不要用 <Image>,我们用 <Canvas> 组件。

HTML5 的开发者对 Canvas 一定不陌生,ArkUI 完美复刻了 Canvas 的 2D 渲染上下文。Canvas 有一个极其强大的同步方法:getImageData(sx, sy, sw, sh)

这个方法可以直接返回指定区域的像素数据。如果把宽高设为 1,getImageData(x, y, 1, 1) 就能在 1 毫秒内,返回当前坐标点 [R, G, B, A] 四个通道的精准色值。

为了让各位拿到这篇代码之后,直接复制进 DevEco Studio 就能跑,不用再去折腾本地图片路径和权限问题,我在这里做了一个妥协:

我不用本地图片,我直接用 Canvas 的渐变 API,在屏幕上现场画出一幅色彩极其绚丽、过渡极其复杂的"赛博朋克极光"背景。

你可以直接在这个由代码生成的背景上滑动取色,效果和放一张真实图片完全一样。


三、 进制转换的数学课

拿到 getImageData 返回的数组后,数据是这样的:比如红色的返回是 [255, 0, 0, 255]

但我们平时写 UI 代码,设计师给的都是十六进制代码,比如 #FF0000。这就需要我们手写一个转换函数。

平时写前端,很多人喜欢用 Number.toString(16) 然后拼接字符串。但在高频调用的触摸事件里,大量创建字符串不仅慢,还容易导致频繁的垃圾回收(GC)。

这里我用了一个位运算(Bitwise)的极客写法:

复制代码
rgbToHex(r: number, g: number, b: number) {
  // 1 << 24 是为了保证结果有 6 位数字,利用位移操作把 RGB 拼成一个整数
  return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
}

这段代码怎么理解呢?

比如 RGB 是 (255, 128, 0)

255 左移 16 位,到了十六进制的前两位;

128 左移 8 位,到了中间两位;

0 留在最后。

加上 1 << 24,是为了确保像 (0, 0, 5) 这种极小的数值转换时,前面能自动补零。最后转成 16 进制字符串,用 slice(1) 砍掉第一位,拼接上 # 号,转成大写。

干脆利落,不拖泥带水,而且执行速度极快。


四、 灵感色卡面板:状态管理与数组坑点

取色只是第一步。我取到了好看的颜色,我得保存下来对比啊。

所以我们需要在屏幕底部做一个"收藏夹"(色卡板)。每次取色觉得满意,点击保存按钮,这个颜色就会作为一个色块,追加到屏幕底部的列表里。

这就涉及到了 ArkTS 的状态管理。

我们需要定义一个数组:@State savedColors: string[] = []

这里我必须要单独拎出来提醒一下,所有从其他语言转来写鸿蒙的开发者,很容易在这里翻车。

如果你在某个点击事件里,直接写 this.savedColors[0] = '#FFFFFF',或者去修改数组内部对象的属性,界面的色卡板是绝对不会更新的

在 ArkTS 中,框架的观察者(Observer)默认只能监听到数组整体引用的变化,或者少数几个被重写了的数组变异方法(比如 push, pop, splice, shift, unshift)。

所以,当我们保存颜色时,使用 this.savedColors.push(currentColor) 是安全的。

如果你要删除色卡里的某一个颜色,千万不要用 delete array[index],老老实实地用 this.savedColors.splice(index, 1)。只有这样,视图层才能收到更新通知,把那个颜色块给删掉。


五、 完整工程代码:一份可以直接跑的源码

讲完了所有的踩坑点,现在咱们把零件组装起来。

下面是这个"灵感取色器"的完整代码。它是一个单文件组件,不需要你配置任何路由、权限或本地静态资源。

只要你有 DevEco Studio,新建好工程,把代码覆盖粘贴进去,立刻就能看到效果。

为了防止阅读疲劳,我把所有的解释都写在了代码的注释里。顺着注释看,就是这个应用运行的完整生命周期。

打开你工程的 entry/src/main/ets/pages/Index.ets,清空内容,粘贴:

复制代码
import { curves } from '@kit.ArkUI';

@Entry
@Component
struct PalettePicker {
  // ==========================================
  // 核心状态数据
  // ==========================================
  
  // 触摸点在屏幕上的真实坐标
  @State touchX: number = 0;
  @State touchY: number = 0;
  
  // 是否正在触摸屏幕(控制放大镜的显示和隐藏)
  @State isTouching: boolean = false;
  
  // 当前吸取到的十六进制颜色值
  @State currentColorHex: string = '#FFFFFF';
  
  // 底部调色盘:保存用户收藏的颜色
  @State savedColors: string[] = ['#1E1E1E', '#3498DB'];

  // ==========================================
  // Canvas 配置
  // ==========================================
  
  // 开启抗锯齿,保证画板渲染质量
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  
  // 画板的物理宽高,用于绘制极光背景
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;

  // ==========================================
  // 工具函数
  // ==========================================

  /**
   * 将 R, G, B 数值转换为带 # 的十六进制大写字符串
   */
  rgbToHex(r: number, g: number, b: number): string {
    // 利用位运算保证补零,速度最快
    return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
  }

  /**
   * 在 Canvas 上绘制一个极其复杂的"赛博极光"渐变背景
   * 替代了繁琐的本地图片加载,保证复制即跑
   */
  drawInspirationBackground() {
    if (this.canvasWidth === 0 || this.canvasHeight === 0) return;

    // 清空画布
    this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);

    // 第一层:暗夜深蓝色主底色
    let bgGrad = this.ctx.createLinearGradient(0, 0, 0, this.canvasHeight);
    bgGrad.addColorStop(0, '#0F2027');
    bgGrad.addColorStop(0.5, '#203A43');
    bgGrad.addColorStop(1, '#2C5364');
    this.ctx.fillStyle = bgGrad;
    this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);

    // 第二层:左上角的紫红色光晕 (放射性渐变)
    let radial1 = this.ctx.createRadialGradient(
      this.canvasWidth * 0.2, this.canvasHeight * 0.2, 10,
      this.canvasWidth * 0.2, this.canvasHeight * 0.2, 300
    );
    radial1.addColorStop(0, 'rgba(255, 0, 102, 0.8)');
    radial1.addColorStop(1, 'rgba(255, 0, 102, 0)');
    this.ctx.fillStyle = radial1;
    this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);

    // 第三层:右下角的青色电光
    let radial2 = this.ctx.createRadialGradient(
      this.canvasWidth * 0.8, this.canvasHeight * 0.7, 50,
      this.canvasWidth * 0.8, this.canvasHeight * 0.7, 400
    );
    radial2.addColorStop(0, 'rgba(0, 255, 204, 0.9)');
    radial2.addColorStop(1, 'rgba(0, 255, 204, 0)');
    this.ctx.fillStyle = radial2;
    this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);

    // 第四层:中间贯穿的黄色暖光带
    let linear1 = this.ctx.createLinearGradient(0, this.canvasHeight * 0.4, this.canvasWidth, this.canvasHeight * 0.6);
    linear1.addColorStop(0, 'rgba(241, 196, 15, 0)');
    linear1.addColorStop(0.5, 'rgba(241, 196, 15, 0.6)');
    linear1.addColorStop(1, 'rgba(241, 196, 15, 0)');
    this.ctx.fillStyle = linear1;
    this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
  }

  // ==========================================
  // 核心交互逻辑
  // ==========================================
  
  /**
   * 处理触摸事件,提取像素颜色并更新坐标
   */
  handleTouch(event: TouchEvent) {
    if (event.touches.length === 0) return;
    
    // 获取单指的坐标
    let x = event.touches[0].x;
    let y = event.touches[0].y;

    // 边界防护:防止手指滑出 Canvas 导致报错
    if (x < 0) x = 0;
    if (y < 0) y = 0;
    if (x >= this.canvasWidth) x = this.canvasWidth - 1;
    if (y >= this.canvasHeight) y = this.canvasHeight - 1;

    // 状态分发
    if (event.type === TouchType.Down || event.type === TouchType.Move) {
      this.isTouching = true;
      this.touchX = x;
      this.touchY = y;

      // 【精髓所在】同步获取 1x1 区域的像素数组
      let imgData = this.ctx.getImageData(x, y, 1, 1);
      let data = imgData.data; // 返回 Uint8ClampedArray [R, G, B, A]

      // 转换为 Hex 并存入状态,触发 UI 刷新
      this.currentColorHex = this.rgbToHex(data[0], data[1], data[2]);
    } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
      // 抬起手指,延迟一下让放大镜消失,动画更柔和
      animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
        this.isTouching = false;
      });
    }
  }

  // ==========================================
  // 视图构建
  // ==========================================
  build() {
    // 根容器必须是 Stack,因为要实现放大镜的绝对定位悬浮
    Stack() {
      
      // ----------------------------------------
      // 第 1 层:Canvas 取色背景板
      // ----------------------------------------
      Canvas(this.ctx)
        .width('100%')
        .height('100%')
        // 组件加载准备完毕时,获取宽高并绘制背景
        .onReady(() => {
          this.canvasWidth = this.ctx.width;
          this.canvasHeight = this.ctx.height;
          this.drawInspirationBackground();
        })
        // 绑定全屏的触摸事件
        .onTouch((e) => this.handleTouch(e))

      // ----------------------------------------
      // 第 2 层:悬浮放大镜 (胖手指杀手)
      // ----------------------------------------
      if (this.isTouching) {
        Column() {
          // 颜色展示圆盘
          Circle({ width: 80, height: 80 })
            .fill(this.currentColorHex) // 实心填充当前吸到的颜色
            .strokeWidth(6)
            .stroke('#FFFFFF') // 加一圈白边,对比更强烈
            .shadow({ radius: 10, color: '#66000000', offsetY: 5 })
          
          // 颜色代码标签框
          Text(this.currentColorHex)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
            .backgroundColor('#F5F5F5')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(12)
            .margin({ top: -10 }) // 往上挤一点,和圆形交叉
        }
        .width(100)
        .height(110)
        // 【核心交互】动态绑定坐标,X 轴居中,Y 轴向上偏移 100 像素防遮挡
        .position({ x: this.touchX - 50, y: this.touchY - 120 })
        // 入场和出场动画
        .transition(TransitionEffect.scale({ x: 0.5, y: 0.5 }).animation({ curve: Curve.FastOutSlowIn }))
        // 防止放大镜挡住自己的触摸事件
        .hitTestBehavior(HitTestMode.None)
      }

      // ----------------------------------------
      // 第 3 层:底部调色盘 Dock
      // ----------------------------------------
      Column() {
        Row() {
          Text('灵感调色盘')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          
          Blank() // 撑开空间
          
          // 当前颜色预览和保存按钮
          Row({ space: 10 }) {
            Circle({ width: 20, height: 20 })
              .fill(this.currentColorHex)
              .stroke('#FFFFFF')
              .strokeWidth(2)
            
            Button('吸取保存')
              .height(30)
              .fontSize(12)
              .backgroundColor('#007DFF')
              .onClick(() => {
                // 如果数组里还没有这个颜色,就 push 进去
                if (!this.savedColors.includes(this.currentColorHex)) {
                  this.savedColors.push(this.currentColorHex);
                }
              })
          }
        }
        .width('100%')
        .padding({ bottom: 15 })

        // 色卡流式展示区
        Flex({ wrap: FlexWrap.Wrap, space: { main: LengthMetrics.vp(10), cross: LengthMetrics.vp(10) } }) {
          ForEach(this.savedColors, (color: string, index: number) => {
            Row() {
              // 色块
              Rect({ width: 24, height: 24 })
                .fill(color)
                .radius(4)
              // 十六进制文案
              Text(color).fontSize(12).fontColor('#333333').margin({ left: 8 })
              // 删除小按钮
              Text(' ×')
                .fontSize(16).fontColor('#999999').margin({ left: 5 })
                .onClick(() => {
                  // 必须使用 splice 才能触发 @State 数组的视图更新!
                  this.savedColors.splice(index, 1);
                })
            }
            .backgroundColor('#FFFFFF')
            .padding({ left: 6, right: 10, top: 6, bottom: 6 })
            .borderRadius(8)
            .shadow({ radius: 3, color: '#22000000', offsetY: 2 })
          })
        }
        .width('100%')
      }
      .width('90%')
      .backgroundColor('rgba(30, 30, 30, 0.85)') // 半透明毛玻璃黑底
      .borderRadius(20)
      .padding(20)
      .position({ x: '5%', y: '100%' })
      // 把 Dock 固定在底部上方一点点的位置
      .translate({ x: 0, y: '-100%' })
      .margin({ bottom: 20 })
      // 给底座加一点弥散阴影
      .shadow({ radius: 20, color: '#88000000', offsetY: 10 })
      // 毛玻璃特效 (背景模糊)
      .backdropBlur(20)

    }
    .width('100%')
    .height('100%')
    // 隐藏状态栏沉浸式体验更好,这里为简单演示设置全屏黑底
    .backgroundColor('#000000')
  }
}

六、 运行实测:把它跑在华为手机上

代码搞定了,现在我们在 DevEco Studio 里看看成果。

  1. 启动模拟器:在 IDE 右上角的 Device Manager 中,启动你的 Huawei Phone 模拟器(推荐 API 11+ 的 Stage 模型环境)。
  2. 编译安装 :选中 entry 模块,点击绿色的 Run 按钮。没有乱七八糟的 Gradle 依赖,几秒钟后,应用就会在模拟器上弹出来。
  3. 视觉冲击 :屏幕亮起的一瞬间,不是枯燥的白板,而是一幅由代码生成的、色彩浓郁的"赛博极光"全屏背景。这种用 Canvas createRadialGradient 叠加出来的效果,比加载 JPG 图片还快,且毫无失真。
  4. 把玩放大镜 :用鼠标(或在真机上用手指)按住屏幕中间。你会发现手指按下的那一刻,指尖上方立刻弹出一个带阴影的圆形悬浮窗。随着你的拖动,悬浮窗紧紧跟随着你的移动轨迹,里面的颜色和底部的十六进制标签 #XXXXXX 以肉眼难以分辨的极高帧率在疯狂跳动刷新。不管你怎么移动,悬浮窗永远不会被你的手指遮挡。
  5. 保存色卡:滑到一个你觉得很骚气的紫红色,停住。另一只手点击屏幕底部的"吸取保存"按钮。这个颜色瞬间就变成了一张精致的小色卡,乖乖地躺在了底部的半透明毛玻璃 Dock 栏里。不想要的颜色?点击色卡旁边的"×",它立刻消失。

做完这个小工具,我立刻把它打包装进了自己的手机。

下周一上班,当 UI 设计师再拿着一张图让我取色的时候,我就可以当着她的面,打开这个 App,用极其优雅的姿势在屏幕上滑动,然后把提取出来的 HEX 甩到她脸上。

技术不就是用来干这个的吗?把那些繁琐、恶心、还要看广告的日常痛点,用自己写的代码一行行抹平。不用求人,也没有商业软件的恶臭,这就是身为开发者的终极爽感。老哥们,拿去玩吧。

相关推荐
ai_coder_ai13 小时前
在自动化脚本ui编程之webview控件
ui·autojs·自动化脚本·冰狐智能辅助·easyclick
RReality14 小时前
【Unity Shader URP】色带渐变着色(Ramp Shading)实战教程
ui·unity·游戏引擎·图形渲染
jixingkj20 小时前
避开设置误区,让免打扰模式真正适配你的生活
大数据·安全·智能手机
小樱花的樱花1 天前
4 文件选择对话框 QFileDialog
开发语言·c++·ui
Z_Wonderful1 天前
文件上传,pc端上传成功,手机上传失败,有线网络与移动 网络的限制
网络·智能手机
wanhengidc1 天前
服务器如何防范爬虫攻击?
运维·服务器·网络·爬虫·游戏·智能手机
Digitally1 天前
如何将短信从华为手机迁移到 iPhone
华为·智能手机·iphone
jingxindeyi1 天前
electron 配置 shadcn-ui
javascript·ui·electron
可达鸭小栈1 天前
易语言自绘UI实战:基于美易模块的登录界面快速开发(可换肤)
ui