Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// AsyncImage.swift
// AndroidSwiftUICore
//
// A remote image. The interpreter fetches and decodes off the main thread,
// showing a progress indicator while loading and a placeholder on failure —
// the whole lifecycle stays Compose-side, so loading never touches the bridge.
//

import Foundation

public struct AsyncImage: View {

internal let url: URL?

public init(url: URL?) {
self.url = url
}

public typealias Body = Never
}

extension AsyncImage: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
var props: [String: PropValue] = [:]
if let url { props["url"] = .string(url.absoluteString) }
return RenderNode(type: "AsyncImage", id: context.path, props: props)
}
}

// MARK: - Content mode

/// How a resizable image maps into the space offered it.
public enum ContentMode: String, Sendable {
case fit, fill
}

public struct _ContentModeModifier: RenderModifier {
let mode: ContentMode
public var _modifierNode: ModifierNode {
ModifierNode(kind: "contentMode", args: ["mode": .string(mode.rawValue)])
}
}

public extension View {

func scaledToFit() -> ModifiedContent<Self, _ContentModeModifier> {
modifier(_ContentModeModifier(mode: .fit))
}

func scaledToFill() -> ModifiedContent<Self, _ContentModeModifier> {
modifier(_ContentModeModifier(mode: .fill))
}

func aspectRatio(contentMode: ContentMode) -> ModifiedContent<Self, _ContentModeModifier> {
modifier(_ContentModeModifier(mode: contentMode))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public struct Image: View {

internal let name: String
internal let systemName: String?
internal var isResizable = false

public init(_ name: String) {
self.name = name
Expand All @@ -61,13 +62,22 @@ public struct Image: View {
self.systemName = systemName
}

/// Lets the image scale to fill its proposed space (pair with
/// `scaledToFit`/`scaledToFill`). A method on `Image` itself, as in SwiftUI.
public func resizable() -> Image {
var copy = self
copy.isResizable = true
return copy
}

public typealias Body = Never
}

extension Image: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
var props: [String: PropValue] = ["name": .string(name)]
if let systemName { props["systemName"] = .string(systemName) }
if isResizable { props["resizable"] = .bool(true) }
return RenderNode(type: "Image", id: context.path, props: props)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,39 @@ struct ModifierTests {
#expect(changes == 0)
}

@Test("Image distinguishes a system symbol from a named asset")
func imageNaming() {
let symbol = ViewHost(Image(systemName: "star.fill")).evaluate()
#expect(symbol.props["systemName"] == .string("star.fill"))
let asset = ViewHost(Image("sample_photo")).evaluate()
#expect(asset.props["name"] == .string("sample_photo"))
#expect(asset.props["systemName"] == nil) // not a symbol lookup
#expect(asset.props["resizable"] == nil) // opt-in only
}

@Test("resizable and a content mode travel together to the interpreter")
func imageResizable() {
let node = ViewHost(Image("sample_photo").resizable().scaledToFit()).evaluate()
#expect(node.props["resizable"] == .bool(true))
let mode = node.modifiers.first { $0.kind == "contentMode" }
#expect(mode?.args["mode"] == .string("fit"))
// scaledToFill and aspectRatio(contentMode:) select the other mode
let filled = ViewHost(Image("x").resizable().scaledToFill()).evaluate()
#expect(filled.modifiers.first { $0.kind == "contentMode" }?.args["mode"] == .string("fill"))
let ratio = ViewHost(Image("x").aspectRatio(contentMode: .fit)).evaluate()
#expect(ratio.modifiers.first { $0.kind == "contentMode" }?.args["mode"] == .string("fit"))
}

@Test("AsyncImage carries its URL, and a nil URL carries none")
func asyncImage() {
let node = ViewHost(AsyncImage(url: URL(string: "https://example.com/a.png"))).evaluate()
#expect(node.type == "AsyncImage")
#expect(node.props["url"] == .string("https://example.com/a.png"))
let empty = ViewHost(AsyncImage(url: nil)).evaluate()
#expect(empty.type == "AsyncImage")
#expect(empty.props["url"] == nil)
}

@Test("onAppear and onDisappear emit distinct callback kinds")
func appearDisappear() {
let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate()
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct CatalogEntry: Identifiable {
CatalogEntry(id: "frame", title: "Frames", screen: AnyCatalogScreen(FramePlayground())),
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
CatalogEntry(id: "image", title: "Images", screen: AnyCatalogScreen(ImagePlayground())),
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
CatalogEntry(id: "map", title: "Map", screen: AnyCatalogScreen(MapPlayground())),
CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground())),
Expand Down
58 changes: 58 additions & 0 deletions Demo/App.swiftpm/Sources/ImagePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
#endif
import Foundation

struct ImagePlayground: View {

@State private var remoteIndex = 0

private let remotes = [
"https://www.gstatic.com/webp/gallery/1.jpg",
"https://picsum.photos/id/237/300/200",
]

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("Asset image") {
VStack(alignment: .leading, spacing: 8) {
Text("Image(\"sample_photo\") — natural size")
Image("sample_photo")
}
}
Example("resizable + scaledToFit") {
Image("sample_photo")
.resizable()
.scaledToFit()
.frame(width: 160, height: 90)
}
Example("resizable + scaledToFill") {
Image("sample_photo")
.resizable()
.scaledToFill()
.frame(width: 160, height: 90)
}
Example("AsyncImage") {
VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: URL(string: remotes[remoteIndex]))
.frame(width: 200, height: 150)
Button("Load the other image") {
remoteIndex = (remoteIndex + 1) % remotes.count
}
}
}
Example("AsyncImage — failure") {
AsyncImage(url: URL(string: "https://example.invalid/missing.png"))
.frame(width: 200, height: 60)
}
Example("Unknown asset falls back") {
Image("no_such_asset")
}
}
}
.navigationTitle("Images")
}
}
Binary file added Demo/app/src/main/res/drawable/sample_photo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.pureswift.swiftui

