Skip to content

Commit 29356bc

Browse files
authored
Merge pull request #61 from PureSwift/feature/link
Add Link
2 parents db5cc6f + a4ad2c8 commit 29356bc

5 files changed

Lines changed: 121 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//
2+
// Link.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Opens a URL outside the app. Nothing crosses back into Swift: the
6+
// interpreter hands the address to Compose's UriHandler, so the platform
7+
// decides which app answers it and no callback or bridge entry is involved.
8+
//
9+
10+
import Foundation
11+
12+
public struct Link<Label: View>: View {
13+
14+
internal let destination: URL
15+
internal let label: Label
16+
17+
public init(destination: URL, @ViewBuilder label: () -> Label) {
18+
self.destination = destination
19+
self.label = label()
20+
}
21+
22+
public typealias Body = Never
23+
}
24+
25+
public extension Link where Label == Text {
26+
init<S: StringProtocol>(_ title: S, destination: URL) {
27+
self.init(destination: destination) { Text(title) }
28+
}
29+
}
30+
31+
extension Link: PrimitiveView {
32+
public func _render(in context: ResolveContext) -> RenderNode {
33+
RenderNode(
34+
type: "Link",
35+
id: context.path,
36+
props: ["url": .string(destination.absoluteString)],
37+
children: Evaluator.resolveChildren(label, context.descending("label"))
38+
)
39+
}
40+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,29 @@ struct ModifierTests {
282282
#expect(empty.props["url"] == nil)
283283
}
284284

285+
@Test("Link carries its destination and its label")
286+
func link() {
287+
let node = ViewHost(
288+
Link("Swift.org", destination: URL(string: "https://swift.org")!)
289+
).evaluate()
290+
#expect(node.type == "Link")
291+
#expect(node.props["url"] == .string("https://swift.org"))
292+
#expect(firstTextString(node) == "Swift.org")
293+
// no callback: the interpreter opens the address itself
294+
#expect(node.props["onTap"] == nil)
295+
}
296+
297+
@Test("Link accepts an arbitrary label")
298+
func linkCustomLabel() {
299+
let node = ViewHost(
300+
Link(destination: URL(string: "https://example.com/docs")!) {
301+
HStack { Text("Read"); Text("the docs") }
302+
}
303+
).evaluate()
304+
#expect(node.props["url"] == .string("https://example.com/docs"))
305+
#expect(node.children.first?.type == "HStack")
306+
}
307+
285308
@Test("onAppear and onDisappear emit distinct callback kinds")
286309
func appearDisappear() {
287310
let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate()

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ struct CatalogEntry: Identifiable {
4242
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
4343
CatalogEntry(id: "image", title: "Images", screen: AnyCatalogScreen(ImagePlayground())),
4444
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
45+
CatalogEntry(id: "link", title: "Link", screen: AnyCatalogScreen(LinkPlayground())),
4546
CatalogEntry(id: "map", title: "Map", screen: AnyCatalogScreen(MapPlayground())),
4647
CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground())),
4748
CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
import Foundation
7+
8+
struct LinkPlayground: View {
9+
var body: some View {
10+
ScrollView {
11+
VStack(alignment: .leading, spacing: 0) {
12+
Example("Link with a title") {
13+
Link("Open swift.org", destination: URL(string: "https://swift.org")!)
14+
}
15+
Example("Link with a custom label") {
16+
Link(destination: URL(string: "https://developer.apple.com/documentation/swiftui")!) {
17+
HStack(spacing: 6) {
18+
Image(systemName: "star.fill")
19+
Text("SwiftUI documentation")
20+
}
21+
}
22+
}
23+
Example("Link inside a sentence") {
24+
VStack(alignment: .leading, spacing: 6) {
25+
Text("Questions? The forums are a good place to start.")
26+
Link("forums.swift.org", destination: URL(string: "https://forums.swift.org")!)
27+
}
28+
}
29+
}
30+
}
31+
.navigationTitle("Link")
32+
}
33+
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ import androidx.compose.ui.semantics.selected
158158
import androidx.compose.ui.semantics.Role
159159
import androidx.compose.ui.semantics.role
160160
import androidx.compose.ui.platform.testTag
161+
import androidx.compose.ui.platform.LocalUriHandler
161162
import androidx.compose.ui.graphics.Color
162163
import androidx.compose.ui.layout.ContentScale
163164
import androidx.compose.ui.graphics.ImageBitmap
@@ -342,6 +343,8 @@ private fun RenderResolved(node: ViewNode) {
342343
}
343344
}
344345

346+
"Link" -> RenderLink(node)
347+
345348
"GeometryReader" -> RenderGeometryReader(node)
346349

347350
"LazyVStack" -> RenderLazyStack(node, vertical = true)
@@ -577,6 +580,27 @@ private fun RenderGeometryReader(node: ViewNode) {
577580
}
578581
}
579582

583+
// Handing the address to the platform's UriHandler keeps the whole hop out of
584+
// Swift — no callback, no bridge entry. The label takes the accent colour so it
585+
// reads as a link, unless something upstream already tinted it.
586+
@Composable
587+
private fun RenderLink(node: ViewNode) {
588+
val url = node.string("url")
589+
val handler = LocalUriHandler.current
590+
val accent = LocalTint.current ?: MaterialTheme.colorScheme.primary
591+
Row(
592+
verticalAlignment = Alignment.CenterVertically,
593+
modifier = node.composeModifiers().clickable(enabled = url != null && node.isEnabled()) {
594+
// a malformed or unhandled address must not take the app down
595+
url?.let { runCatching { handler.openUri(it) } }
596+
},
597+
) {
598+
CompositionLocalProvider(LocalInheritedColor provides accent) {
599+
RenderChildren(node)
600+
}
601+
}
602+
}
603+
580604
private fun ViewNode.isPresentation(): Boolean =
581605
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"
582606

0 commit comments

Comments
 (0)