Skyroc Native UI

Signature

手写签名板,基于 Skia 绘制并导出 base64 图片

签名板(Signature)提供一块可手写的画布,并把笔迹导出为 base64 图片。绘制基于 @shopify/react-native-skia,手势基于 react-native-gesture-handler:整个书写过程跑在 UI 线程 —— 手势直接改写 shared value 里的 SkPath,Skia 自行重绘,一帧都不经过 React,只在落笔与抬笔时各跨回 JS 线程一次,用于维护撤销历史和触发回调。

import { Signature } from '@skyroc/native-ui';

组件内部用到了 GestureDetector,请确保 App 根节点已经包了 GestureHandlerRootView

基础用法

默认带一个底部按钮栏(清除 + 确认)。点确认触发 onSubmit,回调参数里 image 是 base64 data URI,isEmpty 表示画布是否为空。

SignatureBasic.tsx
Loading…
import { Image, Signature, Text } from '@skyroc/native-ui';
import type { SignatureSubmitData } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';

/** SignatureBasic 组件属性 */
interface SignatureBasicProps {
  /** 书写状态变化:外层是滚动容器时据此临时锁掉滚动,单独使用可以不传 */
  onSigningChange?: (signing: boolean) => void;
}

const SignatureBasic = (props: SignatureBasicProps) => {
  const { onSigningChange } = props;

  const [preview, setPreview] = useState('');
  const [tip, setTip] = useState('');

  function handleSubmit(data: SignatureSubmitData) {
    if (data.isEmpty) {
      setTip('还没签名');
      setPreview('');
      return;
    }

    setTip(`已生成,base64 长度 ${data.image.length}`);
    setPreview(data.image);
  }

  return (
    <View className="bg-background p-4">
      <Signature
        tips="请在此处签名"
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
        onSubmit={handleSubmit}
      />
      {tip ? (
        <Text
          className="mt-2"
          color="muted"
        >
          {tip}
        </Text>
      ) : null}
      {preview ? (
        <Image
          className="mt-2 h-[120px] w-full rounded-lg border border-border"
          contentFit="contain"
          src={preview}
        />
      ) : null}
    </View>
  );
};

export { SignatureBasic };

tips 是画布为空时显示的占位文字,落笔后自动消失,且不拦截触摸(pointerEvents="none")。

何时使用

  • 需要用户手写签名并留存图片:合同确认、签收单、回执。
  • 只是画个草图或涂鸦,也可以用它,但组件没有画笔粗细切换与橡皮擦。

语义笔色

color 决定笔迹颜色,默认 carbon —— 它在浅色主题下是近黑、深色主题下是近白,正好是「墨」该有的行为。写死 #000 会在深色模式下和深色画布糊成一片。

