noehassiel/signature-pad#
Native signature capture for NativePHP Mobile v4. Ships a new EDGE element,
<native:signature-pad>, that renders a freehand drawing surface and hands PHP the drawing as a
base64-encoded PNG data URL.
Why this exists#
EDGE cannot capture a signature. <native:canvas> is a declarative container for shape
primitives — rects, circles, lines — with no touch-path capture and no raster export, and there is
no drawing element in core or in any published plugin. That makes a signature a genuinely new
element type, which is exactly what the UI component plugin
mechanism is for. (The NativePHP docs use "a signature pad" as their own example of the case.)
The wire format is a plain data:image/png;base64,... string — the same thing a web <canvas>
signature pad posts — so an existing server endpoint that already accepts signatures from a web
form needs no changes to accept one from a phone.
Installation#
From Packagist:
composer require noehassiel/signature-pad
Or as a local path package while developing:
"repositories": [ { "type": "path", "url": "./packages/noehassiel/signature-pad" }]
Then, either way:
php artisan vendor:publish --tag=nativephp-plugins-provider # once, before the first pluginphp artisan native:plugin:register noehassiel/signature-padphp artisan native:plugin:list # verify it appearsphp artisan native:plugin:validate # verify the manifest
Then rebuild — php artisan native:run ios or android. The native renderers are copied in
and registered by generated code at build time, so a newly added component needs a build, not just
a composer update. Adding the plugin and only restarting PHP gives you an element that serializes
fine and renders nothing.
Requirements#
nativephp/mobile^4.0- Android: minSdk 26. No permissions.
- iOS: 16.0. No Info.plist entries.
No permissions of any kind — it draws on a surface the app already owns.
Usage#
<native:signature-pad ref="inspection-item-{{ $item['id'] }}" _change="signatureChanged" :clear-token="$clearToken" :value="$existingSignature" pen-color="#111827" :stroke-width="2.5" :read-only="$accepted" class="w-full h-40 rounded-xl border border-theme-outline bg-theme-surface" />
class InspectionScreen extends NativeComponent{ public string $signature = ''; public int $clearToken = 0; /** Fires on stroke end with the whole drawing so far. */ public function signatureChanged(string $dataUrl): void { $this->signature = $dataUrl; } public function clearSignature(): void { $this->clearToken++; // any change wipes the pad $this->signature = ''; } public function save(): void { // A plain base64 PNG — post it wherever your web signature pad posts. Api::post("inspections/{$id}/items/{$itemId}/response", [ 'value' => ['value' => 'signed'], 'signature' => $this->signature, ]); }}
Attributes#
| Attribute | Type | Default | Notes |
|---|---|---|---|
ref |
string | node id | Stable id, required if you use capture() |
_change |
method | — | Called on stroke end with the full drawing as a data URL |
value |
string|null | — | An existing signature to display |
pen-color |
string | #111827 |
Any color the Tailwind parser accepts |
stroke-width |
float | 2.5 |
dp / points |
read-only |
bool | false |
Shows ink, ignores touches |
clear-token |
int|string | — | Change it to wipe the pad |
undo-token |
int|string | — | Change it to drop the last stroke |
Tailwind classes on the tag are parsed by the same parser core elements use, so sizing, radius, and background need nothing from this plugin.
Emit, don't poll#
_change fires on stroke end carrying the entire drawing, and that is the path to use: the screen
holds the latest PNG, so a save button reads state it already has. This matters for offline work —
by the time a queued write drains, the pad may be long gone, so the image has to be in the payload,
not fetched later.
SignaturePad::capture() exists for the case where the control that needs the signature cannot see
the pad's state (a shell-level action bar, or a sheet owning the button while a child owns the pad).
Reach for it second.
use Noehassiel\SignaturePad\Facades\SignaturePad;use Noehassiel\SignaturePad\Events\SignatureCaptured;use Native\Mobile\Attributes\On; SignaturePad::capture('inspection-item-9'); #[On(SignatureCaptured::class)]public function signatureReady(string $ref, string $signature, bool $isEmpty): void{ if ($isEmpty) { return; // an untouched pad still exports a valid blank PNG } $this->signature = $signature;}
Usage (JavaScript / Inertia)#
The element itself is native and cannot be drawn into from a SPA frontend — what the JS module exposes is the bridge function, for pulling the drawing out of a pad a native screen is showing.
import { capture, Events } from 'noehassiel-signature-pad'; await capture('inspection-item-9'); // The drawing arrives as an event, not a return value.on(Events.SignatureCaptured, ({ ref, signature, isEmpty }) => { if (! isEmpty) { upload(signature); // data:image/png;base64,... }});
Clearing is a token, not a call#
PHP has no imperative channel into a mounted view, so clear-token / undo-token are props the
renderer watches: change the value and the pad reacts. It survives re-renders, needs no view
handle, and cannot get out of sync with component state.
Events#
| Event | Payload | When |
|---|---|---|
SignatureCaptured |
ref, signature, isEmpty |
Reply to capture() |
SignatureCaptureFailed |
ref, reason |
capture() named a ref no mounted pad claims — usually the sheet closed |
Stroke updates do not go through an event; they use the element's _change callback.
Always check isEmpty. A pad nobody touched still exports a valid white PNG, so a non-empty
string does not mean somebody signed.
Implementation notes#
- iOS is not PencilKit.
PKCanvasViewbrings a tool picker, ruler, and its own undo stack, its ink is pressure- and tilt-reactive in ways that make a finger signature look nothing like a pen one, and flattening aPKDrawingto PNG means fighting its image pipeline. A signature is one pen, one colour, one flat raster — aPathover aDragGestureis less code and closer to what the web pad produces. - Exports are white-backed, not transparent. Signatures get stamped onto reports, and transparent ink becomes black-on-black in a dark PDF viewer.
- Export runs on stroke end, never on move. A signature is hundreds of move events; one PNG each would flood the bridge.
- Fixed 2× export scale so a phone and a tablet post comparable images.
- A tap with no drag still leaves a dot — one-point strokes are drawn as circles, on screen and in the export.
NativeUINode.idis anInt, not a string — found by an actual build failure (cannot convert value of type 'Int' to expected argument type 'String'). Any fallback that uses it as a ref/key needsString(node.id)(Swift) /node.id.toString()(Kotlin).getCallbackId()andgetColor()return non-optionalInt, notInt?/a color object —0is the "absent" sentinel for a callback id, and the color is a packed ARGBIntyou unpack yourself (UIColor(argb:)on iOS,Color(Int)on Android, which accepts one directly).if let cb = onChangeCbdoesn't compile against a non-optional Int; checkonChangeCb != 0instead — same reason Kotlin needs!= 0, not!= null.sendTextChangeEvent's third parameter is labeledtext:, notvalue:, on both platforms.
Testing#
The PHP half — manifest, element registration, props, callback ids — is straightforward to cover with NativePHP's component testing: render a screen containing the element and assert the published node.
Native::test(SignatureScreen::class) ->assertElement('signature_pad', fn (array $node) => is_int($node['props']['on_change'] ?? null));
The renderers themselves are native and need a real device — a simulator will draw, but confirm export size and stroke-end latency on hardware.
License#
Proprietary. All rights reserved © noehassiel. See LICENSE. Redistribution, resale, or republishing of this package — in source or compiled form — is not permitted without prior written permission from the copyright holder.