diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Link.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Link.swift new file mode 100644 index 0000000..43abc1c --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Link.swift @@ -0,0 +1,40 @@ +// +// Link.swift +// AndroidSwiftUICore +// +// Opens a URL outside the app. Nothing crosses back into Swift: the +// interpreter hands the address to Compose's UriHandler, so the platform +// decides which app answers it and no callback or bridge entry is involved. +// + +import Foundation + +public struct Link: View { + + internal let destination: URL + internal let label: Label + + public init(destination: URL, @ViewBuilder label: () -> Label) { + self.destination = destination + self.label = label() + } + + public typealias Body = Never +} + +public extension Link where Label == Text { + init(_ title: S, destination: URL) { + self.init(destination: destination) { Text(title) } + } +} + +extension Link: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + RenderNode( + type: "Link", + id: context.path, + props: ["url": .string(destination.absoluteString)], + children: Evaluator.resolveChildren(label, context.descending("label")) + ) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index c884ff3..0c6789d 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -282,6 +282,29 @@ struct ModifierTests { #expect(empty.props["url"] == nil) } + @Test("Link carries its destination and its label") + func link() { + let node = ViewHost( + Link("Swift.org", destination: URL(string: "https://swift.org")!) + ).evaluate() + #expect(node.type == "Link") + #expect(node.props["url"] == .string("https://swift.org")) + #expect(firstTextString(node) == "Swift.org") + // no callback: the interpreter opens the address itself + #expect(node.props["onTap"] == nil) + } + + @Test("Link accepts an arbitrary label") + func linkCustomLabel() { + let node = ViewHost( + Link(destination: URL(string: "https://example.com/docs")!) { + HStack { Text("Read"); Text("the docs") } + } + ).evaluate() + #expect(node.props["url"] == .string("https://example.com/docs")) + #expect(node.children.first?.type == "HStack") + } + @Test("onAppear and onDisappear emit distinct callback kinds") func appearDisappear() { let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate() diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 010f52f..3f5ecd1 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -42,6 +42,7 @@ struct CatalogEntry: Identifiable { 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: "link", title: "Link", screen: AnyCatalogScreen(LinkPlayground())), CatalogEntry(id: "map", title: "Map", screen: AnyCatalogScreen(MapPlayground())), CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground())), CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())), diff --git a/Demo/App.swiftpm/Sources/LinkPlaygrounds.swift b/Demo/App.swiftpm/Sources/LinkPlaygrounds.swift new file mode 100644 index 0000000..00fe25b --- /dev/null +++ b/Demo/App.swiftpm/Sources/LinkPlaygrounds.swift @@ -0,0 +1,33 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif +import Foundation + +struct LinkPlayground: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Link with a title") { + Link("Open swift.org", destination: URL(string: "https://swift.org")!) + } + Example("Link with a custom label") { + Link(destination: URL(string: "https://developer.apple.com/documentation/swiftui")!) { + HStack(spacing: 6) { + Image(systemName: "star.fill") + Text("SwiftUI documentation") + } + } + } + Example("Link inside a sentence") { + VStack(alignment: .leading, spacing: 6) { + Text("Questions? The forums are a good place to start.") + Link("forums.swift.org", destination: URL(string: "https://forums.swift.org")!) + } + } + } + } + .navigationTitle("Link") + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 01dbdfe..3498913 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -158,6 +158,7 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.ImageBitmap @@ -342,6 +343,8 @@ private fun RenderResolved(node: ViewNode) { } } + "Link" -> RenderLink(node) + "GeometryReader" -> RenderGeometryReader(node) "LazyVStack" -> RenderLazyStack(node, vertical = true) @@ -577,6 +580,27 @@ private fun RenderGeometryReader(node: ViewNode) { } } +// Handing the address to the platform's UriHandler keeps the whole hop out of +// Swift — no callback, no bridge entry. The label takes the accent colour so it +// reads as a link, unless something upstream already tinted it. +@Composable +private fun RenderLink(node: ViewNode) { + val url = node.string("url") + val handler = LocalUriHandler.current + val accent = LocalTint.current ?: MaterialTheme.colorScheme.primary + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = node.composeModifiers().clickable(enabled = url != null && node.isEnabled()) { + // a malformed or unhandled address must not take the app down + url?.let { runCatching { handler.openUri(it) } } + }, + ) { + CompositionLocalProvider(LocalInheritedColor provides accent) { + RenderChildren(node) + } + } +} + private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"