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
Expand Up @@ -238,6 +238,29 @@ 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("Overlay emits base and overlay children with alignment")
func overlay() {
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).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 @@ -36,6 +36,7 @@ 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: "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)
}
}
}
}
}
51 changes: 51 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,8 @@ private fun RenderResolved(node: ViewNode) {

"LinearGradient" -> RenderGradient(node)

"Map" -> RenderMap(node)

"ProgressView" -> {
val value = node.double("value")
if (value != null) {
Expand Down Expand Up @@ -611,6 +613,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
Loading