import android.graphics.BitmapFactory
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL

// Asset images come from the app's drawable resources, looked up by name —
// the Android analog of an asset-catalog image.
@Composable
internal actual fun rememberAssetPainter(name: String): Painter? {
val context = LocalContext.current
val id = remember(name) {
context.resources.getIdentifier(name, "drawable", context.packageName)
}
return if (id != 0) painterResource(id) else null
}

internal actual suspend fun loadRemoteImage(url: String): ImageBitmap? =
withContext(Dispatchers.IO) {
runCatching {
val bytes = fetchImageBytes(url)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.pureswift.swiftui

import java.net.HttpURLConnection
import java.net.URL

/// Bytes for a remote image, shared by both JVM targets.
///
/// Deliberately not `URL.openStream()`: that sends the default `Java/<version>`
/// user agent, which a number of CDNs reject outright (Wikimedia answers 403),
/// and it neither follows cross-protocol redirects nor surfaces a non-200 as a
/// failure — a redirect or error page would otherwise be decoded as garbage.
internal fun fetchImageBytes(url: String): ByteArray {
var remaining = 5
var current = URL(url)
while (true) {
val connection = (current.openConnection() as HttpURLConnection).apply {
instanceFollowRedirects = false // handled below, so http↔https works
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "image/*")
}
try {
val code = connection.responseCode
if (code in 300..399 && remaining-- > 0) {
val location = connection.getHeaderField("Location")
?: error("redirect with no location")
current = URL(current, location)
continue
}
if (code != HttpURLConnection.HTTP_OK) error("HTTP $code for $current")
return connection.inputStream.use { it.readBytes() }
} finally {
connection.disconnect()
}
}
}

private const val USER_AGENT = "AndroidSwiftUI/1.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.pureswift.swiftui

import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter

// Platform seams for image loading, mirroring the VideoPlayer pattern: the
// common interpreter stays free of platform APIs, and each target supplies
// its lookup (Android app resources) and its decoder.

/// A bundled image by name, or null when the platform can't resolve it.
@Composable
internal expect fun rememberAssetPainter(name: String): Painter?

/// Fetches and decodes a remote image off the main thread; null on failure.
internal expect suspend fun loadRemoteImage(url: String): ImageBitmap?
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
Expand Down Expand Up @@ -337,6 +339,8 @@ private fun RenderResolved(node: ViewNode) {

"Image" -> RenderImage(node)

"AsyncImage" -> RenderAsyncImage(node)

"Overlay" -> Box(
contentAlignment = zStackAlignment(node),
modifier = node.composeModifiers(),
Expand Down Expand Up @@ -951,11 +955,61 @@ private fun RenderImage(node: ViewNode) {
tint = tint ?: LocalContentColor.current,
modifier = node.composeModifiers(),
)
return
}
val asset = node.string("name")?.let { rememberAssetPainter(it) }
if (asset != null) {
androidx.compose.foundation.Image(
painter = asset,
contentDescription = node.string("name"),
contentScale = node.imageContentScale(),
modifier = node.composeModifiers(),
)
} else {
Text("[${node.string("name") ?: "image"}]", modifier = node.composeModifiers())
}
}

// resizable() unlocks scaling; the contentMode modifier then picks fit or fill.
private fun ViewNode.imageContentScale(): ContentScale {
if (string("resizable") != "true") return ContentScale.Inside
return when (modifiers.firstOrNull { it.kind == "contentMode" }?.args?.string("mode")) {
"fit" -> ContentScale.Fit
"fill" -> ContentScale.Crop
else -> ContentScale.FillBounds // bare resizable stretches, as in SwiftUI
}
}

// Fetch → decode → swap in, entirely Compose-side: a spinner while loading and
// a labeled placeholder on failure. Keyed by URL so a new URL reloads.
@Composable
private fun RenderAsyncImage(node: ViewNode) {
val url = node.string("url")
if (url == null) {
Text("[image]", modifier = node.composeModifiers())
return
}
var bitmap by remember(url) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(url) { mutableStateOf(false) }
LaunchedEffect(url) {
val loaded = loadRemoteImage(url)
if (loaded != null) bitmap = loaded else failed = true
}
val image = bitmap
when {
image != null -> androidx.compose.foundation.Image(
bitmap = image,
contentDescription = url,
contentScale = node.imageContentScale(),
modifier = node.composeModifiers(),
)
failed -> Text("[failed: $url]", modifier = node.composeModifiers())
else -> Box(contentAlignment = Alignment.Center, modifier = node.composeModifiers()) {
CircularProgressIndicator()
}
}
}

private fun ViewNode.colorList(key: String): List<Color> {
val arr = props[key] as? kotlinx.serialization.json.JsonArray ?: return emptyList()
return arr.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull()?.let { c -> Color(c.toInt()) } }
Expand Down Expand Up @@ -1231,7 +1285,7 @@ private val KNOWN_MODIFIER_KINDS = setOf(
"scale", "opacity", "border", "shadow", "clipShape", "onTapGesture", "disabled",
"font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment",
"tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem",
"transition", "focused", "longPress", "drag",
"transition", "focused", "longPress", "drag", "contentMode",
)

// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.pureswift.swiftui

import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

// The desktop rig has no app resource bundle, so named assets don't resolve
// there (a documented desktop limit); the placeholder path renders instead.
@Composable
internal actual fun rememberAssetPainter(name: String): Painter? = null

internal actual suspend fun loadRemoteImage(url: String): ImageBitmap? =
withContext(Dispatchers.IO) {
runCatching {
org.jetbrains.skia.Image.makeFromEncoded(fetchImageBytes(url)).toComposeImageBitmap()
}.getOrNull()
}
Loading