Skip to content

Commit e1dfca4

Browse files
authored
Merge pull request #66 from PureSwift/feature/context-menu
Add contextMenu
2 parents f523ed6 + 62227ff commit e1dfca4

5 files changed

Lines changed: 143 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// ContextMenu.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A menu revealed by long-pressing a view. Like a sheet, the menu items are
6+
// views, so they travel as children of the wrapped content rather than through
7+
// a scalar modifier: `contentCount` marks how many leading children are the
8+
// pressable content, and the rest are the menu.
9+
//
10+
11+
public struct _ContextMenuView<Content: View, MenuItems: View>: View {
12+
internal let content: Content
13+
internal let menuItems: MenuItems
14+
public typealias Body = Never
15+
}
16+
17+
extension _ContextMenuView: PrimitiveView {
18+
public func _render(in context: ResolveContext) -> RenderNode {
19+
let contentNodes = Evaluator.resolveChildren(content, context.descending("content"))
20+
let menuNodes = Evaluator.resolveChildren(menuItems, context.descending("menu"))
21+
return RenderNode(
22+
type: "ContextMenu",
23+
id: context.path,
24+
props: ["contentCount": .int(contentNodes.count)],
25+
children: contentNodes + menuNodes
26+
)
27+
}
28+
}
29+
30+
public extension View {
31+
/// Adds a menu shown when the view is long-pressed.
32+
func contextMenu<M: View>(@ViewBuilder menuItems: () -> M) -> _ContextMenuView<Self, M> {
33+
_ContextMenuView(content: self, menuItems: menuItems())
34+
}
35+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,32 @@ struct ModifierTests {
342342
}
343343
}
344344

345+
@Test("contextMenu wraps content and carries its menu items")
346+
func contextMenu() {
347+
var deleted = false
348+
let host = ViewHost(
349+
Text("Long press me")
350+
.contextMenu {
351+
Button("Rename") {}
352+
Button("Delete") { deleted = true }
353+
}
354+
)
355+
let node = host.evaluate()
356+
#expect(node.type == "ContextMenu")
357+
#expect(node.props["contentCount"] == .int(1))
358+
// 1 content + 2 menu items
359+
#expect(node.children.count == 3)
360+
#expect(firstTextString(node.children[0]) == "Long press me")
361+
362+
// the menu item's callback still reaches the closure
363+
let deleteItem = node.children[2]
364+
guard case .int(let id)? = deleteItem.props["onTap"] else {
365+
Issue.record("menu item lost its callback"); return
366+
}
367+
host.callbacks.invokeVoid(Int64(id))
368+
#expect(deleted)
369+
}
370+
345371
@Test("onAppear and onDisappear emit distinct callback kinds")
346372
func appearDisappear() {
347373
let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate()

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ struct CatalogEntry: Identifiable {
5858
CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())),
5959
CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())),
6060
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
61+
CatalogEntry(id: "contextmenu", title: "Context Menu", screen: AnyCatalogScreen(ContextMenuPlayground())),
6162
CatalogEntry(id: "gesture", title: "Gestures", screen: AnyCatalogScreen(GesturePlayground())),
6263
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
6364
CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())),
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct ContextMenuPlayground: View {
8+
9+
@State private var status = "Long-press a row"
10+
@State private var pinned = false
11+
12+
var body: some View {
13+
ScrollView {
14+
VStack(alignment: .leading, spacing: 0) {
15+
Example("Long-press for a menu") {
16+
VStack(alignment: .leading, spacing: 12) {
17+
Text("Project Alpha")
18+
.foregroundColor(.white)
19+
.padding()
20+
.background(Color.blue)
21+
.cornerRadius(8)
22+
.contextMenu {
23+
Button("Rename") { status = "Renamed Alpha" }
24+
Button("Duplicate") { status = "Duplicated Alpha" }
25+
Button("Delete") { status = "Deleted Alpha" }
26+
}
27+
Text(status)
28+
}
29+
}
30+
Example("Menu that reads state") {
31+
VStack(alignment: .leading, spacing: 12) {
32+
Text(pinned ? "📌 Pinned note" : "Note")
33+
.foregroundColor(.white)
34+
.padding()
35+
.background(Color.purple)
36+
.cornerRadius(8)
37+
.contextMenu {
38+
Button(pinned ? "Unpin" : "Pin") { pinned.toggle() }
39+
Button("Share") { status = "Shared note" }
40+
}
41+
}
42+
}
43+
}
44+
}
45+
.navigationTitle("Context Menu")
46+
}
47+
}

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ private fun RenderResolved(node: ViewNode) {
416416

417417
"Stepper" -> RenderStepper(node)
418418

419+
"ContextMenu" -> RenderContextMenu(node)
420+
419421
"Menu" -> RenderMenu(node)
420422

421423
"DatePicker" -> RenderDatePicker(node)
@@ -894,6 +896,38 @@ private fun formatDateMillis(millis: Long): String {
894896
return format.format(java.util.Date(millis))
895897
}
896898

899+
// Long-press the content to reveal a dropdown of the menu items. children are
900+
// [content..., menuItem...]; contentCount splits them.
901+
@OptIn(ExperimentalFoundationApi::class)
902+
@Composable
903+
private fun RenderContextMenu(node: ViewNode) {
904+
val contentCount = node.long("contentCount")?.toInt() ?: 0
905+
var expanded by remember { mutableStateOf(false) }
906+
Box {
907+
Column(
908+
modifier = node.composeModifiers().combinedClickable(
909+
onClick = {},
910+
onLongClick = { expanded = true },
911+
)
912+
) {
913+
node.children.take(contentCount).forEach { RenderChild(it) }
914+
}
915+
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
916+
for (child in node.children.drop(contentCount)) {
917+
if (child.isPresentation()) continue
918+
val onTap = child.long("onTap")
919+
DropdownMenuItem(
920+
text = { Text(pickerLabel(child)) },
921+
onClick = {
922+
expanded = false
923+
onTap?.let { SwiftBridge.sink.invokeVoid(it) }
924+
},
925+
)
926+
}
927+
}
928+
}
929+
}
930+
897931
// A trigger button opening a dropdown; each child Button becomes a menu item
898932
// firing its own tap callback.
899933
@Composable

0 commit comments

Comments
 (0)