├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── dist ├── index.d.ts └── index.js ├── src └── index.tsx └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | .expo 5 | *.tgz -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "rootDir": "src", 5 | "declaration": true, 6 | "jsx": "react", 7 | "module": "ESNext", 8 | "target": "ES2017", 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2025, Solarin Johnson 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-drawpad", 3 | "version": "0.3.0", 4 | "description": "A reusable drawpad component for Expo React Native", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "prepare": "npm run build" 10 | }, 11 | "keywords": [ 12 | "react-native", 13 | "expo", 14 | "component", 15 | "drawpad", 16 | "signature", 17 | "canvas", 18 | "svg", 19 | "gesture" 20 | ], 21 | "author": "Solarin Johnson", 22 | "license": "ISC", 23 | "peerDependencies": { 24 | "react": ">=18", 25 | "react-native": ">=0.81.4", 26 | "react-native-gesture-handler": ">=2.28.0", 27 | "react-native-reanimated": ">=4.1.1", 28 | "react-native-svg": ">=15.12.1", 29 | "react-native-worklets": ">=0.5.1" 30 | }, 31 | "dependencies": { 32 | "svg-path-properties": "^1.3.0" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "^19.2.2", 36 | "@types/react-native": "^0.72.8", 37 | "react-native-gesture-handler": "^2.28.0", 38 | "react-native-reanimated": "^4.1.3", 39 | "react-native-svg": "^15.14.0", 40 | "react-native-worklets": "^0.6.1", 41 | "typescript": "^5.9.2" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/Solarin-Johnson/expo-drawpad.git" 46 | }, 47 | "bugs": { 48 | "url": "https://github.com/Solarin-Johnson/expo-drawpad/issues" 49 | }, 50 | "homepage": "https://github.com/Solarin-Johnson/expo-drawpad#readme" 51 | } 52 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PathProps } from "react-native-svg"; 3 | import { SharedValue, EasingFunction } from "react-native-reanimated"; 4 | export interface gradientProps { 5 | colors: string[]; 6 | locations?: number[]; 7 | start?: { 8 | x: number; 9 | y: number; 10 | }; 11 | end?: { 12 | x: number; 13 | y: number; 14 | }; 15 | } 16 | export type BrushType = "solid" | "dotted" | "dashed" | "highlighter"; 17 | export interface DrawPadProps { 18 | strokeWidth?: number; 19 | stroke?: string; 20 | pathLength?: SharedValue; 21 | playing?: SharedValue; 22 | signed?: SharedValue; 23 | pathProps?: PathProps; 24 | gradient?: gradientProps; 25 | brushType?: BrushType; 26 | onDrawStart?: () => void; 27 | onDrawEnd?: () => void; 28 | animationDuration?: number; 29 | easingFunction?: EasingFunction; 30 | } 31 | export type DrawPadHandle = { 32 | erase: () => void; 33 | undo: () => void; 34 | play: () => void; 35 | stop: () => void; 36 | getPaths: () => string[]; 37 | setPaths?: (paths: string[]) => void; 38 | addPath?: (path: string) => void; 39 | getSVG?: () => Promise; 40 | }; 41 | declare const DrawPad: React.ForwardRefExoticComponent>; 42 | export declare const buildSVGString: ({ paths, gradient, ...pathProps }: { 43 | paths: String[]; 44 | gradient?: gradientProps; 45 | } & PathProps) => string; 46 | export default DrawPad; 47 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | var __rest = (this && this.__rest) || function (s, e) { 2 | var t = {}; 3 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) 4 | t[p] = s[p]; 5 | if (s != null && typeof Object.getOwnPropertySymbols === "function") 6 | for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { 7 | if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) 8 | t[p[i]] = s[p[i]]; 9 | } 10 | return t; 11 | }; 12 | import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from "react"; 13 | import { Platform, View } from "react-native"; 14 | import Svg, { Defs, G, LinearGradient, Path, Stop, } from "react-native-svg"; 15 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 16 | import Animated, { useSharedValue, useAnimatedProps, interpolate, withTiming, useAnimatedReaction, Easing, Extrapolation, useDerivedValue, } from "react-native-reanimated"; 17 | import { svgPathProperties } from "svg-path-properties"; 18 | import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; 19 | const AnimatedPath = Animated.createAnimatedComponent(Path); 20 | const PATH_PROPS = { 21 | fill: "none", 22 | strokeLinecap: "round", 23 | strokeLinejoin: "round", 24 | }; 25 | const isWeb = Platform.OS === "web"; 26 | const DrawPad = forwardRef(({ strokeWidth = 3.5, stroke = "grey", pathLength: _pathLength, playing, signed, pathProps: _pathProps, gradient, brushType = "solid", onDrawStart, onDrawEnd, animationDuration: _duration, easingFunction = Easing.bezier(0.4, 0, 0.5, 1), }, ref) => { 27 | const [paths, setPaths] = useState([]); 28 | const currentPath = useSharedValue(""); 29 | const progress = useSharedValue(1); 30 | const __pathLength = useSharedValue(0); 31 | const pathLength = _pathLength || __pathLength; 32 | const duration = useDerivedValue(() => { 33 | return _duration || pathLength.value * 2; 34 | }); 35 | const timeoutRef = useRef(null); 36 | const pathProps = Object.assign(Object.assign({}, (_pathProps || {})), PATH_PROPS); 37 | useEffect(() => { 38 | if (pathLength) { 39 | const totalLength = paths.reduce((total, path) => { 40 | return total + new svgPathProperties(path).getTotalLength(); 41 | }, 0); 42 | pathLength.value = totalLength; 43 | } 44 | }, [paths, pathLength]); 45 | const sharedProps = { 46 | strokeWidth, 47 | stroke: gradient ? "url(#strokeGradient)" : stroke, 48 | strokeOpacity: brushType === "highlighter" ? 0.3 : 1, 49 | strokeDasharray: brushType === "dotted" 50 | ? [1, strokeWidth * 2] 51 | : brushType === "dashed" 52 | ? [strokeWidth * 4, strokeWidth * 2] 53 | : "0", 54 | }; 55 | const animatedProps = useAnimatedProps(() => ({ 56 | d: currentPath.value, 57 | })); 58 | const finishPath = () => { 59 | const pathValue = currentPath.value; 60 | if (onDrawEnd) { 61 | scheduleOnRN(onDrawEnd); 62 | } 63 | if (pathValue) { 64 | setPaths((prev) => { 65 | const updatedPaths = [...prev, pathValue]; 66 | setTimeout(() => { 67 | currentPath.value = ""; 68 | }, 0); 69 | return updatedPaths; 70 | }); 71 | } 72 | }; 73 | const handleErase = () => { 74 | setPaths([]); 75 | currentPath.value = ""; 76 | }; 77 | const handleUndo = useCallback(() => { 78 | setPaths((prev) => { 79 | const newPaths = [...prev]; 80 | newPaths.pop(); 81 | return newPaths; 82 | }); 83 | }, []); 84 | const handlePlay = useCallback(() => { 85 | if (playing && pathLength && !playing.value) { 86 | playing.value = true; 87 | timeoutRef.current = setTimeout(() => { 88 | playing.value = false; 89 | }, duration.value); 90 | } 91 | }, [playing, pathLength]); 92 | const handleStop = useCallback(() => { 93 | if (timeoutRef.current) { 94 | clearTimeout(timeoutRef.current); 95 | timeoutRef.current = null; 96 | } 97 | if (playing) { 98 | scheduleOnUI(() => { 99 | playing.value = false; 100 | }); 101 | } 102 | }, [playing]); 103 | const handleSetPaths = (newPaths) => { 104 | setPaths(newPaths); 105 | }; 106 | const handleAddPath = (path) => { 107 | setPaths((prev) => [...prev, path]); 108 | }; 109 | const handleGetSVG = async () => { 110 | const svgString = buildSVGString(Object.assign(Object.assign({ paths, 111 | gradient }, pathProps), sharedProps)); 112 | return svgString; 113 | }; 114 | useImperativeHandle(ref, () => ({ 115 | erase: handleErase, 116 | undo: handleUndo, 117 | play: handlePlay, 118 | stop: handleStop, 119 | getPaths: () => paths, 120 | setPaths: handleSetPaths, 121 | addPath: handleAddPath, 122 | getSVG: handleGetSVG, 123 | })); 124 | const prevX = useSharedValue(0); 125 | const prevY = useSharedValue(0); 126 | const panGesture = Gesture.Pan() 127 | .minDistance(0) 128 | .onStart((e) => { 129 | currentPath.value = `M ${e.x} ${e.y}`; 130 | prevX.value = e.x; 131 | prevY.value = e.y; 132 | if (onDrawStart) { 133 | scheduleOnRN(onDrawStart); 134 | } 135 | }) 136 | .onUpdate((e) => { 137 | const midX = (prevX.value + e.x) / 2; 138 | const midY = (prevY.value + e.y) / 2; 139 | currentPath.value += ` Q ${prevX.value} ${prevY.value} ${midX} ${midY}`; 140 | prevX.value = e.x; 141 | prevY.value = e.y; 142 | }) 143 | .onEnd(() => { 144 | scheduleOnRN(finishPath); 145 | }); 146 | useAnimatedReaction(() => { var _a; return (_a = playing === null || playing === void 0 ? void 0 : playing.value) !== null && _a !== void 0 ? _a : false; }, (isPlaying) => { 147 | if (!playing || !pathLength) 148 | return; 149 | if (isPlaying) { 150 | progress.value = 0; 151 | progress.value = withTiming(1, { 152 | duration: duration.value, 153 | easing: easingFunction, 154 | }); 155 | return; 156 | } 157 | progress.value = withTiming(0, { 158 | duration: (signed === null || signed === void 0 ? void 0 : signed.value) || progress.value > 0.999 159 | ? 1 160 | : progress.value * duration.value, 161 | easing: easingFunction, 162 | }, () => { 163 | progress.value = 1; 164 | }); 165 | }); 166 | return (React.createElement(GestureDetector, { gesture: panGesture }, 167 | React.createElement(View, { style: { flex: 1 } }, 168 | React.createElement(Svg, { height: "100%", width: "100%" }, 169 | gradient && (React.createElement(Defs, null, 170 | React.createElement(LinearGradient, { id: "strokeGradient", x1: "0%", y1: "0%", x2: "100%", y2: "0%" }, gradient.colors.map((color, i) => { 171 | var _a, _b; 172 | return (React.createElement(Stop, { key: i, offset: (_b = (_a = gradient.locations) === null || _a === void 0 ? void 0 : _a[i]) !== null && _b !== void 0 ? _b : i / (gradient.colors.length - 1), stopColor: color })); 173 | })))), 174 | paths.map((p, i) => { 175 | const prevLength = paths.slice(0, i).reduce((total, prevPath) => { 176 | return total + new svgPathProperties(prevPath).getTotalLength(); 177 | }, 0); 178 | return (React.createElement(DrawPath, Object.assign({ key: i, path: p, progress: progress, prevLength: prevLength, totalPathLength: pathLength }, sharedProps, pathProps))); 179 | }), 180 | React.createElement(AnimatedPath, Object.assign({}, pathProps, sharedProps, { animatedProps: animatedProps })))))); 181 | }); 182 | const DrawPath = (_a) => { 183 | var { path, strokeWidth, stroke, progress, prevLength, totalPathLength } = _a, pathProps = __rest(_a, ["path", "strokeWidth", "stroke", "progress", "prevLength", "totalPathLength"]); 184 | const pathRef = useRef(null); 185 | // Adjustment added to account for rendering quirks in strokeDasharray calculations. 186 | const PATH_LENGTH_ADJUSTMENT = 1; 187 | const length = new svgPathProperties(path).getTotalLength() + PATH_LENGTH_ADJUSTMENT; 188 | const { strokeOpacity, strokeDasharray } = pathProps; 189 | const animatedProps = useAnimatedProps(() => { 190 | var _a, _b; 191 | const prev = prevLength !== null && prevLength !== void 0 ? prevLength : 0; 192 | const total = (_a = totalPathLength === null || totalPathLength === void 0 ? void 0 : totalPathLength.value) !== null && _a !== void 0 ? _a : 0; 193 | const start = prev / total; 194 | const end = (prev + length) / total; 195 | const p = (_b = progress === null || progress === void 0 ? void 0 : progress.value) !== null && _b !== void 0 ? _b : 1; 196 | const turn = interpolate(p, [start, end], [0, 1], Extrapolation.CLAMP); 197 | const opacity = p >= start ? 1 : 0; 198 | return { 199 | strokeDashoffset: interpolate(turn, [0, 1], [length, 0]) - 1, 200 | opacity, 201 | }; 202 | }); 203 | const dasharray = Array.isArray(strokeDasharray) 204 | ? strokeDasharray 205 | : undefined; 206 | return (React.createElement(G, null, 207 | React.createElement(Path, Object.assign({ d: path }, pathProps, { strokeWidth: strokeWidth, stroke: stroke, ref: pathRef, strokeOpacity: Number(strokeOpacity) * 0.2 })), 208 | React.createElement(AnimatedPath, Object.assign({ d: path }, pathProps, { strokeWidth: strokeWidth, stroke: stroke, strokeDasharray: dasharray || length, animatedProps: dasharray ? {} : animatedProps })))); 209 | }; 210 | const buildDefsString = (gradient) => { 211 | if (!gradient) 212 | return ""; 213 | const stops = gradient.colors 214 | .map((color, i) => { 215 | var _a, _b; 216 | const offset = (_b = (_a = gradient.locations) === null || _a === void 0 ? void 0 : _a[i]) !== null && _b !== void 0 ? _b : i / (gradient.colors.length - 1); 217 | return ``; 218 | }) 219 | .join("\n"); 220 | return ` 221 | 222 | 223 | ${stops} 224 | 225 | 226 | `; 227 | }; 228 | export const buildSVGString = (_a) => { 229 | var { paths, gradient } = _a, pathProps = __rest(_a, ["paths", "gradient"]); 230 | const defs = buildDefsString(gradient); 231 | const svgPaths = paths 232 | .map((d) => { 233 | const kebabProps = Object.entries(pathProps || {}) 234 | .filter(([_, v]) => v !== undefined) 235 | .map(([key, value]) => { 236 | const kebabKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); 237 | return `${kebabKey}="${String(value)}"`; 238 | }); 239 | return ``; 240 | }) 241 | .join("\n"); 242 | return ` 243 | 244 | ${defs} 245 | ${svgPaths} 246 | 247 | `.trim(); 248 | }; 249 | export default DrawPad; 250 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useCallback, 4 | useEffect, 5 | useImperativeHandle, 6 | useRef, 7 | useState, 8 | } from "react"; 9 | import { Platform, View } from "react-native"; 10 | import Svg, { 11 | Defs, 12 | G, 13 | LinearGradient, 14 | Path, 15 | PathProps, 16 | Stop, 17 | } from "react-native-svg"; 18 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 19 | import Animated, { 20 | useSharedValue, 21 | useAnimatedProps, 22 | interpolate, 23 | withTiming, 24 | SharedValue, 25 | useAnimatedReaction, 26 | Easing, 27 | Extrapolation, 28 | useDerivedValue, 29 | EasingFunction, 30 | } from "react-native-reanimated"; 31 | import { svgPathProperties } from "svg-path-properties"; 32 | import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; 33 | 34 | const AnimatedPath = Animated.createAnimatedComponent(Path); 35 | 36 | const PATH_PROPS: PathProps = { 37 | fill: "none", 38 | strokeLinecap: "round", 39 | strokeLinejoin: "round", 40 | }; 41 | 42 | export interface gradientProps { 43 | colors: string[]; 44 | locations?: number[]; 45 | start?: { x: number; y: number }; 46 | end?: { x: number; y: number }; 47 | } 48 | 49 | export type BrushType = "solid" | "dotted" | "dashed" | "highlighter"; 50 | 51 | export interface DrawPadProps { 52 | strokeWidth?: number; 53 | stroke?: string; 54 | pathLength?: SharedValue; 55 | playing?: SharedValue; 56 | signed?: SharedValue; 57 | pathProps?: PathProps; 58 | gradient?: gradientProps; 59 | brushType?: BrushType; 60 | onDrawStart?: () => void; 61 | onDrawEnd?: () => void; 62 | animationDuration?: number; 63 | easingFunction?: EasingFunction; 64 | } 65 | 66 | export type DrawPadHandle = { 67 | erase: () => void; 68 | undo: () => void; 69 | play: () => void; 70 | stop: () => void; 71 | getPaths: () => string[]; 72 | setPaths?: (paths: string[]) => void; 73 | addPath?: (path: string) => void; 74 | getSVG?: () => Promise; 75 | }; 76 | 77 | const isWeb = Platform.OS === "web"; 78 | 79 | const DrawPad = forwardRef( 80 | ( 81 | { 82 | strokeWidth = 3.5, 83 | stroke = "grey", 84 | pathLength: _pathLength, 85 | playing, 86 | signed, 87 | pathProps: _pathProps, 88 | gradient, 89 | brushType = "solid", 90 | onDrawStart, 91 | onDrawEnd, 92 | animationDuration: _duration, 93 | easingFunction = Easing.bezier(0.4, 0, 0.5, 1), 94 | }, 95 | ref 96 | ) => { 97 | const [paths, setPaths] = useState([]); 98 | const currentPath = useSharedValue(""); 99 | const progress = useSharedValue(1); 100 | const __pathLength = useSharedValue(0); 101 | const pathLength = _pathLength || __pathLength; 102 | 103 | const duration = useDerivedValue(() => { 104 | return _duration || pathLength.value * 2; 105 | }); 106 | 107 | const timeoutRef = useRef | null>(null); 108 | 109 | const pathProps = { 110 | ...(_pathProps || {}), 111 | ...PATH_PROPS, 112 | }; 113 | 114 | useEffect(() => { 115 | if (pathLength) { 116 | const totalLength = paths.reduce((total, path) => { 117 | return total + new svgPathProperties(path).getTotalLength(); 118 | }, 0); 119 | pathLength.value = totalLength; 120 | } 121 | }, [paths, pathLength]); 122 | 123 | const sharedProps: PathProps = { 124 | strokeWidth, 125 | stroke: gradient ? "url(#strokeGradient)" : stroke, 126 | strokeOpacity: brushType === "highlighter" ? 0.3 : 1, 127 | strokeDasharray: 128 | brushType === "dotted" 129 | ? [1, strokeWidth * 2] 130 | : brushType === "dashed" 131 | ? [strokeWidth * 4, strokeWidth * 2] 132 | : "0", 133 | }; 134 | 135 | const animatedProps = useAnimatedProps(() => ({ 136 | d: currentPath.value, 137 | })); 138 | 139 | const finishPath = () => { 140 | const pathValue = currentPath.value; 141 | if (onDrawEnd) { 142 | scheduleOnRN(onDrawEnd); 143 | } 144 | if (pathValue) { 145 | setPaths((prev) => { 146 | const updatedPaths = [...prev, pathValue]; 147 | setTimeout(() => { 148 | currentPath.value = ""; 149 | }, 0); 150 | return updatedPaths; 151 | }); 152 | } 153 | }; 154 | 155 | const handleErase = () => { 156 | setPaths([]); 157 | currentPath.value = ""; 158 | }; 159 | 160 | const handleUndo = useCallback(() => { 161 | setPaths((prev) => { 162 | const newPaths = [...prev]; 163 | newPaths.pop(); 164 | return newPaths; 165 | }); 166 | }, []); 167 | 168 | const handlePlay = useCallback(() => { 169 | if (playing && pathLength && !playing.value) { 170 | playing.value = true; 171 | timeoutRef.current = setTimeout(() => { 172 | playing.value = false; 173 | }, duration.value); 174 | } 175 | }, [playing, pathLength]); 176 | 177 | const handleStop = useCallback(() => { 178 | if (timeoutRef.current) { 179 | clearTimeout(timeoutRef.current); 180 | timeoutRef.current = null; 181 | } 182 | if (playing) { 183 | scheduleOnUI(() => { 184 | playing.value = false; 185 | }); 186 | } 187 | }, [playing]); 188 | 189 | const handleSetPaths = (newPaths: string[]) => { 190 | setPaths(newPaths); 191 | }; 192 | 193 | const handleAddPath = (path: string) => { 194 | setPaths((prev) => [...prev, path]); 195 | }; 196 | 197 | const handleGetSVG = async (): Promise => { 198 | const svgString = buildSVGString({ 199 | paths, 200 | gradient, 201 | ...pathProps, 202 | ...sharedProps, 203 | }); 204 | return svgString; 205 | }; 206 | 207 | useImperativeHandle(ref, () => ({ 208 | erase: handleErase, 209 | undo: handleUndo, 210 | play: handlePlay, 211 | stop: handleStop, 212 | getPaths: () => paths, 213 | setPaths: handleSetPaths, 214 | addPath: handleAddPath, 215 | getSVG: handleGetSVG, 216 | })); 217 | 218 | const prevX = useSharedValue(0); 219 | const prevY = useSharedValue(0); 220 | 221 | const panGesture = Gesture.Pan() 222 | .minDistance(0) 223 | .onStart((e) => { 224 | currentPath.value = `M ${e.x} ${e.y}`; 225 | prevX.value = e.x; 226 | prevY.value = e.y; 227 | if (onDrawStart) { 228 | scheduleOnRN(onDrawStart); 229 | } 230 | }) 231 | .onUpdate((e) => { 232 | const midX = (prevX.value + e.x) / 2; 233 | const midY = (prevY.value + e.y) / 2; 234 | currentPath.value += ` Q ${prevX.value} ${prevY.value} ${midX} ${midY}`; 235 | prevX.value = e.x; 236 | prevY.value = e.y; 237 | }) 238 | .onEnd(() => { 239 | scheduleOnRN(finishPath); 240 | }); 241 | 242 | useAnimatedReaction( 243 | () => playing?.value ?? false, 244 | (isPlaying) => { 245 | if (!playing || !pathLength) return; 246 | 247 | if (isPlaying) { 248 | progress.value = 0; 249 | progress.value = withTiming(1, { 250 | duration: duration.value, 251 | easing: easingFunction, 252 | }); 253 | return; 254 | } 255 | 256 | progress.value = withTiming( 257 | 0, 258 | { 259 | duration: 260 | signed?.value || progress.value > 0.999 261 | ? 1 262 | : progress.value * duration.value, 263 | easing: easingFunction, 264 | }, 265 | () => { 266 | progress.value = 1; 267 | } 268 | ); 269 | } 270 | ); 271 | 272 | return ( 273 | 274 | 275 | 276 | {gradient && ( 277 | 278 | 285 | {gradient.colors.map((color, i) => ( 286 | 294 | ))} 295 | 296 | 297 | )} 298 | {paths.map((p, i) => { 299 | const prevLength = paths.slice(0, i).reduce((total, prevPath) => { 300 | return total + new svgPathProperties(prevPath).getTotalLength(); 301 | }, 0); 302 | 303 | return ( 304 | 313 | ); 314 | })} 315 | 320 | 321 | 322 | 323 | ); 324 | } 325 | ); 326 | 327 | const DrawPath = ({ 328 | path, 329 | strokeWidth, 330 | stroke, 331 | progress, 332 | prevLength, 333 | totalPathLength, 334 | ...pathProps 335 | }: { 336 | path: string; 337 | prevLength?: number; 338 | progress?: SharedValue; 339 | totalPathLength?: SharedValue; 340 | } & Omit) => { 341 | const pathRef = useRef(null); 342 | // Adjustment added to account for rendering quirks in strokeDasharray calculations. 343 | const PATH_LENGTH_ADJUSTMENT = 1; 344 | const length = 345 | new svgPathProperties(path).getTotalLength() + PATH_LENGTH_ADJUSTMENT; 346 | 347 | const { strokeOpacity, strokeDasharray } = pathProps; 348 | 349 | const animatedProps = useAnimatedProps(() => { 350 | const prev = prevLength ?? 0; 351 | const total = totalPathLength?.value ?? 0; 352 | 353 | const start = prev / total; 354 | const end = (prev + length) / total; 355 | const p = progress?.value ?? 1; 356 | 357 | const turn = interpolate(p, [start, end], [0, 1], Extrapolation.CLAMP); 358 | const opacity = p >= start ? 1 : 0; 359 | 360 | return { 361 | strokeDashoffset: interpolate(turn, [0, 1], [length, 0]) - 1, 362 | opacity, 363 | }; 364 | }); 365 | 366 | const dasharray = Array.isArray(strokeDasharray) 367 | ? strokeDasharray 368 | : undefined; 369 | 370 | return ( 371 | 372 | 380 | 388 | 389 | ); 390 | }; 391 | 392 | const buildDefsString = (gradient?: gradientProps): string => { 393 | if (!gradient) return ""; 394 | 395 | const stops = gradient.colors 396 | .map((color, i) => { 397 | const offset = 398 | gradient.locations?.[i] ?? i / (gradient.colors.length - 1); 399 | return ``; 400 | }) 401 | .join("\n"); 402 | 403 | return ` 404 | 405 | 406 | ${stops} 407 | 408 | 409 | `; 410 | }; 411 | 412 | export const buildSVGString = ({ 413 | paths, 414 | gradient, 415 | ...pathProps 416 | }: { 417 | paths: String[]; 418 | gradient?: gradientProps; 419 | } & PathProps) => { 420 | const defs = buildDefsString(gradient); 421 | 422 | const svgPaths = paths 423 | .map((d) => { 424 | const kebabProps = Object.entries(pathProps || {}) 425 | .filter(([_, v]) => v !== undefined) 426 | .map(([key, value]) => { 427 | const kebabKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); 428 | return `${kebabKey}="${String(value)}"`; 429 | }); 430 | 431 | return ``; 432 | }) 433 | .join("\n"); 434 | 435 | return ` 436 | 437 | ${defs} 438 | ${svgPaths} 439 | 440 | `.trim(); 441 | }; 442 | 443 | export default DrawPad; 444 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # expo-drawpad 2 | 3 | [![npm version](https://badge.fury.io/js/expo-drawpad.svg)](https://badge.fury.io/js/expo-drawpad) 4 | [![npm downloads](https://img.shields.io/npm/dm/expo-drawpad.svg)](https://www.npmjs.com/package/expo-drawpad) 5 | [![license](https://img.shields.io/npm/l/expo-drawpad.svg)](https://github.com/solarinjohnson/expo-drawpad/blob/main/LICENSE) 6 | 7 | A smooth, animated drawing pad component for React Native and Expo applications. Perfect for signatures, sketches, and interactive drawing experiences. 8 | 9 | ## Features 10 | 11 | - ✨ **Smooth Drawing**: Responsive touch gestures with customizable stroke width and color 12 | - 🎬 **Path Animation**: Replay drawings with smooth animations 13 | - 🎨 **Multiple Brush Types**: Solid, dotted, dashed, and highlighter brush styles 14 | - 🌈 **Gradient Support**: Apply beautiful gradients to your strokes 15 | - ↩️ **Undo/Redo**: Built-in undo functionality for better user experience 16 | - 🧹 **Clear Canvas**: Easy erase functionality to start fresh 17 | - 📱 **Cross-Platform**: Works on iOS, Android, and Web 18 | - 🎯 **TypeScript**: Full TypeScript support with type definitions 19 | - 🪶 **Lightweight**: Minimal dependencies and optimized performance 20 | - 📁 **Path Management**: Get, set, and add paths programmatically 21 | - 📄 **SVG Export**: Export drawings as SVG strings 22 | - 🎛️ **Customizable Animation**: Control animation duration and easing 23 | - 🎪 **Event Callbacks**: Handle draw start and end events 24 | 25 | ## Installation 26 | 27 | ```bash 28 | npm install expo-drawpad 29 | ``` 30 | 31 | ### Peer Dependencies 32 | 33 | Make sure you have these peer dependencies installed: 34 | 35 | ```bash 36 | npm install react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets 37 | ``` 38 | 39 | For Expo projects, you can install them with: 40 | 41 | ```bash 42 | npx expo install react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets 43 | ``` 44 | 45 | ## Quick Start 46 | 47 | ### Minimal Example 48 | 49 | ```tsx 50 | import React, { useRef } from "react"; 51 | import { View, Button } from "react-native"; 52 | import DrawPad, { DrawPadHandle } from "expo-drawpad"; 53 | 54 | export default function App() { 55 | const drawPadRef = useRef(null); 56 | 57 | return ( 58 | 59 | 60 | 61 | 62 | 63 | 64 |