diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Popover.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Popover.swift new file mode 100644 index 0000000..8a21363 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Popover.swift @@ -0,0 +1,54 @@ +// +// Popover.swift +// AndroidSwiftUICore +// +// A small piece of content floated next to a view while `isPresented` is true. +// Anchored to a specific view (not screen-level like a sheet), so it emits a +// wrapper node — `anchorCount` leading children are the anchor, the rest are the +// popover body — with the binding's state and a dismiss callback. Dismissing +// anywhere off the bubble writes the flag back. +// + +public struct _PopoverView: View { + internal let isPresented: Binding + internal let content: Content + internal let popover: Popover + public typealias Body = Never +} + +extension _PopoverView: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let anchorNodes = Evaluator.resolveChildren(content, context.descending("content")) + let binding = isPresented + + var props: [String: PropValue] = [ + "anchorCount": .int(anchorNodes.count), + "isPresented": .bool(binding.wrappedValue), + ] + // resolve the body only while shown; a dismiss callback lets the bubble + // write the flag back on an outside tap + var bodyNodes: [RenderNode] = [] + if binding.wrappedValue { + let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + props["onDismiss"] = .int(Int(dismissID)) + var popoverContext = context.descending("popover") + popoverContext.environment.values.dismiss = DismissAction { binding.wrappedValue = false } + bodyNodes = Evaluator.resolveChildren(popover, popoverContext) + } + return RenderNode( + type: "Popover", + id: context.path, + props: props, + children: anchorNodes + bodyNodes + ) + } +} + +public extension View { + func popover( + isPresented: Binding, + @ViewBuilder content: () -> C + ) -> _PopoverView { + _PopoverView(isPresented: isPresented, content: self, popover: content()) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift index 7acfdd9..e1ec417 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift @@ -254,6 +254,39 @@ struct NavigationTests { #expect(plainSheet?.props["detents"] == .array([])) } + @Test("A popover shows its body only while presented, and a tap dismisses it") + func popover() { + struct Screen: View { + @State var shown = false + var body: some View { + Button("Show") { shown = true } + .popover(isPresented: $shown) { Text("Popover body") } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.type == "Popover") + #expect(node.props["isPresented"] == .bool(false)) + #expect(node.props["anchorCount"] == .int(1)) + // collapsed: only the anchor is present, no body resolved + #expect(node.children.count == 1) + + host.callbacks.invokeVoid(findOnTap(node)!) + node = host.evaluate() + #expect(node.props["isPresented"] == .bool(true)) + #expect(node.children.count == 2) // anchor + body + #expect(firstTextString(node.children[1]) == "Popover body") + + // an outside tap dismisses through the callback + guard case .int(let dismiss)? = node.props["onDismiss"] else { + Issue.record("missing dismiss callback"); return + } + host.callbacks.invokeVoid(Int64(dismiss)) + node = host.evaluate() + #expect(node.props["isPresented"] == .bool(false)) + #expect(node.children.count == 1) + } + @Test("TabView emits tabs with their item labels and selection") func tabs() { struct Screen: View { diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index f242155..b87f803 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -64,6 +64,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())), CatalogEntry(id: "toolbar", title: "Toolbar", screen: AnyCatalogScreen(ToolbarPlayground())), CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())), + CatalogEntry(id: "popover", title: "Popover", screen: AnyCatalogScreen(PopoverPlayground())), CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())), CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())), CatalogEntry(id: "accessibility", title: "Accessibility", screen: AnyCatalogScreen(AccessibilityPlayground())), diff --git a/Demo/App.swiftpm/Sources/PopoverPlaygrounds.swift b/Demo/App.swiftpm/Sources/PopoverPlaygrounds.swift new file mode 100644 index 0000000..ebcc7c5 --- /dev/null +++ b/Demo/App.swiftpm/Sources/PopoverPlaygrounds.swift @@ -0,0 +1,42 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct PopoverPlayground: View { + + @State private var infoShown = false + @State private var chooserShown = false + @State private var picked = "nothing" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Info popover") { + Button("Show details") { infoShown = true } + .popover(isPresented: $infoShown) { + VStack(alignment: .leading, spacing: 8) { + Text("Quick details") + Text("This bubble is anchored to the button.") + Button("Got it") { infoShown = false } + } + } + } + Example("Popover that reports a choice") { + VStack(alignment: .leading, spacing: 8) { + Text("Picked: \(picked)") + Button("Choose") { chooserShown = true } + .popover(isPresented: $chooserShown) { + VStack(alignment: .leading, spacing: 8) { + Button("Option A") { picked = "A"; chooserShown = false } + Button("Option B") { picked = "B"; chooserShown = false } + } + } + } + } + } + } + .navigationTitle("Popover") + } +} 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 dc22748..1e70aaa 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -161,6 +161,8 @@ 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.window.Popup +import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.ImageBitmap @@ -416,6 +418,8 @@ private fun RenderResolved(node: ViewNode) { "Stepper" -> RenderStepper(node) + "Popover" -> RenderPopover(node) + "ContextMenu" -> RenderContextMenu(node) "Menu" -> RenderMenu(node) @@ -896,6 +900,40 @@ private fun formatDateMillis(millis: Long): String { return format.format(java.util.Date(millis)) } +// Anchors a floating bubble to its content while presented. children are +// [anchor..., body...]; anchorCount splits them. Dismissing off the bubble +// writes the bound flag back through onDismiss. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenderPopover(node: ViewNode) { + val anchorCount = node.long("anchorCount")?.toInt() ?: 0 + val presented = node.string("isPresented") == "true" + val onDismiss = node.long("onDismiss") + Box { + Column(modifier = node.composeModifiers()) { + node.children.take(anchorCount).forEach { RenderChild(it) } + } + if (presented) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(0, 24), + onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } }, + properties = PopupProperties(focusable = true), + ) { + Surface( + shadowElevation = 8.dp, + tonalElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + node.children.drop(anchorCount).forEach { RenderChild(it) } + } + } + } + } + } +} + // Long-press the content to reveal a dropdown of the menu items. children are // [content..., menuItem...]; contentCount splits them. @OptIn(ExperimentalFoundationApi::class)