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,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<Content: View, Popover: View>: View {
internal let isPresented: Binding<Bool>
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<C: View>(
isPresented: Binding<Bool>,
@ViewBuilder content: () -> C
) -> _PopoverView<Self, C> {
_PopoverView(isPresented: isPresented, content: self, popover: content())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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 @@ -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())),
Expand Down
42 changes: 42 additions & 0 deletions Demo/App.swiftpm/Sources/PopoverPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
38 changes: 38 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 @@ -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
Expand Down Expand Up @@ -416,6 +418,8 @@ private fun RenderResolved(node: ViewNode) {

"Stepper" -> RenderStepper(node)

"Popover" -> RenderPopover(node)

"ContextMenu" -> RenderContextMenu(node)

"Menu" -> RenderMenu(node)
Expand Down Expand Up @@ -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)
Expand Down
Loading