SignatureColor.tsx
Loading…
import type { SignatureVariantProps } from '@skyroc/native-ui';
import { Button, Signature, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';

const COLORS: NonNullable<SignatureVariantProps['color']>[] = [
  'carbon',
  'primary',
  'secondary',
  'accent',
  'success',
  'warning',
  'destructive',
  'info'
];

/** SignatureColor 组件属性 */
interface SignatureColorProps {
  /** 书写状态变化:外层是滚动容器时据此临时锁掉滚动,单独使用可以不传 */
  onSigningChange?: (signing: boolean) => void;
}

/**
 * 八种语义色共用一块画布,靠上方按钮切换。
 *
 * 不是为了省地方:每块 Signature 在 web 上都要独占一个 WebGL 上下文(Skia 的画布是 GPU 表面),
 * 而浏览器每页只给 16 个,超出后最早创建的那几块会被静默回收 —— 表现为画布还在、笔却画不上去。
 * 八个语义色摊成八块画布,光这一节就吃掉半个额度,文档站整页必然溢出。
 */
const SignatureColor = (props: SignatureColorProps) => {
  const { onSigningChange } = props;

  const [color, setColor] = useState<NonNullable<SignatureVariantProps['color']>>('carbon');

  return (
    <View className="gap-4 bg-background p-4">
      <View className="flex-row flex-wrap gap-2">
        {COLORS.map(item => (
          <Button
            key={item}
            color="primary"
            size="sm"
            variant={item === color ? 'solid' : 'outline'}
            onPress={() => setColor(item)}
          >
            {item}
          </Button>
        ))}
      </View>

      <Text className="text-sm font-medium text-foreground">color={color}</Text>

      {/* 笔色是整块画布一支笔,切换颜色时已经写下的笔迹会一起换色,正好用来对比 */}
      <Signature
        color={color}
        showFooter={false}
        tips={`${color} 笔色`}
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
      />
    </View>
  );
};

export { SignatureColor };

secondary 是浅底色,直接当笔色几乎不可见,因此内部取前景色代替。需要完全脱离主题时用 penColor 传具体色值。

尺寸与线宽

size 只决定画布高度(宽度撑满父容器),lineWidth 决定笔画粗细:

尺寸画布高度
sm140
md200
lg280
SignatureSize.tsx
Loading…
import { Signature } from '@skyroc/native-ui';
import { View } from 'react-native';

/** SignatureSize 组件属性 */
interface SignatureSizeProps {
  /** 书写状态变化:外层是滚动容器时据此临时锁掉滚动,单独使用可以不传 */
  onSigningChange?: (signing: boolean) => void;
}

const SignatureSize = (props: SignatureSizeProps) => {
  const { onSigningChange } = props;

  return (
    <View className="gap-4 bg-background p-4">
      <Signature
        lineWidth={1.5}
        showFooter={false}
        size="sm"
        tips="sm + 细笔"
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
      />
      <Signature
        lineWidth={3}
        showFooter={false}
        size="md"
        tips="md + 默认线宽"
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
      />
      <Signature
        color="destructive"
        lineWidth={6}
        showFooter={false}
        size="lg"
        tips="lg + 粗笔"
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
      />
    </View>
  );
};

export { SignatureSize };

命令式调用

ref 暴露四个方法,配合 showFooter={false} 可以完全用自己的按钮驱动:

方法说明
clear()清空画布,并触发 onClear
undo()撤销最后一笔
submit()等价于点确认按钮,结果走 onSubmit
toDataURL()返回 Promise<string>,画布为空或快照失败时是空字符串
SignatureImperative.tsx
Loading…
import { Button, Image, Signature, Text } from '@skyroc/native-ui';
import type { SignatureRef, SignatureSubmitData } from '@skyroc/native-ui';
import { useRef, useState } from 'react';
import { View } from 'react-native';

/** SignatureImperative 组件属性 */
interface SignatureImperativeProps {
  /** 书写状态变化:外层是滚动容器时据此临时锁掉滚动,单独使用可以不传 */
  onSigningChange?: (signing: boolean) => void;
}

const SignatureImperative = (props: SignatureImperativeProps) => {
  const { onSigningChange } = props;

  const [preview, setPreview] = useState('');
  const [tip, setTip] = useState('');

  const signatureRef = useRef<SignatureRef>(null);

  function handleSubmit(data: SignatureSubmitData) {
    if (data.isEmpty) {
      setTip('还没签名');
      setPreview('');
      return;
    }

    setTip(`已生成,base64 长度 ${data.image.length}`);
    setPreview(data.image);
  }

  async function handleToDataURL() {
    const image = await signatureRef.current?.toDataURL();

    if (!image) {
      setTip('画布为空,toDataURL 返回空字符串');
      setPreview('');
      return;
    }

    setTip(`toDataURL 已生成,base64 长度 ${image.length}`);
    setPreview(image);
  }

  return (
    <View className="bg-background p-4">
      <Signature
        ref={signatureRef}
        showFooter={false}
        tips="用下面的按钮控制"
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
        onSubmit={handleSubmit}
      />
      <View className="mt-3 flex-row flex-wrap gap-2">
        <Button
          className="min-w-[45%] flex-1"
          variant="outline"
          onPress={() => signatureRef.current?.undo()}
        >
          撤销
        </Button>
        <Button
          className="min-w-[45%] flex-1"
          variant="outline"
          onPress={() => signatureRef.current?.clear()}
        >
          清除
        </Button>
        <Button
          className="min-w-[45%] flex-1"
          onPress={() => signatureRef.current?.submit()}
        >
          提交
        </Button>
        <Button
          className="min-w-[45%] flex-1"
          variant="outline"
          onPress={handleToDataURL}
        >
          导出 Data URL
        </Button>
      </View>
      {tip ? (
        <Text
          className="mt-2"
          color="muted"
        >
          {tip}
        </Text>
      ) : null}
      {preview ? (
        <Image
          className="mt-2 h-[120px] w-full rounded-lg border border-border"
          contentFit="contain"
          src={preview}
        />
      ) : null}
    </View>
  );
};

export { SignatureImperative };

撤销的实现是把每一笔以 SVG 字符串存进历史,撤销时用剩余笔画重建整条已完成路径 —— 存字符串而不是 SkPath,跨线程时不涉及宿主对象的生命周期。

导出格式

type 选择 png(默认)或 jpegquality 取值 0–100,缺省时 png 用 100、jpeg 用 80(签名是大片纯色,再高只是白白撑大 base64)。

SignatureJpeg.tsx
Loading…
import { Image, Signature, Text } from '@skyroc/native-ui';
import type { SignatureSubmitData } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';

/** SignatureJpeg 组件属性 */
interface SignatureJpegProps {
  /** 书写状态变化:外层是滚动容器时据此临时锁掉滚动,单独使用可以不传 */
  onSigningChange?: (signing: boolean) => void;
}

const SignatureJpeg = (props: SignatureJpegProps) => {
  const { onSigningChange } = props;

  const [preview, setPreview] = useState('');
  const [tip, setTip] = useState('');

  function handleSubmit(data: SignatureSubmitData) {
    if (data.isEmpty) {
      setTip('还没签名');
      setPreview('');
      return;
    }

    setTip(`已生成,base64 长度 ${data.image.length}`);
    setPreview(data.image);
  }

  return (
    <View className="bg-background p-4">
      <Signature
        quality={60}
        tips="导出为 jpeg"
        type="jpeg"
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
        onSubmit={handleSubmit}
      />
      {tip ? (
        <Text
          className="mt-2"
          color="muted"
        >
          {tip}
        </Text>
      ) : null}
      {preview ? (
        <Image
          className="mt-2 h-[120px] w-full rounded-lg border border-border"
          contentFit="contain"
          src={preview}
        />
      ) : null}
    </View>
  );
};

export { SignatureJpeg };

JPEG 没有 alpha 通道,透明底会被压成纯黑。所以导出格式为 jpeg 且未指定 backgroundColor(或显式传了 'transparent')时,组件会自动回落到画布的 bg-background 作为填充色。

书写事件

回调时机
onStart手指按下画布
onSigning书写过程中每个采样点
onEnd手指抬起
onClear清除时
onSubmit每次提交,无论画布是否为空都恰好回调一次
SignatureEvents.tsx
Loading…
import { Signature, Text } from '@skyroc/native-ui';
import { useRef, useState } from 'react';
import { View } from 'react-native';

/** SignatureEvents 组件属性 */
interface SignatureEventsProps {
  /** 书写状态变化:外层是滚动容器时据此临时锁掉滚动 */
  onSigningChange?: (signing: boolean) => void;
}

const SignatureEvents = (props: SignatureEventsProps) => {
  const { onSigningChange } = props;

  const [status, setStatus] = useState('等待落笔');

  const signingCountRef = useRef(0);

  function handleStart() {
    signingCountRef.current = 0;
    setStatus('onStart:已落笔');
    onSigningChange?.(true);
  }

  function handleSigning() {
    signingCountRef.current += 1;
  }

  function handleEnd() {
    setStatus(`onEnd:本笔触发 onSigning ${signingCountRef.current} 次`);
    onSigningChange?.(false);
  }

  function handleClear() {
    setStatus('onClear:画布已清空');
  }

  return (
    <View className="gap-2 bg-background p-4">
      <Signature
        clearButtonText="清空笔迹"
        confirmButtonText="生成图片"
        tips="写一笔后观察回调"
        onClear={handleClear}
        onEnd={handleEnd}
        onSigning={handleSigning}
        onStart={handleStart}
      />
      <Text className="text-sm text-muted-foreground">{status}</Text>
    </View>
  );
};

export { SignatureEvents };

onSigning 要谨慎使用:绘制本身不经过 React,传了这个回调就意味着每个采样点都要跨回 JS 线程。只在确实需要「正在书写」指示(比如临时锁掉外层 ScrollView)时才传。

采样本身也做了节流:两点间距小于 1.5px 的移动事件直接丢弃 —— 触摸流每秒能吐上百个几乎重合的点,全都入 path 既让曲线抖动,又把点数推高一个量级。笔迹用二次贝塞尔连接(以上一个采样点为控制点、两点中位数为终点),所以是圆滑曲线而不是折线;单点点按会补一段 0.01px 的极短线段,配合圆端帽正好落成一个圆点。

自定义颜色与样式

className 追加到根容器上,classNames 按 slot 细粒度覆盖:

slot作用位置
root最外层 View(画布 + 按钮栏)
canvas画布容器(高度、边框、圆角、裁剪)
tips占位文字的定位容器
tipsText占位文字本身
footer底部按钮栏容器
pen色源:用 text-* 类给笔迹取色,不对应任何渲染节点
background色源:用 bg-* 类给画布填充取色,不对应任何渲染节点
SignatureStyles.tsx
Loading…
import { Signature } from '@skyroc/native-ui';
import { View } from 'react-native';
import { useResolveClassNames } from 'uniwind';

/** SignatureStyles 组件属性 */
interface SignatureStylesProps {
  /** 书写状态变化:外层是滚动容器时据此临时锁掉滚动 */
  onSigningChange?: (signing: boolean) => void;
}

const SignatureStyles = (props: SignatureStylesProps) => {
  const { onSigningChange } = props;

  const backgroundStyle = useResolveClassNames('bg-warning-50');
  const penStyle = useResolveClassNames('text-warning-700');

  const backgroundColor =
    typeof backgroundStyle.backgroundColor === 'string' ? backgroundStyle.backgroundColor : undefined;
  const penColor = typeof penStyle.color === 'string' ? penStyle.color : undefined;

  return (
    <View className="bg-background p-4">
      <Signature
        backgroundColor={backgroundColor}
        className="rounded-2xl border border-warning-200 p-3"
        classNames={{
          canvas: 'rounded-xl border-solid border-warning-300',
          footer: 'mt-4',
          tipsText: 'font-medium text-warning-700'
        }}
        clearButtonText="重新书写"
        confirmButtonText="使用签名"
        penColor={penColor}
        tips="自定义画布与画笔"
        onEnd={() => onSigningChange?.(false)}
        onStart={() => onSigningChange?.(true)}
      />
    </View>
  );
};

export { SignatureStyles };

pen / background 之所以是「色源」而不是普通 slot:Skia 的 Canvas 不吃 className,组件用 useResolveClassNames 把这两个槽解析成真实色值再喂给 <Path> / <Fill>,笔色和底色因此仍然跟随主题 token 与深浅模式。直接传 penColor / backgroundColor 会覆盖解析结果。

禁用与只读

disabled 阻止书写、把整体降到 50% 不透明度,并禁用底部两个按钮;readonly 只阻止书写,按钮仍可用(还能提交已有内容)。

SignatureDisabled.tsx
Loading…
import { Signature } from '@skyroc/native-ui';
import { View } from 'react-native';

const SignatureDisabled = () => {
  return (
    <View className="gap-4 bg-background p-4">
      <Signature
        disabled
        tips="禁用状态"
      />
      <Signature
        readonly
        tips="只读:画不上,但按钮仍可用"
      />
    </View>
  );
};

export { SignatureDisabled };

API

Signature

属性说明类型默认值
size画布高度预设,宽度撑满父容器'sm' | 'md' | 'lg''md'
color笔迹的主题色'primary' | 'destructive' | 'success' | 'warning' | 'info' | 'accent' | 'carbon' | 'secondary''carbon'
lineWidth画笔线宽(px)number3
penColor画笔颜色,传入后覆盖 color 解析出的主题色;仅在需要脱离主题时使用string-
backgroundColor画布填充色,会一并烘进导出图片;缺省为透明,jpeg 下自动回落到画布底色string-
type导出图片格式'png' | 'jpeg''png'
quality导出图片质量,取值 0–100;缺省时 png 用 100、jpeg 用 80number-
tips画布为空时显示的提示文字string-
showFooter是否显示底部按钮栏(清除 + 确认)booleantrue
clearButtonText清除按钮文字string'清除'
confirmButtonText确认按钮文字string'确认'
disabled禁用,画布与底部按钮均不响应并整体置灰booleanfalse
readonly只读,画布不接受输入但仍可提交已有内容booleanfalse
onStart开始签名(手指触摸画布)时触发() => void-
onSigning签名过程中持续触发;每次都要跨回 JS 线程,按需使用() => void-
onEnd签名结束(手指抬起)时触发() => void-
onClear清除签名时触发() => void-
onSubmit提交签名时触发,每次提交恰好回调一次(data: SignatureSubmitData) => void-
className根容器类名,合并在 classNames.root 之后string-
classNames各 slot 的类名覆盖,见「自定义颜色与样式」一节SlotClassNames<SignatureSlots>-
ref组件实例引用,用于命令式调用 clear / undo / submit / toDataURLRef<SignatureRef>-

类型

import type { SignatureImageType, SignatureProps, SignatureRef, SignatureSubmitData } from '@skyroc/native-ui';

SignatureImageType

签名图片的导出格式。

'jpeg' | 'png'

SignatureSlots

可通过 classNames 覆盖的 slot 名称,其中 pen / background 是色源而非渲染节点。

'background' | 'canvas' | 'footer' | 'pen' | 'root' | 'tips' | 'tipsText'

SlotClassNames

classNames 的取值形态:把 slot 名映射到类名,每个 slot 都可选。本页用到的 slot 见 SignatureSlots。

Partial<Record<Slots, string>>

SignatureSubmitData

onSubmit 的回调参数。

字段类型说明
image*stringBase64 编码的 data URI;画布为空时是空字符串,isEmpty 为 false 却拿到空串说明快照生成失败。
isEmpty*boolean画布是否为空。

SignatureRef

组件实例暴露的命令式方法。

字段类型说明
clear*() => void清除画布上的所有内容。
undo*() => void撤销最后一笔。
submit*() => void触发提交,结果通过 onSubmit 返回。
toDataURL*() => Promise<string>生成签名图片的 data URI,画布为空或快照失败时返回空字符串。

包内还导出了 signatureVariants 与四个常量:DEFAULT_SIGNATURE_LINE_WIDTH(3)、SIGNATURE_QUALITY_MAP(png 100 / jpeg 80)、SIGNATURE_MIN_SAMPLE_DISTANCE(1.5)、SIGNATURE_DOT_LENGTH(0.01)。