From 44c8e2846cd68b23667ce5d61cc584ae1d049bf1 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 08:31:23 -0400 Subject: [PATCH 1/5] Add contextMenu attaching a long-press menu --- .../Primitives/ContextMenu.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ContextMenu.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ContextMenu.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ContextMenu.swift new file mode 100644 index 0000000..ad943eb --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ContextMenu.swift @@ -0,0 +1,35 @@ +// +// ContextMenu.swift +// AndroidSwiftUICore +// +// A menu revealed by long-pressing a view. Like a sheet, the menu items are +// views, so they travel as children of the wrapped content rather than through +// a scalar modifier: `contentCount` marks how many leading children are the +// pressable content, and the rest are the menu. +// + +public struct _ContextMenuView: View { + internal let content: Content + internal let menuItems: MenuItems + public typealias Body = Never +} + +extension _ContextMenuView: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let contentNodes = Evaluator.resolveChildren(content, context.descending("content")) + let menuNodes = Evaluator.resolveChildren(menuItems, context.descending("menu")) + return RenderNode( + type: "ContextMenu", + id: context.path, + props: ["contentCount": .int(contentNodes.count)], + children: contentNodes + menuNodes + ) + } +} + +public extension View { + /// Adds a menu shown when the view is long-pressed. + func contextMenu(@ViewBuilder menuItems: () -> M) -> _ContextMenuView { + _ContextMenuView(content: self, menuItems: menuItems()) + } +} From 6d4ad4ce02b86557a94b0063fb0be5dda634adaa Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 08:31:23 -0400 Subject: [PATCH 2/5] Reveal a context menu on long press --- .../kotlin/com/pureswift/swiftui/Render.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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 a577803..dc22748 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -416,6 +416,8 @@ private fun RenderResolved(node: ViewNode) { "Stepper" -> RenderStepper(node) + "ContextMenu" -> RenderContextMenu(node) + "Menu" -> RenderMenu(node) "DatePicker" -> RenderDatePicker(node) @@ -894,6 +896,38 @@ private fun formatDateMillis(millis: Long): String { return format.format(java.util.Date(millis)) } +// Long-press the content to reveal a dropdown of the menu items. children are +// [content..., menuItem...]; contentCount splits them. +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RenderContextMenu(node: ViewNode) { + val contentCount = node.long("contentCount")?.toInt() ?: 0 + var expanded by remember { mutableStateOf(false) } + Box { + Column( + modifier = node.composeModifiers().combinedClickable( + onClick = {}, + onLongClick = { expanded = true }, + ) + ) { + node.children.take(contentCount).forEach { RenderChild(it) } + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + for (child in node.children.drop(contentCount)) { + if (child.isPresentation()) continue + val onTap = child.long("onTap") + DropdownMenuItem( + text = { Text(pickerLabel(child)) }, + onClick = { + expanded = false + onTap?.let { SwiftBridge.sink.invokeVoid(it) } + }, + ) + } + } + } +} + // A trigger button opening a dropdown; each child Button becomes a menu item // firing its own tap callback. @Composable From cc1d915af2a03f34c6e1fbc412857cdab91d0b03 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 08:31:23 -0400 Subject: [PATCH 3/5] Test context menu wrapping and callbacks --- .../EvaluatorTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index eb0b7fe..b82e1dc 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -342,6 +342,32 @@ struct ModifierTests { } } + @Test("contextMenu wraps content and carries its menu items") + func contextMenu() { + var deleted = false + let host = ViewHost( + Text("Long press me") + .contextMenu { + Button("Rename") {} + Button("Delete") { deleted = true } + } + ) + let node = host.evaluate() + #expect(node.type == "ContextMenu") + #expect(node.props["contentCount"] == .int(1)) + // 1 content + 2 menu items + #expect(node.children.count == 3) + #expect(firstTextString(node.children[0]) == "Long press me") + + // the menu item's callback still reaches the closure + let deleteItem = node.children[2] + guard case .int(let id)? = deleteItem.props["onTap"] else { + Issue.record("menu item lost its callback"); return + } + host.callbacks.invokeVoid(Int64(id)) + #expect(deleted) + } + @Test("onAppear and onDisappear emit distinct callback kinds") func appearDisappear() { let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate() From 927ac7ef2d5948bfe294697aed9a9e5977b5d0f4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 08:31:23 -0400 Subject: [PATCH 4/5] Add a context menu playground --- .../Sources/ContextMenuPlaygrounds.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/ContextMenuPlaygrounds.swift diff --git a/Demo/App.swiftpm/Sources/ContextMenuPlaygrounds.swift b/Demo/App.swiftpm/Sources/ContextMenuPlaygrounds.swift new file mode 100644 index 0000000..c177750 --- /dev/null +++ b/Demo/App.swiftpm/Sources/ContextMenuPlaygrounds.swift @@ -0,0 +1,47 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct ContextMenuPlayground: View { + + @State private var status = "Long-press a row" + @State private var pinned = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Long-press for a menu") { + VStack(alignment: .leading, spacing: 12) { + Text("Project Alpha") + .foregroundColor(.white) + .padding() + .background(Color.blue) + .cornerRadius(8) + .contextMenu { + Button("Rename") { status = "Renamed Alpha" } + Button("Duplicate") { status = "Duplicated Alpha" } + Button("Delete") { status = "Deleted Alpha" } + } + Text(status) + } + } + Example("Menu that reads state") { + VStack(alignment: .leading, spacing: 12) { + Text(pinned ? "📌 Pinned note" : "Note") + .foregroundColor(.white) + .padding() + .background(Color.purple) + .cornerRadius(8) + .contextMenu { + Button(pinned ? "Unpin" : "Pin") { pinned.toggle() } + Button("Share") { status = "Shared note" } + } + } + } + } + } + .navigationTitle("Context Menu") + } +} From 62227ff53dab0e2d36dc90cd873df7c74dab6b46 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 08:31:23 -0400 Subject: [PATCH 5/5] List the context menu playground in the catalog --- Demo/App.swiftpm/Sources/Catalog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 4c4be58..f242155 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -58,6 +58,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())), CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())), CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())), + CatalogEntry(id: "contextmenu", title: "Context Menu", screen: AnyCatalogScreen(ContextMenuPlayground())), CatalogEntry(id: "gesture", title: "Gestures", screen: AnyCatalogScreen(GesturePlayground())), CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())), CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())),