Skip to content

Commit 7760915

Browse files
authored
Merge pull request #67 from PureSwift/feature/popover
Add popover
2 parents e1dfca4 + 7571a62 commit 7760915

5 files changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// Popover.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A small piece of content floated next to a view while `isPresented` is true.
6+
// Anchored to a specific view (not screen-level like a sheet), so it emits a
7+
// wrapper node — `anchorCount` leading children are the anchor, the rest are the
8+
// popover body — with the binding's state and a dismiss callback. Dismissing
9+
// anywhere off the bubble writes the flag back.
10+
//
11+
12+
public struct _PopoverView<Content: View, Popover: View>: View {
13+
internal let isPresented: Binding<Bool>
14+
internal let content: Content
15+
internal let popover: Popover
16+
public typealias Body = Never
17+
}
18+
19+
extension _PopoverView: PrimitiveView {
20+
public func _render(in context: ResolveContext) -> RenderNode {
21+
let anchorNodes = Evaluator.resolveChildren(content, context.descending("content"))
22+
let binding = isPresented
23+
24+
var props: [String: PropValue] = [
25+
"anchorCount": .int(anchorNodes.count),
26+
"isPresented": .bool(binding.wrappedValue),
27+
]
28+
// resolve the body only while shown; a dismiss callback lets the bubble
29+
// write the flag back on an outside tap
30+
var bodyNodes: [RenderNode] = []
31+
if binding.wrappedValue {
32+
let dismissID = context.callbacks.register(.void { binding.wrappedValue = false })
33+
props["onDismiss"] = .int(Int(dismissID))
34+
var popoverContext = context.descending("popover")
35+
popoverContext.environment.values.dismiss = DismissAction { binding.wrappedValue = false }
36+
bodyNodes = Evaluator.resolveChildren(popover, popoverContext)
37+
}
38+
return RenderNode(
39+
type: "Popover",
40+
id: context.path,
41+
props: props,
42+
children: anchorNodes + bodyNodes
43+
)
44+
}
45+
}
46+
47+
public extension View {
48+
func popover<C: View>(
49+
isPresented: Binding<Bool>,
50+
@ViewBuilder content: () -> C
51+
) -> _PopoverView<Self, C> {
52+
_PopoverView(isPresented: isPresented, content: self, popover: content())
53+
}
54+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,39 @@ struct NavigationTests {
254254
#expect(plainSheet?.props["detents"] == .array([]))
255255
}
256256

257+
@Test("A popover shows its body only while presented, and a tap dismisses it")
258+
func popover() {
259+
struct Screen: View {
260+
@State var shown = false
261+
var body: some View {
262+
Button("Show") { shown = true }
263+
.popover(isPresented: $shown) { Text("Popover body") }
264+
}
265+
}
266+
let host = ViewHost(Screen())
267+
var node = host.evaluate()
268+
#expect(node.type == "Popover")
269+
#expect(node.props["isPresented"] == .bool(false))
270+
#expect(node.props["anchorCount"] == .int(1))
271+
// collapsed: only the anchor is present, no body resolved
272+
#expect(node.children.count == 1)
273+
274+
host.callbacks.invokeVoid(findOnTap(node)!)
275+
node = host.evaluate()
276+
#expect(node.props["isPresented"] == .bool(true))
277+
#expect(node.children.count == 2) // anchor + body
278+
#expect(firstTextString(node.children[1]) == "Popover body")
279+
280+
// an outside tap dismisses through the callback
281+
guard case .int(let dismiss)? = node.props["onDismiss"] else {
282+
Issue.record("missing dismiss callback"); return
283+
}
284+
host.callbacks.invokeVoid(Int64(dismiss))
285+
node = host.evaluate()
286+
#expect(node.props["isPresented"] == .bool(false))
287+
#expect(node.children.count == 1)
288+
}
289+
257290
@Test("TabView emits tabs with their item labels and selection")
258291
func tabs() {
259292
struct Screen: View {

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ struct CatalogEntry: Identifiable {
6464
CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())),
6565
CatalogEntry(id: "toolbar", title: "Toolbar", screen: AnyCatalogScreen(ToolbarPlayground())),
6666
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),
67+
CatalogEntry(id: "popover", title: "Popover", screen: AnyCatalogScreen(PopoverPlayground())),
6768
CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())),
6869
CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())),
6970
CatalogEntry(id: "accessibility", title: "Accessibility", screen: AnyCatalogScreen(AccessibilityPlayground())),
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct PopoverPlayground: View {
8+
9+
@State private var infoShown = false
10+
@State private var chooserShown = false
11+
@State private var picked = "nothing"
12+
13+
var body: some View {
14+
ScrollView {
15+
VStack(alignment: .leading, spacing: 0) {
16+
Example("Info popover") {
17+
Button("Show details") { infoShown = true }
18+
.popover(isPresented: $infoShown) {
19+
VStack(alignment: .leading, spacing: 8) {
20+
Text("Quick details")
21+
Text("This bubble is anchored to the button.")
22+
Button("Got it") { infoShown = false }
23+
}
24+
}
25+
}
26+
Example("Popover that reports a choice") {
27+
VStack(alignment: .leading, spacing: 8) {
28+
Text("Picked: \(picked)")
29+
Button("Choose") { chooserShown = true }
30+
.popover(isPresented: $chooserShown) {
31+
VStack(alignment: .leading, spacing: 8) {
32+
Button("Option A") { picked = "A"; chooserShown = false }
33+
Button("Option B") { picked = "B"; chooserShown = false }
34+
}
35+
}
36+
}
37+
}
38+
}
39+
}
40+
.navigationTitle("Popover")
41+
}
42+
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ import androidx.compose.ui.semantics.Role
161161
import androidx.compose.ui.semantics.role
162162
import androidx.compose.ui.platform.testTag
163163
import androidx.compose.ui.platform.LocalUriHandler
164+
import androidx.compose.ui.window.Popup
165+
import androidx.compose.ui.window.PopupProperties
164166
import androidx.compose.ui.graphics.Color
165167
import androidx.compose.ui.layout.ContentScale
166168
import androidx.compose.ui.graphics.ImageBitmap
@@ -416,6 +418,8 @@ private fun RenderResolved(node: ViewNode) {
416418

417419
"Stepper" -> RenderStepper(node)
418420

421+
"Popover" -> RenderPopover(node)
422+
419423
"ContextMenu" -> RenderContextMenu(node)
420424

421425
"Menu" -> RenderMenu(node)
@@ -896,6 +900,40 @@ private fun formatDateMillis(millis: Long): String {
896900
return format.format(java.util.Date(millis))
897901
}
898902

903+
// Anchors a floating bubble to its content while presented. children are
904+
// [anchor..., body...]; anchorCount splits them. Dismissing off the bubble
905+
// writes the bound flag back through onDismiss.
906+
@OptIn(ExperimentalMaterial3Api::class)
907+
@Composable
908+
private fun RenderPopover(node: ViewNode) {
909+
val anchorCount = node.long("anchorCount")?.toInt() ?: 0
910+
val presented = node.string("isPresented") == "true"
911+
val onDismiss = node.long("onDismiss")
912+
Box {
913+
Column(modifier = node.composeModifiers()) {
914+
node.children.take(anchorCount).forEach { RenderChild(it) }
915+
}
916+
if (presented) {
917+
Popup(
918+
alignment = Alignment.TopStart,
919+
offset = IntOffset(0, 24),
920+
onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } },
921+
properties = PopupProperties(focusable = true),
922+
) {
923+
Surface(
924+
shadowElevation = 8.dp,
925+
tonalElevation = 2.dp,
926+
shape = RoundedCornerShape(12.dp),
927+
) {
928+
Column(modifier = Modifier.padding(16.dp)) {
929+
node.children.drop(anchorCount).forEach { RenderChild(it) }
930+
}
931+
}
932+
}
933+
}
934+
}
935+
}
936+
899937
// Long-press the content to reveal a dropdown of the menu items. children are
900938
// [content..., menuItem...]; contentCount splits them.
901939
@OptIn(ExperimentalFoundationApi::class)

0 commit comments

Comments
 (0)