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,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<Content: View, MenuItems: View>: 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<M: View>(@ViewBuilder menuItems: () -> M) -> _ContextMenuView<Self, M> {
_ContextMenuView(content: self, menuItems: menuItems())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
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 @@ -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())),
Expand Down
47 changes: 47 additions & 0 deletions Demo/App.swiftpm/Sources/ContextMenuPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
34 changes: 34 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 @@ -416,6 +416,8 @@ private fun RenderResolved(node: ViewNode) {

"Stepper" -> RenderStepper(node)

"ContextMenu" -> RenderContextMenu(node)

"Menu" -> RenderMenu(node)

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