Skip to content

Commit 5e0f0c2

Browse files
authored
Merge pull request #35 from PureSwift/feature/map
Map with coordinate regions and markers
2 parents 6832bb5 + 1329f7d commit 5e0f0c2

5 files changed

Lines changed: 197 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// Map.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A map showing a coordinate region with markers. The interpreter renders a
6+
// schematic map — markers positioned proportionally within the visible
7+
// region — until a tile provider is registered through the composable
8+
// registry (real tiles need a maps SDK and API key on Android).
9+
//
10+
11+
public struct CLLocationCoordinate2D: Equatable, Sendable {
12+
public var latitude: Double
13+
public var longitude: Double
14+
public init(latitude: Double, longitude: Double) {
15+
self.latitude = latitude
16+
self.longitude = longitude
17+
}
18+
}
19+
20+
public struct MKCoordinateSpan: Equatable, Sendable {
21+
public var latitudeDelta: Double
22+
public var longitudeDelta: Double
23+
public init(latitudeDelta: Double, longitudeDelta: Double) {
24+
self.latitudeDelta = latitudeDelta
25+
self.longitudeDelta = longitudeDelta
26+
}
27+
}
28+
29+
public struct MKCoordinateRegion: Equatable, Sendable {
30+
public var center: CLLocationCoordinate2D
31+
public var span: MKCoordinateSpan
32+
public init(center: CLLocationCoordinate2D, span: MKCoordinateSpan) {
33+
self.center = center
34+
self.span = span
35+
}
36+
}
37+
38+
/// A map annotation: a titled pin at a coordinate.
39+
public struct MapMarker: Sendable {
40+
public var title: String
41+
public var coordinate: CLLocationCoordinate2D
42+
public var tint: Color?
43+
public init(_ title: String, coordinate: CLLocationCoordinate2D, tint: Color? = nil) {
44+
self.title = title
45+
self.coordinate = coordinate
46+
self.tint = tint
47+
}
48+
}
49+
50+
public struct Map: View {
51+
52+
internal let region: Binding<MKCoordinateRegion>
53+
internal let markers: [MapMarker]
54+
55+
public init(coordinateRegion: Binding<MKCoordinateRegion>, markers: [MapMarker] = []) {
56+
self.region = coordinateRegion
57+
self.markers = markers
58+
}
59+
60+
public typealias Body = Never
61+
}
62+
63+
extension Map: PrimitiveView {
64+
public func _render(in context: ResolveContext) -> RenderNode {
65+
let value = region.wrappedValue
66+
let children = markers.enumerated().map { index, marker -> RenderNode in
67+
var props: [String: PropValue] = [
68+
"title": .string(marker.title),
69+
"latitude": .double(marker.coordinate.latitude),
70+
"longitude": .double(marker.coordinate.longitude),
71+
]
72+
if let tint = marker.tint { props["tint"] = tint.propValue }
73+
return RenderNode(type: "MapMarker", id: context.path + "/marker#\(index)", props: props)
74+
}
75+
return RenderNode(
76+
type: "Map",
77+
id: context.path,
78+
props: [
79+
"centerLatitude": .double(value.center.latitude),
80+
"centerLongitude": .double(value.center.longitude),
81+
"spanLatitude": .double(value.span.latitudeDelta),
82+
"spanLongitude": .double(value.span.longitudeDelta),
83+
],
84+
children: children
85+
)
86+
}
87+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,29 @@ struct GraphicsTests {
238238
#expect(node.props["systemName"] == .string("star.fill"))
239239
}
240240

241+
@Test("Map emits its region and marker children")
242+
func map() {
243+
struct Screen: View {
244+
@State var region = MKCoordinateRegion(
245+
center: CLLocationCoordinate2D(latitude: -12.046, longitude: -77.043),
246+
span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
247+
)
248+
var body: some View {
249+
Map(coordinateRegion: $region, markers: [
250+
MapMarker("Plaza", coordinate: CLLocationCoordinate2D(latitude: -12.045, longitude: -77.030)),
251+
])
252+
}
253+
}
254+
let node = ViewHost(Screen()).evaluate()
255+
#expect(node.type == "Map")
256+
#expect(node.props["centerLatitude"] == .double(-12.046))
257+
#expect(node.props["spanLongitude"] == .double(0.1))
258+
#expect(node.children.count == 1)
259+
#expect(node.children[0].type == "MapMarker")
260+
#expect(node.children[0].props["title"] == .string("Plaza"))
261+
#expect(node.children[0].props["latitude"] == .double(-12.045))
262+
}
263+
241264
@Test("Overlay emits base and overlay children with alignment")
242265
func overlay() {
243266
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate()

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ struct CatalogEntry: Identifiable {
3636
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
3737
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
3838
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
39+
CatalogEntry(id: "map", title: "Map", screen: AnyCatalogScreen(MapPlayground())),
3940
CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())),
4041
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
4142
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
import MapKit
6+
#endif
7+
8+
struct MapPlayground: View {
9+
@State private var lima = MKCoordinateRegion(
10+
center: CLLocationCoordinate2D(latitude: -12.046, longitude: -77.043),
11+
span: MKCoordinateSpan(latitudeDelta: 0.12, longitudeDelta: 0.12)
12+
)
13+
@State private var cupertino = MKCoordinateRegion(
14+
center: CLLocationCoordinate2D(latitude: 37.334, longitude: -122.009),
15+
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
16+
)
17+
var body: some View {
18+
ScrollView {
19+
VStack(alignment: .leading, spacing: 0) {
20+
Example("Region with markers") {
21+
Map(coordinateRegion: $lima, markers: [
22+
MapMarker("Plaza Mayor", coordinate: CLLocationCoordinate2D(latitude: -12.046, longitude: -77.030)),
23+
MapMarker("Miraflores", coordinate: CLLocationCoordinate2D(latitude: -12.120, longitude: -77.030), tint: .blue),
24+
MapMarker("Callao", coordinate: CLLocationCoordinate2D(latitude: -12.055, longitude: -77.100), tint: .green),
25+
])
26+
}
27+
Example("Plain region") {
28+
Map(coordinateRegion: $cupertino)
29+
.frame(height: 140)
30+
.cornerRadius(12)
31+
}
32+
}
33+
}
34+
}
35+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ private fun RenderResolved(node: ViewNode) {
256256

257257
"LinearGradient" -> RenderGradient(node)
258258

259+
"Map" -> RenderMap(node)
260+
259261
"ProgressView" -> {
260262
val value = node.double("value")
261263
if (value != null) {
@@ -611,6 +613,55 @@ private fun RenderShape(node: ViewNode) {
611613
Box(modifier = node.composeModifiers().background(fill, shape))
612614
}
613615

616+
// A schematic map: the visible region maps linearly onto the box, markers sit
617+
// at their proportional position, and the center coordinate is captioned.
618+
// Real tiles come from a registered composable (maps SDKs need an API key).
619+
@Composable
620+
private fun RenderMap(node: ViewNode) {
621+
val centerLat = node.double("centerLatitude") ?: 0.0
622+
val centerLon = node.double("centerLongitude") ?: 0.0
623+
val spanLat = (node.double("spanLatitude") ?: 1.0).coerceAtLeast(1e-6)
624+
val spanLon = (node.double("spanLongitude") ?: 1.0).coerceAtLeast(1e-6)
625+
Box(
626+
modifier = node.composeModifiers()
627+
.fillMaxWidth()
628+
.height(220.dp)
629+
.clip(RoundedCornerShape(8.dp))
630+
.background(Color(0xFFDCE8DC.toInt()))
631+
.border(1.dp, Color(0xFFB8CCB8.toInt()), RoundedCornerShape(8.dp)),
632+
) {
633+
for (marker in node.children) {
634+
if (marker.type != "MapMarker") continue
635+
val lat = marker.double("latitude") ?: continue
636+
val lon = marker.double("longitude") ?: continue
637+
// normalized region position → alignment bias (north at the top)
638+
val fx = ((lon - (centerLon - spanLon / 2)) / spanLon).coerceIn(0.0, 1.0)
639+
val fy = (((centerLat + spanLat / 2) - lat) / spanLat).coerceIn(0.0, 1.0)
640+
val tint = marker.long("tint")?.let { Color(it.toInt()) } ?: Color(0xFFD32F2F.toInt())
641+
Column(
642+
horizontalAlignment = Alignment.CenterHorizontally,
643+
modifier = Modifier.align(
644+
androidx.compose.ui.BiasAlignment((2 * fx - 1).toFloat(), (2 * fy - 1).toFloat())
645+
),
646+
) {
647+
Box(modifier = Modifier.size(12.dp).clip(CircleShape).background(tint))
648+
Text(marker.string("title") ?: "", fontSize = 11.sp)
649+
}
650+
}
651+
Text(
652+
"${formatCoordinate(centerLat)}, ${formatCoordinate(centerLon)}",
653+
fontSize = 11.sp,
654+
color = Color(0xFF667066.toInt()),
655+
modifier = Modifier.align(Alignment.BottomCenter).padding(4.dp),
656+
)
657+
}
658+
}
659+
660+
private fun formatCoordinate(value: Double): String {
661+
val thousandths = kotlin.math.round(value * 1000) / 1000
662+
return thousandths.toString()
663+
}
664+
614665
// Horizontal/vertical when the endpoints share an axis, else a diagonal linear
615666
// gradient across the frame.
616667
@Composable

0 commit comments

Comments
 (0)