Skip to content

<native:image>: object-none ignored on iOS, and fitted images align differently #45

Description

@unlocdavid

While working on SVG support in #44 I ran into two visible differences between platforms on image rendering. Both come out of the same underlying asymmetry in how an image occupies its box.

On Android the image composable fills the box the layout gives it and aligns the bitmap inside itself — AsyncImage defaults to Alignment.Center.

On iOS the image view shrinks to its own fitted size, and FlexContainer then pins that smaller view to the top-leading corner of the box, because SwiftUI's place(at:proposal:) anchors at .topLeading by default.

So wherever the drawn content is smaller than its frame, Android centres it and iOS pushes it to the top-left.

Observed

fit iOS Android
object-none scaled to fit the frame drawn at natural size, centred
object-contain / object-scale-down letterboxed to the leading edge letterboxed centred
object-cover / object-fill matches matches

object-none is two defects stacked: iOS scales when it shouldn't, and it would still be misaligned if it didn't.

Expected

Both platforms centre fitted content, and both honour object-none by drawing at natural size. object-none matches the CSS meaning the attribute name implies.

Cause

1. iOS has no "do not scale" mode. resolveContentMode maps ordinal 0 through to .fit:

private func resolveContentMode(_ fit: Int) -> ContentMode {
    switch fit {
    case 2: return .fill
    case 3: return .fill
    default: return .fit   // ← ordinal 0 (none) lands here
    }
}

Android maps it correctly (ImageRenderer.kt, resolveContentScale: 0 -> ContentScale.None). SwiftUI's ContentMode has only .fit and .fill, so there is no member to return — the expression is to omit .resizable() entirely, which is presumably why it was missed.

2. Alignment comes from the placement anchor, not the renderer. FlexContainer.placeSubviews (in mobile-air, resources/xcode/NativePHP/NativeRender/FlexContainer.swift:686) calls:

subviews[i].place(at: childOrigin, proposal: ProposedViewSize(childSize))

place(at:anchor:proposal:) defaults to a .topLeading anchor. A .fit image reports the fitted size rather than the full proposal, so it lands top-leading. Compose has no equivalent step because AsyncImage fills the box and centres internally.

This is engine-level and affects any child that reports a smaller size than its allotted box, so changing the anchor is a much wider change than images. Fixing it inside the image renderer is likely the right scope.

Reproducing

<native:image src="…" class="w-full h-24 object-contain"/> beside the same source with object-none, on both platforms. A test screen covering every fit mode against an identical raster control is attached to #44.

Both symptoms predate SVG support and apply to ordinary PNG/JPEG rendering.

iOS Android
Image Image

The trap in the obvious fix

Making iOS match means letting the image fill its frame and centring inside it — Color.clear.overlay { … }.clipped(), the pattern the object-cover branch already uses in NativeUIImageRenderer.

That wrapper needs a definite height. Color.clear has no intrinsic size, so it collapses in an unbounded main axis — a bare <native:image class="w-full"> inside a scroll view currently self-sizes to its source's aspect ratio like an HTML <img>, and would render as nothing. Any fix has to keep the height-less case working, which is why this is an issue rather than a patch.

One more difference, if object-none gets fixed

"Natural size" is points on iOS and physical pixels on Android, so a 25×25 asset occupies 25pt on iOS and about 9dp on a 3× screen. iOS would match the web meaning (natural size in CSS pixels); Android would need density scaling in ImageRenderer.kt to agree. Worth deciding which one is correct before either moves.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions