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
87 changes: 87 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Map.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Map.swift
// AndroidSwiftUICore
//
// A map showing a coordinate region with markers. The interpreter renders a
// schematic map — markers positioned proportionally within the visible
// region — until a tile provider is registered through the composable
// registry (real tiles need a maps SDK and API key on Android).
//

public struct CLLocationCoordinate2D: Equatable, Sendable {
public var latitude: Double
public var longitude: Double
public init(latitude: Double, longitude: Double) {
self.latitude = latitude
self.longitude = longitude
}
}

public struct MKCoordinateSpan: Equatable, Sendable {
public var latitudeDelta: Double
public var longitudeDelta: Double
public init(latitudeDelta: Double, longitudeDelta: Double) {
self.latitudeDelta = latitudeDelta
self.longitudeDelta = longitudeDelta
}
}

public struct MKCoordinateRegion: Equatable, Sendable {
public var center: CLLocationCoordinate2D
public var span: MKCoordinateSpan
public init(center: CLLocationCoordinate2D, span: MKCoordinateSpan) {
self.center = center
self.span = span
}
}

/// A map annotation: a titled pin at a coordinate.
public struct MapMarker: Sendable {
public var title: String
public var coordinate: CLLocationCoordinate2D
public var tint: Color?
public init(_ title: String, coordinate: CLLocationCoordinate2D, tint: Color? = nil) {
self.title = title
self.coordinate = coordinate
self.tint = tint
}
}

public struct Map: View {

internal let region: Binding<MKCoordinateRegion>
internal let markers: [MapMarker]

public init(coordinateRegion: Binding<MKCoordinateRegion>, markers: [MapMarker] = []) {
self.region = coordinateRegion
self.markers = markers
}

public typealias Body = Never
}

extension Map: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
let value = region.wrappedValue
let children = markers.enumerated().map { index, marker -> RenderNode in
var props: [String: PropValue] = [
"title": .string(marker.title),
"latitude": .double(marker.coordinate.latitude),
"longitude": .double(marker.coordinate.longitude),
]
if let tint = marker.tint { props["tint"] = tint.propValue }
return RenderNode(type: "MapMarker", id: context.path + "/marker#\(index)", props: props)
}
return RenderNode(
type: "Map",
id: context.path,
props: [
"centerLatitude": .double(value.center.latitude),
"centerLongitude": .double(value.center.longitude),
"spanLatitude": .double(value.span.latitudeDelta),
"spanLongitude": .double(value.span.longitudeDelta),
],
children: children
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// VideoPlayer.swift
// AndroidSwiftUICore
//
// A video player with system playback controls. The node carries the media
// URL; the Android interpreter hosts a platform player view, and the desktop
// rig shows a placeholder.
//

import Foundation

/// A playable media item. Mirrors the AVKit spelling; only URL-backed
/// playback is modeled.
public struct AVPlayer: Sendable {
public var url: URL
public init(url: URL) { self.url = url }
}

public struct VideoPlayer: View {

internal let player: AVPlayer

public init(player: AVPlayer) {
self.player = player
}

public typealias Body = Never
}

extension VideoPlayer: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
RenderNode(
type: "VideoPlayer",
id: context.path,
props: ["url": .string(player.url.absoluteString)]
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Testing
import Foundation
@testable import AndroidSwiftUICore

// MARK: - Emission
Expand Down Expand Up @@ -238,6 +239,36 @@ struct GraphicsTests {
#expect(node.props["systemName"] == .string("star.fill"))
}

@Test("Map emits its region and marker children")
func map() {
struct Screen: View {
@State var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: -12.046, longitude: -77.043),
span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
)
var body: some View {
Map(coordinateRegion: $region, markers: [
MapMarker("Plaza", coordinate: CLLocationCoordinate2D(latitude: -12.045, longitude: -77.030)),
])
}
}
let node = ViewHost(Screen()).evaluate()
#expect(node.type == "Map")
#expect(node.props["centerLatitude"] == .double(-12.046))
#expect(node.props["spanLongitude"] == .double(0.1))
#expect(node.children.count == 1)
#expect(node.children[0].type == "MapMarker")
#expect(node.children[0].props["title"] == .string("Plaza"))
#expect(node.children[0].props["latitude"] == .double(-12.045))
}

@Test("VideoPlayer emits its media URL")
func videoPlayer() {
let node = ViewHost(VideoPlayer(player: AVPlayer(url: URL(string: "https://example.com/clip.mp4")!))).evaluate()
#expect(node.type == "VideoPlayer")
#expect(node.props["url"] == .string("https://example.com/clip.mp4"))
}

@Test("Overlay emits base and overlay children with alignment")
func overlay() {
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate()
Expand Down
2 changes: 2 additions & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ struct CatalogEntry: Identifiable {
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
CatalogEntry(id: "map", title: "Map", screen: AnyCatalogScreen(MapPlayground())),
CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground())),
CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())),
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
Expand Down
35 changes: 35 additions & 0 deletions Demo/App.swiftpm/Sources/MapPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
import MapKit
#endif

struct MapPlayground: View {
@State private var lima = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: -12.046, longitude: -77.043),
span: MKCoordinateSpan(latitudeDelta: 0.12, longitudeDelta: 0.12)
)
@State private var cupertino = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.334, longitude: -122.009),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("Region with markers") {
Map(coordinateRegion: $lima, markers: [
MapMarker("Plaza Mayor", coordinate: CLLocationCoordinate2D(latitude: -12.046, longitude: -77.030)),
MapMarker("Miraflores", coordinate: CLLocationCoordinate2D(latitude: -12.120, longitude: -77.030), tint: .blue),
MapMarker("Callao", coordinate: CLLocationCoordinate2D(latitude: -12.055, longitude: -77.100), tint: .green),
])
}
Example("Plain region") {
Map(coordinateRegion: $cupertino)
.frame(height: 140)
.cornerRadius(12)
}
}
}
}
}
24 changes: 24 additions & 0 deletions Demo/App.swiftpm/Sources/VideoPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
import AVKit
#endif

import Foundation

struct VideoPlayground: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("Streaming video") {
VStack(alignment: .leading, spacing: 8) {
VideoPlayer(player: AVPlayer(url: URL(string: "https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4")!))
.cornerRadius(8)
Text("Tap the video for playback controls")
}
}
}
}
}
}
1 change: 1 addition & 0 deletions Demo/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".Application"
Expand Down
6 changes: 6 additions & 0 deletions Demo/swiftui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ kotlin {
implementation(compose.material3)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
// VideoPlayer: the legacy MediaPlayer stack is unreliable for
// remote streams, so the Android player is Media3.
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
}
// `external fun` is JVM-only; both targets are JVM, so the bridge's
// Swift-implemented classes live in a source set they share.
val jvmShared by creating {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.pureswift.swiftui

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView

/// Hosts a Media3 player with its standard controls. The player prepares
/// paused on the first frame; the user starts playback from the controls.
@Composable
internal actual fun RenderVideoPlayer(node: ViewNode) {
val url = node.string("url") ?: return
val context = LocalContext.current
val player = remember(node.id, url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
playWhenReady = false
prepare()
}
}
DisposableEffect(node.id, url) {
onDispose { player.release() }
}
AndroidView(
factory = { PlayerView(it).apply { this.player = player } },
modifier = node.composeModifiers().fillMaxWidth().height(220.dp),
)
}
53 changes: 53 additions & 0 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ private fun RenderResolved(node: ViewNode) {

"LinearGradient" -> RenderGradient(node)

"Map" -> RenderMap(node)

"VideoPlayer" -> RenderVideoPlayer(node)

"ProgressView" -> {
val value = node.double("value")
if (value != null) {
Expand Down Expand Up @@ -611,6 +615,55 @@ private fun RenderShape(node: ViewNode) {
Box(modifier = node.composeModifiers().background(fill, shape))
}

// A schematic map: the visible region maps linearly onto the box, markers sit
// at their proportional position, and the center coordinate is captioned.
// Real tiles come from a registered composable (maps SDKs need an API key).
@Composable
private fun RenderMap(node: ViewNode) {
val centerLat = node.double("centerLatitude") ?: 0.0
val centerLon = node.double("centerLongitude") ?: 0.0
val spanLat = (node.double("spanLatitude") ?: 1.0).coerceAtLeast(1e-6)
val spanLon = (node.double("spanLongitude") ?: 1.0).coerceAtLeast(1e-6)
Box(
modifier = node.composeModifiers()
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFDCE8DC.toInt()))
.border(1.dp, Color(0xFFB8CCB8.toInt()), RoundedCornerShape(8.dp)),
) {
for (marker in node.children) {
if (marker.type != "MapMarker") continue
val lat = marker.double("latitude") ?: continue
val lon = marker.double("longitude") ?: continue
// normalized region position → alignment bias (north at the top)
val fx = ((lon - (centerLon - spanLon / 2)) / spanLon).coerceIn(0.0, 1.0)
val fy = (((centerLat + spanLat / 2) - lat) / spanLat).coerceIn(0.0, 1.0)
val tint = marker.long("tint")?.let { Color(it.toInt()) } ?: Color(0xFFD32F2F.toInt())
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(
androidx.compose.ui.BiasAlignment((2 * fx - 1).toFloat(), (2 * fy - 1).toFloat())
),
) {
Box(modifier = Modifier.size(12.dp).clip(CircleShape).background(tint))
Text(marker.string("title") ?: "", fontSize = 11.sp)
}
}
Text(
"${formatCoordinate(centerLat)}, ${formatCoordinate(centerLon)}",
fontSize = 11.sp,
color = Color(0xFF667066.toInt()),
modifier = Modifier.align(Alignment.BottomCenter).padding(4.dp),
)
}
}

private fun formatCoordinate(value: Double): String {
val thousandths = kotlin.math.round(value * 1000) / 1000
return thousandths.toString()
}

// Horizontal/vertical when the endpoints share an axis, else a diagonal linear
// gradient across the frame.
@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.pureswift.swiftui

import androidx.compose.runtime.Composable

/// A video player for the node's `url`. Platform-specific: Android hosts a
/// system player view with playback controls; the desktop rig shows a
/// placeholder.
@Composable
internal expect fun RenderVideoPlayer(node: ViewNode)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.pureswift.swiftui

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.material3.Text
import androidx.compose.ui.unit.dp

/// The desktop rig has no media stack; show a labeled placeholder.
@Composable
internal actual fun RenderVideoPlayer(node: ViewNode) {
Box(
contentAlignment = Alignment.Center,
modifier = node.composeModifiers().fillMaxWidth().height(220.dp).background(Color.Black),
) {
Text("▶ ${node.string("url") ?: ""}", color = Color.White)
}
}
Loading