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->transform3d(rotateX, rotateY, rotateZ, translateZ, perspective) |
CSS-style 3D stage inside the 2D transform; see 3D transforms below |
node->origin(x, y) / perspective(distance) |
Pivot for rotation, scale and 3D (like CSS transform-origin); per-node viewer distance |
node->depth(z) / backfaceVisible(false) |
Back-to-front sibling order; hide content whose 3D transform faces away |
| `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, opacity, the 3D properties rotateX, rotateY, rotateZ, translateZ,
perspective, originX and originY, and depth z. 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.
3D transforms, depth and pop-up scenes#
Nodes can rotate in 3D, lift toward a perspective camera and reorder by depth. All of it binds to shared values, so a single value such as a fold angle drives every layer natively each frame with no PHP round trip.
$node->origin(0, 90) // pivot on the left edge, vertically centered ->transform3d(rotateX: 0, rotateY: -45, rotateZ: 0, translateZ: 20) ->perspective(800); // or Scene::perspective(900) for the whole scene
The full local matrix is translate(origin + x, y) · rotate · scale · perspective(d) · translateZ · rotateX · rotateY · rotateZ · translate(-origin), the CSS order for
transform-origin plus transform: translate rotate scale perspective translateZ rotateX rotateY rotateZ. Angles are degrees. Positive rotateY swings the right side away from
the viewer, positive rotateX tips the top away, and positive translateZ comes toward
the viewer. With origin (0, 0) the 2D transform is exactly the previous one.
perspective(distance)(scene units, 0 = none, otherwise 1–1,000,000) projects toward the node's origin.Scene::perspective(distance, originX, originY)adds a scene-wide camera centered on the scene by default. Perspective, group transforms and children compose as full 4×4 matrices, so a child'stranslateZrecedes correctly under a parent's or the scene's perspective, including inside compositing layers. Without any perspective,translateZhas no visible effect (orthographic).- Bind or animate
rotateX,rotateY,rotateZ,translateZ,perspective,originX,originY,originZ,scaleZandzon any node, likerotation. depth(z)sorts siblings back to front (ascending z, declaration order for ties); bind or animatezto reorder live. There is no depth buffer: siblings are painted whole, andtranslateZalone does not reorder or intersect them.backfaceVisible(false)hides a node and its children while its accumulated 3D transform faces away (the CSS rule), for two-sided pages and covers.- Hit testing,
CanvasTouched, dragging, ink and accessibility bounds use the inverted projective matrix of the full transform chain, so touches land on the projected shape. Points behind the viewer and edge-on (singular) planes do not hit.
Scaled, clamped and additive bindings#
$node->bind('rotateY', 'fold', scale: 0.5, offset: 0, min: 0, max: 90);$node->bind('translateX', 'tilt', scale: 12, additive: true); // added to transform()/animate()
Every binding is clamp((additive ? base : 0) + value × scale + offset, min, max), where base
is the static or animated value. One value can drive many layers at different rates.
Paint::bindUniform and Filter::bind take the same options. parallax($value, $depth, x: 1, y: 0) is shorthand: it sets depth($depth) and additive translateX/Y bindings of
value × depth × factor (replacing any translateX/Y bindings), so deeper layers move further.
Shadows and glows that follow values#
$shadow = Filter::shadow(0, 0, 0, '#2a1a4080') ->bind('y', 'fold', scale: 6 / 180) ->bind('sigma', 'fold', scale: 5 / 180) ->bind('opacity', 'fold', scale: 1 / 180, min: 0, max: 1);$glow = Filter::glow(4, '#ffe9a8', spread: 2)->bind('sigma', 'fold', scale: 8 / 180);Node::group([$castle], Paint::fill()->filters($shadow));
Bindable filter parameters: blur, offset, dilate and erode x/y; shadow x, y,
sigma and opacity; glow sigma, spread and opacity. Filter::glow draws a colored halo
(optionally dilated by spread, then blurred) behind its input. Native code clamps radii
to 0–128 and opacity to 0–1. Bound filters work on shape, baseline text and group layer
paints; paragraph paints reject them. Call bind() before passing the filter to
Paint::filters(), which snapshots it.
Pop-up book#
use Native\Mobile\Skia\{Animation, Canvas, Filter, Node, Paint, Scene}; $lift = fn (Node $node, float $depth) => $node->depth($depth) ->bind('translateZ', 'fold', scale: $depth * 20 / 180, min: 0) // rises as the page opens ->parallax('tilt', $depth, x: 0.04); // deeper layers drift further$shadow = Filter::shadow(0, 0, 0, '#2a1a4080') ->bind('y', 'fold', scale: 6 / 180, min: 0)->bind('sigma', 'fold', scale: 5 / 180, min: 0) ->bind('opacity', 'fold', scale: 1 / 180, min: 0, max: 1); $inside = Node::group([ Node::rect(0, 0, 160, 180, Paint::fill()->linearGradient(0, 0, 0, 180, ['#2b3a67', '#f6d6a8']), 6), $lift(Node::group([Node::circle(118, 42, 16, Paint::fill('#fff4cf') ->filters(Filter::glow(0, '#ffe9a8', spread: 2)->bind('sigma', 'fold', scale: 8 / 180, min: 0)))]), 3), $lift(Node::group([Node::path('M30 150 V92 H44 V80 H52 V92 H70 V70 H82 V58 H90 V70 H102 V92 H118 V80 H126 V92 H134 V150 Z', Paint::fill('#5b4a7a'))], Paint::fill()->filters($shadow)), 2), $lift(Node::path('M0 150 Q40 118 80 140 T160 132 V180 H0 Z', Paint::fill('#3f7d5a')), 1),])->clip('M6 0 H154 Q160 0 160 6 V174 Q160 180 154 180 H6 Q0 180 0 174 V6 Q0 0 6 0 Z') ->backfaceVisible(false);$cover = Node::rect(0, 0, 160, 180, Paint::fill('#7b4b94'), 6) ->origin(80, 90)->transform3d(rotateY: 180)->backfaceVisible(false); $scene = Scene::make(360, 240)->background('#1d1b2f')->perspective(900) ->value('fold', 0) // 0 = closed, 180 = flat open ->value('tilt', 0)->gestures(panX: 'tilt') ->add( Node::rect(20, 30, 160, 180, Paint::fill('#fbf3e0'), 6)->id('left-page'), Node::group([$inside, $cover])->id('right-page') ->transform(180, 30)->origin(0, 90) // hinge on the spine ->bind('rotateY', 'fold', offset: -180, min: -180, max: 0), );
<skia-canvas :scene="$scene" animated interactive canvas-id="book" class="w-full h-80" a11y-label="Pop-up book" />
Canvas::animate('book', 'fold', Animation::spring(0, 180)); // open it nativelyCanvas::values('book', ['fold' => $angle]); // or follow an app-supplied angle
The right page hinges on the spine; its inside face and the cover swap with back-face
culling. Each layer lifts toward the camera, drifts with tilt in proportion to its depth,
and the castle's shadow grows as the page opens. The same scene is a CI render fixture
(tests/fixtures.php, popup-book).
Limitations: keep perspective distances larger than the nearest content's distance toward
the viewer (for a hinged page, comfortably more than the page width plus any
translateZ); geometry at or behind the eye is not clipped specially and can render
incorrectly. Image filters (blur, shadow, glow) under perspective are evaluated by Skia
and then projected, so their radii are approximate on steep angles. Accessibility bounds
are projected bounding boxes. Nodes with backfaceVisible(false) count as dynamic, so they
cannot sit inside a cached group. PDF export flattens pages that use any 3D transform,
scene camera or back-face culling to a raster page; PNG/JPEG/WebP are unaffected.
Native values, depth scaling and two-sided artwork#
Native value sources are implemented on iOS and Android. A publishing plugin can write into a shared value without a PHP frame loop:
$scene->value('open', 0.5, source: 'fold.openAmount', ease: 0.8);
The initial value is used until the first update arrives. The source provider (for
example nativephp/mobile-fold) is optional and installed separately. ease smooths
large changes; small continuous updates are applied directly. Canvas value commands
can override the value until the next source update.
origin(x, y, z: 0) supports a pivot in all three dimensions. Negative Z places the
pivot behind a panel, useful for orbiting cards and carousels. scaleZ(scale) scales
depth within a node, including its children's translations; X/Y scaling remains on
transform(). The 3D stage ends with scaleZ, after its rotations. Both originZ
and scaleZ can be animated or bound, including additive/clamped bindings. Zero and
negative scale are accepted, as with the existing XY scale API.
$ticket = Node::twoSided($front, $back, width: 280, height: 176) ->origin(140, 88)->bind('rotateY', 'flip', scale: 180);$stack = Node::group($layers)->scaleZ(1.5);$orbiter = $card->origin(140, 88, z: -120)->bind('rotateY', 'angle');
twoSided() snapshots two ordinary node trees without mutating either input. Its back
is rotated around the given bounds, and each side is culled independently. It creates
surfaces, not a solid with thickness or a depth buffer.
Page curl: paper that bends#
rotateY turns a page like a stiff card. Node::pageCurl() turns it like paper: the
sheet wraps around a cylinder whose axis is perpendicular to the drag, so pulling a corner
peels it diagonally and pulling an edge rolls the whole edge. The front and back are
ordinary nodes in the sheet's own width × height coordinates; they are rendered at device
resolution (3D children included, exactly as they look at rest), mapped onto a lit mesh,
and drawn front faces first, then a drop shadow that grows with the lift, then the
mirrored, slightly darker back. A soft shadow also runs ahead of the roll on the page beneath.
$scene = Scene::make(1000, 700)->value('turn', 0); foreach (range(0, $leaves - 1) as $i) { $scene->add( Node::pageCurl(front: $rightPage($i), back: $leftPage($i + 1), width: 500, height: 700, value: 'turn', index: $i, radius: 48, flutter: 0.014) ->id("leaf-$i") ->transform(500, 0), // the spine is the sheet's x = 0 );}
- Progress is
turn - index: 0 lies flat to the right of the spine, 1 lies turned to the left (the back drawn atx = -width). Anything else bound toturn(pop-ups rising, fades) stays in sync, andCanvas::animate('book', 'turn', …)turns pages from PHP. - Interactive sheets (the default; they need an
id) handle their own gestures natively: the grabbed point follows the finger 1:1, a grab within 20% of the top or bottom pins that corner, release springs to the nearest side using the fling velocity (critically damped, so it never overshoots into the next page), and a tap flips the page. Pointer events still reachCanvasPointerwith sheet-local coordinates. radiusis the roll radius in scene units;flutter(0–0.2) gently lifts the free edge of the top right-hand page at rest;anchor(0–1) is the height of the roll for edges turned from PHP.- Sheets draw above unkeyed siblings (the book's fixed first and last pages). Settled sheets
stack by index and a sheet in motion draws above all of them, unless you set an explicit
depth(). Bind face opacity toturnto skip pages buried under others. - Gestures are forgiving: a flick or any deliberate drag (over 15% of a turn) completes the turn, touching a sheet while it is still springing catches it so it can be reversed (or the next sheet, once it has passed halfway), and a capture whose release was lost is finished automatically on the next touch.
- Flat sheets at rest draw directly with no offscreen pass. PDF export flattens scenes with curls.
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
2D and 3D transforms, depth, opacity, geometry, filter parameters (Filter::bind) or
scalar float shader uniforms through Paint::bindUniform, optionally scaled, offset,
clamped or additive (see 3D transforms above).
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.