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
Original file line number Diff line number Diff line change
@@ -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<Label: View>: 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<S: StringProtocol>(_ 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"))
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
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 @@ -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())),
Expand Down
33 changes: 33 additions & 0 deletions Demo/App.swiftpm/Sources/LinkPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
24 changes: 24 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 @@ -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
Expand Down Expand Up @@ -342,6 +343,8 @@ private fun RenderResolved(node: ViewNode) {
}
}

"Link" -> RenderLink(node)

"GeometryReader" -> RenderGeometryReader(node)

"LazyVStack" -> RenderLazyStack(node, vertical = true)
Expand Down Expand Up @@ -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"

Expand Down
Loading