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 |
 |
 |
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.
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 —
AsyncImagedefaults toAlignment.Center.On iOS the image view shrinks to its own fitted size, and
FlexContainerthen pins that smaller view to the top-leading corner of the box, because SwiftUI'splace(at:proposal:)anchors at.topLeadingby default.So wherever the drawn content is smaller than its frame, Android centres it and iOS pushes it to the top-left.
Observed
fitobject-noneobject-contain/object-scale-downobject-cover/object-fillobject-noneis 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-noneby drawing at natural size.object-nonematches the CSS meaning the attribute name implies.Cause
1. iOS has no "do not scale" mode.
resolveContentModemaps ordinal 0 through to.fit:Android maps it correctly (
ImageRenderer.kt,resolveContentScale:0 -> ContentScale.None). SwiftUI'sContentModehas only.fitand.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(inmobile-air,resources/xcode/NativePHP/NativeRender/FlexContainer.swift:686) calls:place(at:anchor:proposal:)defaults to a.topLeadinganchor. A.fitimage reports the fitted size rather than the full proposal, so it lands top-leading. Compose has no equivalent step becauseAsyncImagefills 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 withobject-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.
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 theobject-coverbranch already uses inNativeUIImageRenderer.That wrapper needs a definite height.
Color.clearhas 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-nonegets 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.ktto agree. Worth deciding which one is correct before either moves.