Skip to content

SVG support in native:image - #44

Open
unlocdavid wants to merge 4 commits into
NativePHP:mainfrom
unlocdavid:feature/image-svg-support
Open

SVG support in native:image#44
unlocdavid wants to merge 4 commits into
NativePHP:mainfrom
unlocdavid:feature/image-svg-support

Conversation

@unlocdavid

@unlocdavid unlocdavid commented Aug 10, 2026

Copy link
Copy Markdown

Summary

<native:image> now draws SVG as well as raster. No new element, no new attribute — point src at SVG content and it renders as a vector.

Why

SVG is the format the assets an app cares most about already ship in.

  • Logos and brand material. A brand kit arrives as SVG. Without vector support each mark has to be pre-rendered to PNG at every density it might appear at, re-exported whenever the brand changes, and it still softens wherever a layout sizes it larger than planned. As a vector one file covers every size, and tintColor recolours it per theme instead of shipping a light and a dark copy.
  • Barcodes and QR codes. These often are generated as SVG — including by endroid/qr-code, which core already depends on — and they are read by a camera, so edge fidelity is the whole point. A bitmap rasterized at the wrong size blurs the quiet zone and the finder patterns, and scanning gets slower or fails outright. Drawn as a vector the code is exact at whatever size the screen gives it.
  • Icon sets and illustrations distributed as SVG stop needing a raster export step to be usable.

Without this the workarounds are pre-rendering PNGs per density, or dropping to a web view to get an <img> — which forfeits native layout and gestures for the sake of one graphic.

Vectors are never rasterized. Android decodes through coil-svg; iOS draws with SwiftDraw's SVGView, a Canvas-backed view. Both stay sharp at any display size, so a small viewBox blown up is exact rather than an upscaled bitmap. fit, tintColor, corner radius and alt behave exactly as they do for raster, because the SVG path reuses the same view modifiers.

Screenshot of visual difference between SVG and small PNG icon:

Simulator Screenshot - iPhone 17 - 2026-08-10 at 11 04 12

Commits

Commit What
6662a2e Android: add coil-svg to the plugin manifest
8d42c50 iOS: extract shared view modifiers to file scope (no behaviour change)
1a3d257 iOS: the SVG branch
90d2240 iOS: apply tint_color to raster images

Android needs no renderer change at all. coil-svg self-registers through Coil's ServiceLoader (SvgDecoderServiceLoaderTarget, enabled by default via Extras.Key(default = true) and consumed at RealImageLoader.kt:269), so the whole Android side is one line in nativephp.json and ImageRenderer.kt is untouched.

On the last commit

90d2240 is not an SVG change, and it is here because it is the same one-line fix as the SVG path's.

ImageTintModifier applies foregroundStyle, which only recolours a template image. The SVG path opts in with SVGView.renderingMode(.template) when a tint is set, so tintColor works on vectors. The raster path never opted in — a UIImage decoded from a file or a URL is .original, so the shared tint modifier had nothing to act on and the tint was dropped silently. Android applies ColorFilter.tint regardless, so the same markup tinted on one platform and not the other.

Shipping the SVG opt-in without the raster one would mean leaving two callers of the same modifier deliberately inconsistent. Untinted images are unaffected. If you would rather see it as its own PR, say so and I will lift it out.

Recognised sources

src iOS Android
.svg path, or file://…/x.svg yes yes
https://…/x.svg yes yes
data:image/svg+xml;base64,… yes yes
data:image/svg+xml,%3Csvg… (percent-encoded) yes no
inline <svg …> markup passed as src yes no

Android selects by content: Coil sniffs the bytes it fetched, so an SVG served without a .svg extension renders anyway. iOS selects by prefix for markup and data URIs, and by extension for files and URLs, since neither can be inspected before it is read. On iOS one SVGSource classification drives both the detection and the loading, so every source routed to the SVG branch is one the loader knows how to fetch.

Both Android gaps are Coil's. Its DataUriFetcher requires the ;base64, marker and returns nothing without it, and a bare markup string is not a source it fetches. Base64-encoded SVG data URIs work on both platforms.

Platform notes

  • <text> renders on both. Font selection is the host's, so a family the device lacks is substituted rather than dropped. Convert text to outlines when the exact face matters.
  • object-none is honoured on Android and not on iOS. Android maps it to ContentScale.None; iOS falls through resolveContentMode's default and letterboxes, because SwiftUI's ContentMode has no member for "do not scale". This is existing iOS behaviour for raster images and the SVG path matches it rather than diverging. Fixing it is a separate PR — the expression is to omit .resizable(), and the alignment and density questions that come with it have nothing to do with SVG.
  • Fitted content aligns differently. object-contain letterboxes to the leading edge on iOS and centres on Android. This predates SVG, applies to raster identically, and comes from the shared layout rather than the image renderer — so it is out of scope here and worth its own issue.
  • An undecodable source renders as empty space on both platforms, the same as a missing file, and never throws.

Dependencies

  • Android: io.coil-kt.coil3:coil-svg:3.1.0 in android.dependencies.implementation
  • iOS: swhitty/SwiftDraw 0.29.0 in ios.dependencies.swift_packages — the first use of that manifest slot in this plugin

Both link unconditionally, so apps that never render an SVG still carry them. Worth a maintainer's opinion on whether either should be opt-in; there is no mechanism for optional plugin dependencies today.

Verification

Device-tested on both platforms against one screen that renders every SVG case beside an identical raster control, so a difference between columns is a pipeline bug and a difference between platforms is a decoder bug.

Covered: fit modes, tint, corner radius, gradient fills, clip-path into defs,
a 7.3:1 aspect ratio, missing viewBox, an 8px viewBox upscaled to 96pt,
<text>, local files, remote URLs, all three data-URI forms, malformed SVG, and
missing files.

Both renderers behave identically to their raster control in every case except the platform notes above.

The screen is not part of this PR, but it is attached as a zip so it can be dropped into any NativePHP app to reproduce the results — component, Blade view, fixtures in their directory structure, and an INSTALL.md with the route line and
the expected per-platform differences.

svg-test-screen.zip

unlocdavid and others added 4 commits August 8, 2026 12:44
coil-svg's SvgDecoder self-registers via Coil's ServiceLoader, so adding
the dependency is the whole change — ImageRenderer needs no edit. SVG
sources then flow through the same AsyncImage path as raster: fit modes,
tint, corner clipping and the failure paths are unchanged.

Selection is content-based (`<` at byte 0, `<svg` within 1024 bytes), so
SVG is accepted from any source Coil already fetches, including base64
data URIs. Percent-encoded data URIs are not supported — Coil's
DataUriFetcher requires the `;base64,` marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tint, fitted-corner clipping, alt/accessibility and the fit-ordinal
mapping all operate on `some View` rather than on `Image`, so they move
out of NativeUIImageRenderer as file-scope ViewModifiers. No behaviour
change — the same modifiers apply in the same order.

This lets a second renderer reuse them without inheriting the
Image-specific resize step, which is the one part that cannot be shared:
`Image.resizable()` and a vector view's `resizable()` are unrelated
methods on unrelated types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SVG sources take a SwiftDraw SVGView branch inside NativeUIImageRenderer
rather than a separate element. Vectors are never rasterised — SVGView
draws through a Canvas, so an 8pt viewBox at 96pt is exact instead of an
upscaled bitmap, and no size cache or GeometryReader is needed.

The branch reuses the tint and fitted-corner modifiers, so fit modes,
tinting, corner clipping, alt text and the empty-on-failure behaviour
match the raster path. object-none is expressed the same way too, by
leaving the view unresized.

Sources are recognised by prefix for inline markup and data URIs, and by
extension for files and remote URLs — neither of the latter can be
sniffed without reading them first. iOS therefore accepts percent-encoded
data URIs and inline markup, which Coil on Android does not; Android in
turn accepts SVG from any source it fetches regardless of extension.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tint reached foregroundStyle, which only recolours a template image.
A UIImage decoded from a file or a URL is `.original`, so the modifier
had nothing to act on and tint_color was dropped without a warning.
Android applies ColorFilter.tint regardless, so the same markup tinted on
one platform and not the other.

Switching the source to `.renderingMode(.template)` when a tint is set
makes foregroundStyle effective. Untinted images keep their original
rendering mode and are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@simonhamp

Copy link
Copy Markdown
Member

This is great 👍🏼 Honestly, I love that this could be a core feature of this package, but because of the third-party dependencies I think it probably ought to be its own plugin

That way, the developer opts into them because they know they need them rather than being encumbered with them regardless

In any case, it's probably better as its own <native:svg> element than trying to ride <native:image> (though it could extend image to maintain some parity), as it may make conditional loading of the dependencies later on a little easier

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants