NativePHP Skia#
A native Skia canvas for NativePHP Mobile, with a PHP scene API, a Blade element, and the same C++ scene renderer on iOS and Android. iOS draws directly into a MetalKit surface using Skia Ganesh/Metal. Android uses Ganesh/OpenGL ES 3 on a dedicated render thread, presented through TextureView. There is no WebView, JavaScript runtime, or per-frame PHP callback.
Install in a consuming app#
Requires PHP 8.3+ and NativePHP Mobile 4.5.1+. Install from your application directory using the Composer repository supplied with your plugin purchase:
composer require nativephp/mobile-skia
The native build hook downloads approximately 162 MiB of pinned Skia M139 binaries
and headers from @shopify/[email protected] on the first build, verifies
SHA-512 before extraction, and caches the payload in .deps. Subsequent builds
reuse a complete cache. No React Native runtime or npm scripts are installed.
Keep python3 available on the build host (macOS or Linux).
For an offline build, download the exact pinned archive ahead of time and set
NATIVEPHP_SKIA_ARCHIVE=/absolute/path/react-native-skia-2.2.12.tgz before building.
The same checksum check applies. To prewarm or repair the cache explicitly:
python3 vendor/nativephp/mobile-skia/scripts/bootstrap.pypython3 vendor/nativephp/mobile-skia/scripts/bootstrap.py --check# Only to replace a damaged cache:python3 vendor/nativephp/mobile-skia/scripts/bootstrap.py --force
Never run Composer inside the plugin directory. For local development, a Composer path repository can point to this checkout; install it from the consuming app.
Add Native\Mobile\Skia\SkiaServiceProvider::class to the consuming app's
NativeServiceProvider::plugins() list. Rebuild the native shell; hot reload
alone cannot introduce the native engine.
Build requirements: Python 3, C++17, CocoaPods/Xcode for iOS, and Android SDK 36,
NDK and CMake 3.22.1 for Android. The plugin hook adds a local CocoaPod to both
iOS targets and a separate Android library module. Existing app CMake targets
are preserved. Use the PHP major version pinned by the app's nativephp.lock.
A drawing in PHP and Blade#
use Native\Mobile\Skia\{Scene, Node, Paint, Path, Animation}; $scene = Scene::make(360, 240) ->background('#101624') ->add( Node::rect(20, 20, 320, 200, Paint::fill()->linearGradient(20, 20, 340, 220, ['#a899ff', '#83e2be']), radius: 24), Node::text('Hello, Skia.', 45, 135, 32, Paint::fill('#101624')), ); return view('native.my-screen', compact('scene'));
<skia-canvas :scene="$scene" class="w-full h-64" fit="contain" canvas-id="hero" a11y-label="Colorful greeting" />
<native:skia-canvas> also works. The wire type is skia_canvas. A scene uses
logical coordinates; contain centers and letterboxes, cover crops, and fill
stretches. Set an explicit element height. Give the canvas an accessible label.
Use Node::accessible() for meaningful per-shape labels and actions; decorative
shapes do not become accessibility elements automatically. Provide native controls
for essential actions that otherwise require dragging.
The fluent element is Native\Mobile\Skia\Elements\Canvas::make($scene).
It supports fit(), animated(), interactive(), canvasId(), and the standard
EDGE layout/press APIs. Replacing the Scene republishes its content. Nodes and
paint are snapshotted when added; later edits to the original objects do not
mutate an already-built scene.
Drawing features#
| API | Behavior |
|---|---|
Node::rect(x, y, width, height, paint, radius) |
Filled or stroked rectangles and rounded rectangles |
Node::circle(cx, cy, radius, paint) / oval(...) |
Circles and ellipses |
Node::line(x1, y1, x2, y2, paint) |
Lines with caps, joins and dashes |
| `Node::path(Path | string, paint, fillRule)` |
Node::text(text, x, y, size, paint, font) |
Baseline-positioned, unshaped text |
Node::paragraph(text, x, y, width, ...) |
SkParagraph shaping, wrapping, alignment, direction, max lines and ellipsis |
Node::image(assetId, x, y, width, height, fit, paint) |
Decoded embedded images with contain/cover/fill |
Node::group(children, layerPaint) |
Nested groups and optional isolated compositing layers |
node->transform(x, y, rotation, scaleX, scaleY) |
Translation, rotation in degrees, then scale, about the local origin |
| `node->clip(Path | string, fillRule)` |
node->animate(property, Animation) |
Native-clock transform and opacity animation |
Path::make() supports moveTo, lineTo, quadTo, cubicTo, SVG arcTo, and
close. SVG path data is supported by Node::path; embed full SVG documents with Node::svg.
Paint::fill('#RRGGBB') and Paint::stroke('#RRGGBBAA', width) support:
- Opacity, round/butt/square caps, round/bevel/miter joins and miter limits.
- Positive, even-length dash intervals and a dash phase.
- Linear/radial/sweep gradients with optional ordered stops and clamp/repeat/mirror/decal tiling.
- Gaussian blur (sigma), drop shadow and a 4×5 row-major RGBA color matrix.
- Blend modes: srcOver, src, dstOver, srcIn, dstIn, srcOut, dstOut, srcATop, dstATop, xor, plus, multiply, screen, overlay, darken, lighten, difference, exclusion, clear.
- SkSL runtime shaders via
runtimeShader(source, uniforms, timeUniform: 'time'). Uniforms are scalar or array float values; supply every declared uniform. Shaders compile on scene replacement. An optionaltimeUniformnames a declared scalar float that receives elapsed seconds from the native animation clock; omit it fromuniforms. Setanimatedon the canvas to advance it. Pause and Reduce Motion apply to the clock. Animated shaders work on shapes, images and baseline text; paragraph and group-layer paints reject them. Integer uniforms and child shaders are not exposed. Runtime shaders execute on the native GPU.
Use a layer paint on Node::group() when opacity or filters must apply to the
composite rather than each shape independently. Blur is composed before shadow.
Color matrix filtering is applied as a Skia paint color filter. Coordinates and
filter radii use scene units and scale with the canvas.
Images and fonts#
$scene->asset('photo', file_get_contents(resource_path('images/photo.png')));$scene->asset('inter', file_get_contents(resource_path('fonts/Inter.ttf')), 'font');$scene->add(Node::image('photo', 0, 0, 360, 200, 'cover'));$scene->add(Node::paragraph('Wrapped text', 24, 220, 310, 18, Paint::fill('#ffffff'), font: 'inter', align: 'left', direction: 'ltr'));
Encoded PNG/JPEG/WebP support comes from the pinned Skia build. Images and fonts
are embedded as base64 and decoded when a scene changes. There is no native
URL fetch. Use TTF/OTF assets for consistent typography; system font fallback
can differ across platforms. Use paragraph() for multilingual shaping and
wrapping; text() does not provide those features. Apple and Android use the
Unicode backends shipped by the pinned upstream build, so complex-script
parity must be checked with your actual fonts and text.
Animation#
$scene->add(Node::circle(0, 0, 20, Paint::fill('#83e2be')) ->transform(180, 120) ->animate('scaleX', Animation::between(0.7, 1.3, 1.5, 'easeInOut', 'mirror')) ->animate('scaleY', Animation::between(0.7, 1.3, 1.5, 'easeInOut', 'mirror')));
Set animated on the canvas. Transform properties include translateX, translateY, rotation,
scaleX, scaleY and opacity. Supported geometry properties depend on the node type;
see Physics and paths below. Repeat: once/loop/mirror. Easing: linear/easeIn/easeOut/
easeInOut. Animated values replace the corresponding static transform value.
All animations in a canvas share an elapsed clock. The clock survives scene
updates and pauses when playback stops, the view detaches, or the app backgrounds.
Reduced-motion/disabled-animation settings suppress continuous playback.
Without animated, the scene renders at its current frozen time (initially zero).
No fixed frame-rate or throughput guarantee is made; profile on target devices.
Touch and errors#
<skia-canvas :scene="$scene" interactive canvas-id="chart" class="w-full h-80" />
use Native\Mobile\Attributes\On;use Native\Mobile\Skia\Events\{CanvasTouched, CanvasFailed}; #[On(CanvasTouched::class)]public function touched(string $canvasId, float $x, float $y): void{ // Scene coordinates on touch release, with letterbox hits excluded.} #[On(CanvasFailed::class)]public function failed(string $canvasId, string $message): void{ // SkSL, path, decoding or native rendering diagnostic.}
Payloads also contain nodeId. These are ordinary EDGE component events.
@press is supported for a whole-canvas action. Touch events include the topmost hit shape ID. For native dragging, multi-touch
and pointer capture, use the gesture and interaction APIs below. Use a unique
canvas ID to identify events. Native failures emit CanvasFailed (iOS also displays an error label); a failed scene does not replace the last valid
engine scene. Avoid responding to every error by generating another invalid scene.
Large scenes and bounds#
The EDGE node property block is limited to 65,535 bytes. JSON up to 48,000 bytes
travels inline. Larger scenes are atomically written to
storage/framework/native-skia/<sha256>.json; the bridge carries scene_path.
Native code reads and compiles that file once when its identity changes. The
cache is derived data, is touched on reuse, and prunes old entries over 128 MiB,
protecting files published within the last two minutes. Republish if an external
cache cleanup removes a scene file. This transport is for an on-device PHP
runtime sharing the app sandbox; it is not a remote rendering transport.
Bounds: 8 MiB serialized scene, 10,000 nodes, 32 nested groups, 4 MiB per encoded asset, 256 assets, 16 megapixels of decoded image assets, and 16 megapixels per canvas framebuffer (maximum dimension 8192 pixels). PHP and native code validate inputs. Keep large canvases and filters modest on memory-constrained devices.
SVG, Lottie and rich text#
Embed SVG or Lottie JSON as a named scene asset, then draw it with Node::svg
or Node::lottie. Documents are parsed once per scene replacement. Lottie uses
the native clock: enable animated and playback respects pause and reduced motion.
SVG animation/SMIL, Lottie audio and expressions are not executed.
Paragraphs support rich spans, letter spacing, line height, weight, slant and
text decoration. Use an embedded font for predictable layout. Baseline text()
supports font selection, size and paint; paragraph-only styles are rejected there.
Shared values and gestures#
$scene = Scene::make(360, 300) ->value('x', 180)->value('y', 150)->value('zoom', 1)->value('angle', 0) ->gestures(panX: 'x', panY: 'y', scale: 'zoom', rotation: 'angle') ->add(Node::circle(0, 0, 40, Paint::fill('#83e2be'))->id('planet') ->bind('translateX', 'x')->bind('translateY', 'y') ->bind('scaleX', 'zoom')->bind('scaleY', 'zoom')->bind('rotation', 'angle'));
Shared values can receive an Animation as their third argument. Bind them to
transforms, opacity or scalar float shader uniforms through Paint::bindUniform.
All bindings use one native clock. Gestures update values natively; PHP receives
start/end/cancel notifications, not frame updates. Unchanged definitions preserve
gesture state across PHP rerenders; changing or removing a definition resets it.
Pinch zoom is bounded to 0.1–10. Gesture canvases consume scrolling within their bounds.
CanvasTouched includes shapeId. Hit testing selects the topmost geometric shape,
respecting group transforms, clips, strokes and opacity. Give a group an ID to
select unnamed children through that parent. Text and media use layout bounds;
image alpha, blur/shadow extents and shader alpha are not pixel-tested. Shape IDs
must be unique. CanvasGesture includes canvasId, nodeId, gesture and phase.
Android combines multitouch updates as transform; iOS reports pan, pinch or rotation.
Live canvas controls#
Give <skia-canvas> a stable canvas-id="editor". Controls target a mounted canvas
and execute on its owning render thread. Off-device calls and unknown/detached
canvases throw an error.
use Native\Mobile\Skia\Canvas; Canvas::values('editor', ['x' => 180]);Canvas::animate('editor', 'x', Animation::spring(180, 280));Canvas::update('editor', Node::circle(0, 0, 40, Paint::fill('#a8ffe1')) ->id('ball')->bind('translateX', 'x'));$state = Canvas::state('editor');
update atomically replaces complete identified nodes; it is not a partial JSON
merge. Invalid updates leave the previous scene intact. State includes values,
resource budgets and recorded ink points. Updates invalidate affected cached drawings.
Physics, paths and filters#
Animation::spring, decay and sequence run on the native clock. Geometry can
animate alongside transforms, including circle radius, rectangle dimensions,
stroke width, path trim and compatible morphs. Unsupported property/node
combinations reject at serialization and native compilation. Morph endpoints
must have matching verbs.
$path = Node::path('M0 0 L80 0 L80 80 Z', Paint::fill('#a8ffe1')) ->morphTo('M0 0 L100 0 L50 100 Z') ->animate('morph', Animation::between(0, 1, 2, repeat: 'mirror'));
Node::combine($left, $right, 'difference', $paint) supports union, difference,
intersect, xor and reverseDifference. Image shaders, triangle meshes and sprite
atlas batches are supported through Paint::imageShader, Node::mesh and Node::sprites.
use Native\Mobile\Skia\Filter; $paint = Paint::fill('#a8ffe1')->filters( Filter::blur(3), Filter::offset(8, 2), Filter::shadow(0, 6, 8, '#00000066'),);$lettering = Node::textOnPath('Follow your own curve', 'M20 160 C80 20 280 20 340 160', size: 20, paint: $paint);
Filters run in declaration order, with at most 16 per chain. Matrix filters take 20 numbers; morphology supports dilate and erode. A runtime shader, gradient or image shader replaces the previous shader on that paint. Text on a path follows the first contour and requires outline glyphs; color-only emoji are unsupported.
Per-shape interaction, ink and accessibility#
$scene = Scene::make(600, 440)->value('x', 150)->value('y', 150)->add( Node::circle(0, 0, 50, Paint::fill('#a8ffe1'))->id('ball') ->bind('translateX', 'x')->bind('translateY', 'y') ->draggable('x', 'y', snap: 10, inertia: true) ->dragLimits(50, 550, 50, 390) ->accessible('Move the ball', 'button', 'Drag to arrange', ['reset']));
The topmost interactive shape captures a pointer until release/cancel, even beyond its original bounds. Different pointers can capture different shapes (up to ten). Captured strokes take precedence over enclosing scrolling. Finger pressure defaults to one when unavailable; stylus pressure is used when supplied by the platform.
$paper = Node::rect(24, 70, 552, 275, Paint::fill('#fbf7ef')) ->id('paper')->ink('#5f44b3', width: 9, maxPoints: 8192);Canvas::inkStyle('editor', 'paper', '#dd5947', 7);Canvas::undoInk('editor', 'paper');Canvas::clearInk('editor', 'paper');
Ink retains independent stroke colors; cancellation rolls back the current stroke.
CanvasPointer reports down/up/cancel with shape ID, local coordinates and pressure;
moves stay native. accessible() creates native accessibility elements with
transformed/clipped bounds. Handle CanvasAction in your NativeComponent for
activation and custom actions so essential controls work without dragging.
Export#
use Native\Mobile\Skia\Snapshot; $result = Snapshot::save($scene, format: 'png', width: 1080, height: 900, seconds: 1);$result = Snapshot::capture('editor', format: 'pdf', width: 1200, height: 880);
save exports a supplied scene at a chosen time; capture includes the mounted
canvas's live values, patches, animation and ink. Results contain success, path,
bytes, format, width and height. PNG, JPEG, WebP and hybrid vector PDF are supported.
Files receive unique names in app-private SkiaExports; callers own retention/deletion.
PDF keeps ordinary geometry and text outlines as vectors. Gradients, shaders, filters, images, paragraphs, SVG/Lottie and meshes use transparent raster tiles; destination-dependent blend modes flatten the page. Text is outlined, not selectable. Color glyphs fall back to raster tiles. JPEG flattens transparency on white; PNG/WebP preserve alpha. Quality applies to JPEG/WebP and the flattened PDF fallback. PDF effect tiles are capped at four megapixels each and 64 megapixels total.
Static drawing cache#
Node::group($shapes)->cache() records static descendants in an SkPicture. Its
outer transform may animate or bind to a shared value. Animated or interactive
descendants are rejected when initially cached. Patches invalidate recordings
and disable caching when they introduce dynamic children. Images and typefaces
remain scene-owned. The 3,000-shape cache chapter and 900-particle example demonstrate
recorded drawing separately from asset decoding caches.
Testing your app without a device#
The plugin automatically registers testing macros when Laravel is running tests.
They extend NativePHP's Native::test() / Native::visit() and Native::fakeBridge()
APIs. Use a NativePHP core with macro-enabled TestableComponent and FakeBridge
(the release checks exercise core 4.6.0). Older cores can still render Skia but
will not expose these helpers if their testing extension points are missing.
PHPUnit/Pest belongs in your application's development dependencies, not the
production plugin dependencies. Fakes are enabled only when your test requests one.
Give the canvas a stable canvas-id and identify shapes with Node::id():
use Native\Mobile\Testing\Native; Native::test(MyCanvasScreen::class) ->assertSkiaCanvas('editor', fn (array $props) => $props['animated'] === true) ->assertSkiaNode('editor', 'planet', fn (array $node) => $node['type'] === 'circle') ->assertSkiaPaint('editor', 'planet', fn (array $paint) => $paint['color'] === '#abcdef') ->assertSkiaAnimation('editor', 'planet', 'opacity') ->touchSkia('editor', 120, 80, 'planet') ->assertSet('selectedShape', 'planet');
assertSkiaScene($canvasId, $predicate) checks the full serialized scene;
skiaScene($canvasId) returns it for custom assertions, including shared values,
uniforms, assets and bindings. Both inline and file-backed large scenes work.
Node assertions recurse through groups. assertMissingSkiaNode checks absence.
Predicates return true on a match. Missing or duplicate canvas IDs fail clearly.
assertSkiaAnimation checks a node's declared animation, not its playback or FPS.
Event helpers dispatch the actual event classes through your component's #[On]
listeners, using the canvas's current EDGE node ID:
$screen->gestureSkia('editor', 'pinch', 'end');$screen->pointerSkia('editor', 'paper', 'up', 120, 80, pressure: 0.5);$screen->actionSkia('editor', 'paper', 'undo');$screen->failSkia('editor', 'Shader compilation failed');
Gesture phases are start, end, cancel; pointer phases are down, up,
cancel. Pointer moves remain native and are not dispatched to PHP. These helpers
do not perform hit testing, generate native gesture state or simulate GPU output.
Script native responses explicitly, then assert the app made the right requests:
Native::fakeBridge() ->withSkiaControl(fn (string $canvasId, array $command) => [ 'success' => true, 'values' => ['x' => 180], ]) ->withSkiaExport([ 'success' => true, 'path' => '/virtual/export.png', 'bytes' => 123, ]); Native::test(MyCanvasScreen::class) ->call('moveShape') ->assertSkiaControl('editor', 'values', fn (array $command) => $command['values']['x'] === 250) ->call('exportArtwork') ->assertSkiaExport('editor', 'png');
withSkiaControl() defaults to a successful acknowledgement. Pass an array or a
closure receiving (canvasId, decodedCommand) to supply state or errors.
withSkiaExport() requires an array or a closure receiving export parameters.
No file is written by a fake; supply a real test fixture if your app later reads
or shares the exported file. Responses and call history belong to the current
NativePHP fake bridge and follow its per-test isolation.
assertSkiaExport() without a canvas ID matches any export, including
Snapshot::save(); pass a third predicate to inspect its scene/scene_path and
other parameters. assertNoSkiaControls() and assertNoSkiaExports() assert no
requests were made. Use the core's assertNativeCalledTimes() for exact counts.
Return ['success' => false, 'error' => '...'] to exercise your error handling.
These tests verify PHP scene configuration, event handling and bridge requests. Native rendering, real gestures, accessibility navigation and performance still require native/device verification.
Demo application and verification#
The plugin provides drawing, animation, interaction and export APIs. It does not ship showcase scenes, product compositions or demo artwork.
All 53 examples are constructed in the NativePHP kitchen-sink application:
app/Support/Skia owns the 31 feature chapters, four animated showcases and
18 commercial demos; resources/images/skia owns their artwork. The application's
Skia Studio exposes them through Lab, Showcase and Film demos.
These classes and routes belong to the app and are not part of the plugin API.
For contributors, run python3 -m unittest discover -s tests -p 'test_*.py',
php tests/capabilities.php /tmp/skia-fixtures, and sh scripts/test-native.sh.
PHPUnit must use dependencies from a consuming app, never a nested plugin vendor
directory. Plugin CI uses small test-only engine fixtures and has no dependency
on the demo application. Demo export and Android instrumentation tooling live in
that application's tests/NativeSkia directory.
See docs/verification.md for dated evidence and physical-device qualification;
these checks do not imply a universal frame rate.
THIRD_PARTY_NOTICES.txt, DEPENDENCIES.json and third-party license texts ship
with the package. Native configuration includes notices in iOS app resources
and Android assets. See third-party details.