Skip to content

Instantly share code, notes, and snippets.

@msvargas
Last active November 16, 2021 07:41
Show Gist options
  • Save msvargas/3de4f80e57521cab416c818c294e9003 to your computer and use it in GitHub Desktop.
Save msvargas/3de4f80e57521cab416c818c294e9003 to your computer and use it in GitHub Desktop.
Signature Pad component with react-native-svg
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
const Base64 = {
btoa: (input: string = '') => {
let str = input;
let output = '';
for (
let block = 0, charCode, i = 0, map = chars;
str.charAt(i | 0) || ((map = '='), i % 1);
output += map.charAt(63 & (block >> (8 - (i % 1) * 8)))
) {
charCode = str.charCodeAt((i += 3 / 4));
if (charCode > 0xff) {
throw new Error("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range.");
}
block = (block << 8) | charCode;
}
return output;
},
atob: (input: string = '') => {
let str = input.replace(/=+$/, '');
let output = '';
if (str.length % 4 == 1) {
throw new Error("'atob' failed: The string to be decoded is not correctly encoded.");
}
for (
let bc = 0, bs = 0, buffer, i = 0;
(buffer = str.charAt(i++));
~buffer && ((bs = bc % 4 ? bs * 64 + buffer : buffer), bc++ % 4)
? (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))))
: 0
) {
buffer = chars.indexOf(buffer);
}
return output;
},
};
export default Base64;
import { IBasicPoint, Point } from './Point';
export class Bezier {
public static fromPoints(points: Point[], widths: { start: number; end: number }): Bezier {
const c2 = this.calculateControlPoints(points[0], points[1], points[2]).c2;
const c3 = this.calculateControlPoints(points[1], points[2], points[3]).c1;
return new Bezier(points[1], c2, c3, points[2], widths.start, widths.end);
}
private static calculateControlPoints(
s1: IBasicPoint,
s2: IBasicPoint,
s3: IBasicPoint,
): {
c1: IBasicPoint;
c2: IBasicPoint;
} {
const dx1 = s1.x - s2.x;
const dy1 = s1.y - s2.y;
const dx2 = s2.x - s3.x;
const dy2 = s2.y - s3.y;
const m1 = { x: (s1.x + s2.x) / 2.0, y: (s1.y + s2.y) / 2.0 };
const m2 = { x: (s2.x + s3.x) / 2.0, y: (s2.y + s3.y) / 2.0 };
const l1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const l2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
const dxm = m1.x - m2.x;
const dym = m1.y - m2.y;
const k = l2 / (l1 + l2);
const cm = { x: m2.x + dxm * k, y: m2.y + dym * k };
const tx = s2.x - cm.x;
const ty = s2.y - cm.y;
return {
c1: new Point(m1.x + tx, m1.y + ty),
c2: new Point(m2.x + tx, m2.y + ty),
};
}
constructor(
public startPoint: Point,
public control2: IBasicPoint,
public control1: IBasicPoint,
public endPoint: Point,
public startWidth: number,
public endWidth: number,
) {}
// Returns approximated length. Code taken from https://www.lemoda.net/maths/bezier-length/index.html.
public length(): number {
const steps = 10;
let length = 0;
let px;
let py;
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const cx = this.point(t, this.startPoint.x, this.control1.x, this.control2.x, this.endPoint.x);
const cy = this.point(t, this.startPoint.y, this.control1.y, this.control2.y, this.endPoint.y);
if (i > 0) {
const xdiff = cx - (px as number);
const ydiff = cy - (py as number);
length += Math.sqrt(xdiff * xdiff + ydiff * ydiff);
}
px = cx;
py = cy;
}
return length;
}
// Calculate parametric value of x or y given t and the four point coordinates of a cubic bezier curve.
private point(t: number, start: number, c1: number, c2: number, end: number): number {
// prettier-ignore
return ( start * (1.0 - t) * (1.0 - t) * (1.0 - t))
+ (3.0 * c1 * (1.0 - t) * (1.0 - t) * t)
+ (3.0 * c2 * (1.0 - t) * t * t)
+ ( end * t * t * t);
}
}
// Interface for point data structure used e.g. in SignaturePad#fromData method
export interface IBasicPoint {
x: number;
y: number;
time: number;
}
export class Point implements IBasicPoint {
public time: number;
constructor(public x: number, public y: number, time?: number) {
this.time = time || Date.now();
}
public distanceTo(start: IBasicPoint): number {
const dx = this.x - start.x;
const dy = this.y - start.y;
return Math.sqrt(dx * dx + dy * dy);
}
public equals(other: IBasicPoint): boolean {
return this.x === other.x && this.y === other.y && this.time === other.time;
}
public velocityFrom(start: IBasicPoint): number {
return this.time !== start.time ? this.distanceTo(start) / (this.time - start.time) : 0;
}
}
import * as React from 'react';
import { View, StyleSheet, ViewStyle, Animated, Platform, PixelRatio } from 'react-native';
import {
PanGestureHandler,
TapGestureHandler,
PanGestureHandlerGestureEvent,
PanGestureHandlerStateChangeEvent,
TapGestureHandlerStateChangeEvent,
State,
} from 'react-native-gesture-handler';
import Svg, { G, Path } from 'react-native-svg';
import { captureRef as takeSnapshotAsync, CaptureOptions } from 'react-native-view-shot';
import { Bezier } from './utils/bezier';
import { IBasicPoint, Point } from './utils/point';
import { serializeSvg, enableXML } from './utils/serialize';
import { Base64 } from './utils/base64';
export interface IOptions {
dotSize?: number | (() => number);
minStrokeWidth?: number;
maxStrokeWidth?: number;
minDistance?: number;
strokeColor?: string;
velocityFilterWeight?: number;
onBegin?: (e: PanGestureHandlerStateChangeEvent) => void;
onEnd?: (e: PanGestureHandlerStateChangeEvent) => void;
}
interface IFormatOutputOptions {
/**
*
* @description either png or jpg or webm (Android). Defaults to png. raw is a ARGB array of image pixels.
*/
format?: CaptureOptions['format'] | 'svg';
/**
* Output type when onFingerUp in function
* @format xml only working with "svg" to get output in string xml or (html) format
* @platform "zip-base64" (Android)
* @default "paths"
* @see the method you want to use to save the snapshot, one of: " - tmpfile" (default): save to a temporary file (that will only exist for as long as the app is running). " - base64": encode as base64 and returns the raw string. Use only with small images as this may result of lags (the string is sent over the bridge). N.B. This is not a data uri, use data-uri instead. " - data-uri": same as base64 but also includes the Data URI scheme header. " - zip-base64: compress data with zip deflate algorithm and than convert to base64 and return as a raw string."
*/
output?: CaptureOptions['result'] | 'xml';
}
interface SaveOptions extends IFormatOutputOptions, Omit<CaptureOptions, 'format' | 'output'> {
viewRef?: 'view' | 'svg';
}
export interface SignatureCapturePanelProps extends IOptions, IFormatOutputOptions {
/**
* @description height when autoSave or save fire be default its de same size of signature
*/
width?: number;
/**
* @description height when autoSave or save fire be default its de same size of signature
*/
height?: number;
/**
* @description Custom style or override height and width, change backgroundColor ...etc
*/
style?: ViewStyle;
/**
* autoSave when user end signature after autoSaveTimeout= 1000
*/
onAutoSave?: (result: string) => void;
/**
* timeout to run autoSave
*/
autoSaveTimeout?: number;
/**
* @description called when save callback produce an error
*/
onAutoSaveError?: (error: Error) => void;
/**
* @description imageimageOutputSize
* @default 480
*/
imageOutputSize?: number;
/**
* @default 1.0
* @description the quality. 0.0 - 1.0 (default). (only available on lossy formats like jpg)
*/
quality?: CaptureOptions['quality'];
/**
* @description enable create output with react-native-view-shot and put output after 1s when call onFingerUp
* @default false
*/
autoSave?: boolean;
/**
* @description PanResponder determine if cancel draw signature when outside
*/
shouldCancelWhenOutside?: boolean;
/**
* @description
*/
initialPaths?: { [strokeWidth: string]: string };
/**
* @description select if capture view shot to view container or svg container
* @default view
*/
viewRef?: 'view' | 'svg';
}
//type PathElement = React.ReactElement<React.ComponentPropsWithoutRef<typeof Path>>;
export type IPointGroup = Array<IBasicPoint>;
export interface IBasicPath {
[strokeWidth: string]: string;
}
export interface SignatureCapturePanelState {
paths: IBasicPath;
}
const isAndroid = Platform.OS === 'android';
class SignatureCapturePanel extends React.PureComponent<SignatureCapturePanelProps, SignatureCapturePanelState> {
public static defaultProps: SignatureCapturePanelProps = {
format: 'png',
quality: 1.0,
output: 'data-uri',
autoSave: true,
strokeColor: 'black',
viewRef: 'view',
autoSaveTimeout: 1000,
};
// Public stuff
public dotSize: number | (() => number);
public minStrokeWidth: number;
public maxStrokeWidth: number;
public minDistance: number;
public velocityFilterWeight: number;
// Private stuff
private timer: any = null;
private signatureContainerRef = React.createRef<any>();
private svgRef = React.createRef<any>();
private panRef = React.createRef<PanGestureHandler>();
private tapRef = React.createRef<TapGestureHandler>();
/* tslint:disable: variable-name */
private _isEmpty: boolean = true;
private _lastPoints!: Point[]; // Stores up to 4 most recent points; used to generate a new curve
// Stores up to 4 most recent points; used to generate a new curve
private _data: Array<IPointGroup> = []; // Stores all points in groups (one group per line or dot)
// Stores all points in groups (one group per line or dot)
private _lastVelocity!: number;
private _lastWidth!: number;
private _strokeMoveUpdate!: (event: PanGestureHandlerGestureEvent) => void;
/* tslint:enable: variable-name */
private _signatureElement: React.ReactNode;
private _isTap: boolean | undefined = false;
constructor(props: SignatureCapturePanelProps) {
super(props);
this.state = {
paths: props.initialPaths || {},
};
this.velocityFilterWeight = props.velocityFilterWeight || StyleSheet.hairlineWidth * 0.01;
this.minStrokeWidth = props.minStrokeWidth || StyleSheet.hairlineWidth * 0.25;
this.maxStrokeWidth = props.maxStrokeWidth || PixelRatio.get();
this.minDistance = ('minDistance' in props ? props.minDistance : PixelRatio.get() * 0.5) as number; // in pixels
this._strokeMoveUpdate = this._strokeUpdate.bind(this);
this.dotSize =
props.dotSize ||
function dotSize(this: SignatureCapturePanel) {
return (this.minStrokeWidth + this.maxStrokeWidth) * 0.5;
};
this._reset();
}
public componentDidMount() {
console.log(StyleSheet.hairlineWidth, PixelRatio.get());
// automatic enable XML if outputType if svg
if (this.props.format === 'svg') enableXML();
}
public componentWillUnmount() {
// clear timer on unmount signature panel
this.clearTimeoutAutoSave();
this._signatureElement = undefined;
}
public render() {
const { style, shouldCancelWhenOutside } = this.props;
this._signatureElement = this.renderSvg();
return (
<TapGestureHandler
ref={this.tapRef}
numberOfTaps={1}
waitFor={this.panRef}
onHandlerStateChange={this.handlerTapStateChange}
>
<PanGestureHandler
ref={this.panRef}
maxPointers={1}
minPointers={1}
avgTouches={true}
shouldCancelWhenOutside={shouldCancelWhenOutside}
onHandlerStateChange={this.handlerPanStateChange}
onGestureEvent={this.handlerGestureEvent}
>
<Animated.View
//@ts-ignore
ref={this.signatureContainerRef}
style={[styles.container, style]}
>
{this._signatureElement}
</Animated.View>
</PanGestureHandler>
</TapGestureHandler>
);
}
/**
* @description get SVG paths
*/
public get paths() {
return this.state.paths;
}
/**
* @description get svg tags of signature from SVG paths
*/
public get svg(): string {
return serializeSvg(this._signatureElement) as string;
}
/**
* @description
*/
/**
* @description clear signature
*/
public clear() {
this.clearTimeoutAutoSave();
this._reset();
this._data = [];
this._isEmpty = true;
this.setState({
paths: {},
});
}
/**
* @description check if signature is empty
*/
public isEmpty(): boolean {
return this._isEmpty;
}
/**
* @description save signature
*/
public async save(options?: SaveOptions) {
const { viewRef, ..._options } = Object.assign(
{
viewRef: this.props.viewRef,
format: this.props.format,
quality: this.props.quality,
result: this.props.output as any,
},
options,
);
const output = _options.output;
if (_options.format === 'svg') {
const xml = this.svg;
return output !== 'base64' && output !== 'data-uri'
? xml
: (output === 'data-uri' ? 'data:image/svg+xml;base64,' : '') + Base64.btoa(xml as string);
}
const { width, height } = this.props;
if (typeof width === 'number') _options.width = width;
if (typeof height === 'number') _options.height = height;
return await takeSnapshotAsync(viewRef === 'svg' ? this.svgRef : this.signatureContainerRef, _options as any);
}
/**
* @description set custom paths to draw signature
* @param paths
*/
public setPaths(paths: IBasicPath) {
this.setState({ paths });
}
/**
* @description call toDataURL inner of Svg component
* @callback (PNG : base64string)
*/
public toDataURL(callback: (base64: string) => void, options?: { height?: number; width?: number }) {
return this.svgRef.current?.toDataURL(callback, options);
}
/**
* @description clear timer of autosave
*/
public clearTimeoutAutoSave() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
}
/**
* @description autoSave function when touchEnd
*/
public async autoSave() {
try {
const file = await this.save();
this.props?.onAutoSave?.(file);
} catch (error) {
this.props.onAutoSaveError?.(error);
} finally {
this.timer = undefined;
}
}
/**
* @description append child path and group by strokeWidth to avoid create many views
* @param strokeWidth
* @param path
*/
private appendChild(strokeWidth: string, path: string) {
const paths = { ...this.state.paths };
if (!paths[strokeWidth]) paths[strokeWidth] = path;
else paths[strokeWidth] += path;
this.setState({ paths });
}
/**
* @description Detect handle pan responder state to detect end touch
*/
private handlerPanStateChange = (e: PanGestureHandlerStateChangeEvent) => {
const { oldState, state } = e.nativeEvent;
if (state === State.ACTIVE) {
this._strokeBegin(e);
} else if (oldState === State.ACTIVE && (state === State.END || state === State.CANCELLED)) {
this._strokeEnd(e);
}
};
/**
* @description Detect signature dots to draw circles
*/
private handlerTapStateChange = (e: TapGestureHandlerStateChangeEvent) => {
const { state, oldState } = e.nativeEvent;
if (oldState === State.ACTIVE && state === State.END) {
this._isTap = true;
this.clearTimeoutAutoSave();
this._reset();
this._strokeEnd(e as any);
}
};
/**
* Detect the touch start and move events on the signature pad
* @param {GestureResponderEvent} e Event
* @private
*/
private handlerGestureEvent = (e: PanGestureHandlerGestureEvent) => {
this._strokeMoveUpdate(e);
};
/**
* Takes the points and forms an SVG from them
* @param {Array} paths
* @return {Element}*
* @private
*/
private renderSvg = () => {
return (
<Svg ref={this.svgRef} style={styles.pad} width="100%" height="100%">
<G>
{Object.entries(this.state.paths).map(([strokeWidth, value]) => (
<Path
key={strokeWidth}
d={value}
stroke={this.props.strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill={value.indexOf('a') > -1 ? 'black' : 'none'}
/>
))}
</G>
</Svg>
);
};
/**
* @description returnSignature check if autoSave if enable
*/
public returnSignature = () => {
const { autoSave } = this.props;
if (autoSave) this.timer = setTimeout(() => this.autoSave(), this.props.autoSaveTimeout || 1000);
};
/**
* @description reset lastPoints and timeout
*/
private _reset(): void {
this._lastPoints = [];
this._lastVelocity = 0;
this._lastWidth = (this.minStrokeWidth + this.maxStrokeWidth) / 2;
}
private _createPoint(x: number, y: number): Point {
return new Point(x, y);
}
// Add point to _lastPoints array and generate a new curve if there are enough points (i.e. 3)
private _addPoint(point: Point): Bezier | null {
const { _lastPoints } = this;
_lastPoints.push(point);
if (_lastPoints.length > 2) {
// To reduce the initial lag make it work with 3 points
// by copying the first point to the beginning.
if (_lastPoints.length === 3) {
_lastPoints.unshift(_lastPoints[0]);
}
// _points array will always have 4 points here.
const widths = this._calculateCurveWidths(_lastPoints[1], _lastPoints[2]);
const curve = Bezier.fromPoints(_lastPoints, widths);
// Remove the first element from the list, so that there are no more than 4 points at any time.
_lastPoints.shift();
return curve;
}
return null;
}
private _calculateCurveWidths(startPoint: Point, endPoint: Point): { start: number; end: number } {
const velocity =
this.velocityFilterWeight * endPoint.velocityFrom(startPoint) + (1 - this.velocityFilterWeight) * this._lastVelocity;
const newWidth = this._strokeWidth(velocity);
const widths = {
end: newWidth,
start: this._lastWidth,
};
this._lastVelocity = velocity;
this._lastWidth = newWidth;
return widths;
}
private _strokeWidth(velocity: number): number {
return Math.max(this.maxStrokeWidth / (velocity + 1), this.minStrokeWidth);
}
// Private methods
private _strokeBegin(event: PanGestureHandlerStateChangeEvent): void {
this.clearTimeoutAutoSave();
this._reset();
this._data.push([]);
this._strokeUpdate(event);
this.props.onBegin?.(event);
}
private _strokeUpdate(event: PanGestureHandlerGestureEvent) {
const x = event.nativeEvent.x;
const y = event.nativeEvent.y;
const point = this._createPoint(x, y);
const lastPointGroup = this._data[this._data.length - 1];
const lastPoints = lastPointGroup;
const lastPoint = lastPoints?.length > 0 && lastPoints[lastPoints?.length - 1];
const isLastPointTooClose = lastPoint ? point.distanceTo(lastPoint) <= this.minDistance : false;
// Skip this point if it's too close to the previous one
if (!lastPoint || !(lastPoint && isLastPointTooClose)) {
const curve = this._addPoint(point);
if (this._isTap) {
this._drawDot(point);
this._isTap = false;
} else if (curve) {
this._drawCurve(curve);
}
lastPoints?.push?.({
time: point.time,
x: point.x,
y: point.y,
});
}
}
private _strokeEnd(e: PanGestureHandlerStateChangeEvent): void {
this.returnSignature();
this._strokeUpdate(e);
this.props.onEnd?.(e);
}
private _drawCurve(curve: Bezier): void {
// Need to check curve for NaN values, these pop up when drawing
// lines on the canvas that are not continuous. E.g. Sharp corners
// or stopping mid-stroke and than continuing without lifting mouse.
/* eslint-disable no-restricted-globals */
if (!isNaN(curve.control1.x) && !isNaN(curve.control1.y) && !isNaN(curve.control2.x) && !isNaN(curve.control2.y)) {
const path =
`M ${curve.startPoint.x.toFixed(2)},${curve.startPoint.y.toFixed(2)} ` +
`C ${curve.control1.x.toFixed(2)},${curve.control1.y.toFixed(2)} ` +
`${curve.control2.x.toFixed(2)},${curve.control2.y.toFixed(2)} ` +
`${curve.endPoint.x.toFixed(2)},${curve.endPoint.y.toFixed(2)}`;
const strokeWidth = Math.round(curve.endWidth * 2.5).toString();
this.appendChild(strokeWidth, path);
}
/* eslint-enable no-restricted-globals */
}
/**
* Draw point
* @param point
*/
private _drawDot(point: IBasicPoint) {
const R = Math.round(typeof this.dotSize === 'function' ? this.dotSize() : this.dotSize);
//circle as path
const path = `M ${point.x - R},${point.y} a ${R},${R} 0 1,0 ${R * 2},0 a ${1},${1} 0 1,0 -${R * 2},0`;
this.appendChild(R.toString(), path);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'white',
alignSelf: 'stretch',
},
pad: {
backgroundColor: 'transparent',
},
});
export default SignatureCapturePanel;
// Slightly simplified version of http://stackoverflow.com/a/27078401/815507
export function throttle(fn: (...args: any[]) => any, wait = 250) {
let previous = 0;
let timeout: number | null = null;
let result: any;
let storedContext: any;
let storedArgs: any[];
const later = () => {
previous = Date.now();
timeout = null;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
};
return function wrapper(this: any, ...args: any[]) {
const now = Date.now();
const remaining = wait - (now - previous);
storedContext = this;
storedArgs = args;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
} else if (!timeout) {
timeout = setTimeout(later, remaining) as any;
}
return result;
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment