Skip to content

Commit 5597a10

Browse files
committed
Add onDrop / onHover view modifiers for drag-and-drop reception
Implemented hover and drop reception support in ActionUI, enabling views to react to pointer hover events and accept dropped content via SwiftUI's .onHover and .onDrop modifiers. Changes: New modifiers: Four JSON properties added to base View schema: onHoverActionID — fires on pointer enter/exit with { "isHovering": Bool } onDropTypes — UTType identifiers accepted as drop targets onDropActionID — fires when items are dropped with { "items": [String], "location": {...} } onDropTargetedActionID — fires when drag enters/exits drop zone with { "isTargeted": Bool } Implementation: DropHelper.swift — generic DropModifierView handling targeted state and item extraction from NSItemProvider View.swift — applies modifiers via applyViewModifiers (macOS-only for onHover) Design decision: Drag initiation (onDrag) excluded — SwiftUI requires synchronous NSItemProvider at drag-start, but ActionUI payload lives in host app, not JSON
1 parent e62f6d6 commit 5597a10

7 files changed

Lines changed: 796 additions & 2 deletions

File tree

ActionUI/Helpers/DropHelper.swift

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Helpers/DropHelper.swift
2+
//
3+
// DropModifierView: wraps a content view with SwiftUI .onDrop, bridging the
4+
// isTargeted Binding and async item extraction into ActionUI's actionHandler model.
5+
6+
import SwiftUI
7+
import UniformTypeIdentifiers
8+
9+
/// Wraps a view with `.onDrop(of:isTargeted:perform:)`.
10+
///
11+
/// A wrapper view is required (rather than an inline modifier in `applyModifiers`) because
12+
/// the `isTargeted` binding requires `@State`, which must live in a SwiftUI View struct.
13+
@MainActor
14+
struct DropModifierView<Content: SwiftUI.View>: SwiftUI.View {
15+
let content: Content
16+
let onDropActionID: String
17+
let onDropTypes: [String]
18+
let onDropTargetedActionID: String?
19+
let windowUUID: String
20+
let elementID: Int
21+
22+
@State private var isTargeted: Bool = false
23+
24+
var body: some SwiftUI.View {
25+
let utTypes = onDropTypes.compactMap { UTType($0) }
26+
27+
// Use a custom binding so that setter fires onDropTargetedActionID
28+
// without needing .onChange (avoids cross-version API differences).
29+
let targetedBinding = Binding<Bool>(
30+
get: { isTargeted },
31+
set: { newValue in
32+
isTargeted = newValue
33+
guard let actionID = onDropTargetedActionID else { return }
34+
Task { @MainActor in
35+
ActionUIModel.shared.actionHandler(
36+
actionID,
37+
windowUUID: windowUUID,
38+
viewID: elementID,
39+
viewPartID: 0,
40+
context: ["isTargeted": newValue]
41+
)
42+
}
43+
}
44+
)
45+
46+
return content
47+
.onDrop(of: utTypes, isTargeted: targetedBinding) { providers, location in
48+
let actionID = onDropActionID
49+
let wUUID = windowUUID
50+
let eID = elementID
51+
let loc: [String: Double] = [
52+
"x": Double(location.x),
53+
"y": Double(location.y)
54+
]
55+
Task { @MainActor in
56+
var items: [String] = []
57+
for provider in providers {
58+
if let text = await Self.loadText(from: provider) {
59+
items.append(text)
60+
}
61+
}
62+
ActionUIModel.shared.actionHandler(
63+
actionID,
64+
windowUUID: wUUID,
65+
viewID: eID,
66+
viewPartID: 0,
67+
context: ["items": items, "location": loc]
68+
)
69+
}
70+
return true
71+
}
72+
}
73+
74+
/// Attempts to extract a string representation from an NSItemProvider.
75+
/// Priority order: utf8PlainText → plainText → fileURL (path string).
76+
/// Returns nil if no supported type is available.
77+
private static func loadText(from provider: NSItemProvider) async -> String? {
78+
// Plain text types
79+
let textTypeIDs = [
80+
UTType.utf8PlainText.identifier,
81+
UTType.plainText.identifier
82+
]
83+
for typeID in textTypeIDs {
84+
guard provider.hasItemConformingToTypeIdentifier(typeID) else { continue }
85+
return await withCheckedContinuation { continuation in
86+
provider.loadDataRepresentation(forTypeIdentifier: typeID) { data, _ in
87+
continuation.resume(returning: data.flatMap { String(data: $0, encoding: .utf8) })
88+
}
89+
}
90+
}
91+
92+
// File / folder URL.
93+
// macOS returns the URL in one of several ways depending on the source and OS version:
94+
// • loadDataRepresentation → Data containing a UTF-8 "file://…" URL string
95+
// • loadItem → NSURL / URL object, or Data with the UTF-8 URL string
96+
// We try both paths and return whichever succeeds first.
97+
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
98+
// Path 1: loadDataRepresentation (most reliable on macOS Finder drags)
99+
let viaData: String? = await withCheckedContinuation { continuation in
100+
provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
101+
guard let data else { continuation.resume(returning: nil); return }
102+
let urlString = String(data: data, encoding: .utf8)?
103+
.trimmingCharacters(in: .whitespacesAndNewlines)
104+
continuation.resume(returning: urlString.flatMap { URL(string: $0)?.path } ?? urlString)
105+
}
106+
}
107+
if let result = viaData, !result.isEmpty { return result }
108+
109+
// Path 2: loadItem (returns NSURL on some systems, Data on others)
110+
return await withCheckedContinuation { continuation in
111+
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
112+
switch item {
113+
case let url as URL:
114+
continuation.resume(returning: url.path)
115+
case let nsurl as NSURL:
116+
continuation.resume(returning: nsurl.path)
117+
case let data as Data:
118+
let str = String(data: data, encoding: .utf8)?
119+
.trimmingCharacters(in: .whitespacesAndNewlines)
120+
continuation.resume(returning: str.flatMap { URL(string: $0)?.path } ?? str)
121+
case let str as String:
122+
continuation.resume(returning: URL(string: str)?.path ?? str)
123+
default:
124+
continuation.resume(returning: nil)
125+
}
126+
}
127+
}
128+
}
129+
return nil
130+
}
131+
}

ActionUI/Views/View.swift

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@
4040
"openURLActionID": "view.openURL", // Optional: String for action identifier triggered on open URL (via .onOpenURL modifier)
4141
"onAppearActionID": "view.onAppear", // Optional: String for action identifier triggered on view appear (via .onAppear modifier)
4242
"onDisappearActionID": "view.onDisappear", // Optional: String for action identifier triggered on view disappear (via .onDisappear modifier)
43+
"onHoverActionID": "view.hovered", // Optional: String for action triggered when pointer enters/exits (via .onHover). macOS primary.
44+
// Context: { "isHovering": Bool }
45+
"onDropTypes": ["public.utf8-plain-text"], // Optional: [String] of UTType identifiers accepted as a drop target. Required with onDropActionID.
46+
"onDropActionID": "view.dropped", // Optional: String for action triggered when a valid drop lands. Requires onDropTypes.
47+
// Context: { "items": [String], "location": { "x": Double, "y": Double } }
48+
"onDropTargetedActionID": "view.dropTargeted", // Optional: String for action triggered when drag enters/exits the drop zone.
49+
// Context: { "isTargeted": Bool }
4350
"keyboardShortcut": { // Optional: Dictionary for keyboard shortcut, supports key with array of modifiers
4451
"key": "a", // Required: String for KeyEquivalent (single character like "a" or special key like "return", "space", "upArrow")
4552
"modifiers": ["command", "shift"] // Optional: Array of strings for modifiers (e.g., ["command", "shift"]), defaults to ["command"], must contain unique elements
@@ -382,7 +389,35 @@ struct View: ActionUIViewConstruction {
382389
logger.log("Invalid type for onDisappearActionID: expected String, got \(type(of: onDisappearActionID)), ignoring", .warning)
383390
validatedProperties["onDisappearActionID"] = nil
384391
}
385-
392+
393+
// Validate onHoverActionID
394+
if let onHoverActionID = properties["onHoverActionID"], !(onHoverActionID is String) {
395+
logger.log("Invalid type for onHoverActionID: expected String, got \(type(of: onHoverActionID)), ignoring", .warning)
396+
validatedProperties["onHoverActionID"] = nil
397+
}
398+
399+
// Validate onDropActionID
400+
if let onDropActionID = properties["onDropActionID"], !(onDropActionID is String) {
401+
logger.log("Invalid type for onDropActionID: expected String, got \(type(of: onDropActionID)), ignoring", .warning)
402+
validatedProperties["onDropActionID"] = nil
403+
}
404+
405+
// Validate onDropTypes — must be a non-empty [String]
406+
if let onDropTypes = properties["onDropTypes"] {
407+
if let typesArray = onDropTypes as? [String], !typesArray.isEmpty {
408+
validatedProperties["onDropTypes"] = typesArray
409+
} else {
410+
logger.log("Invalid type for onDropTypes: expected non-empty [String], got \(type(of: onDropTypes)), ignoring", .warning)
411+
validatedProperties["onDropTypes"] = nil
412+
}
413+
}
414+
415+
// Validate onDropTargetedActionID
416+
if let onDropTargetedActionID = properties["onDropTargetedActionID"], !(onDropTargetedActionID is String) {
417+
logger.log("Invalid type for onDropTargetedActionID: expected String, got \(type(of: onDropTargetedActionID)), ignoring", .warning)
418+
validatedProperties["onDropTargetedActionID"] = nil
419+
}
420+
386421
// Validate keyboardShortcut
387422
if let keyboardShortcut = properties["keyboardShortcut"] {
388423
if let shortcutDict = keyboardShortcut as? [String: Any] {
@@ -873,7 +908,18 @@ struct View: ActionUIViewConstruction {
873908
}
874909
}
875910
}
876-
911+
912+
// Handle onHoverActionID with .onHover modifier (macOS; silent no-op elsewhere)
913+
#if os(macOS)
914+
if let onHoverActionID = properties["onHoverActionID"] as? String {
915+
modifiedView = modifiedView.onHover { isHovering in
916+
Task { @MainActor in
917+
ActionUIModel.shared.actionHandler(onHoverActionID, windowUUID: windowUUID, viewID: element.id, viewPartID: 0, context: ["isHovering": isHovering])
918+
}
919+
}
920+
}
921+
#endif
922+
877923
if let columnWidth = properties["navigationSplitViewColumnWidth"] {
878924
if let dict = columnWidth as? [String: Any],
879925
let ideal = dict.cgFloat(forKey: "ideal"), ideal > 0 {
@@ -961,6 +1007,20 @@ struct View: ActionUIViewConstruction {
9611007
modifiedView = AnyView(modifiedView).accessibilityIdentifier(accessibilityIdentifier)
9621008
}
9631009

1010+
// Handle onDrop — requires a wrapper view for the isTargeted Binding and @State
1011+
if let onDropActionID = properties["onDropActionID"] as? String,
1012+
let onDropTypes = properties["onDropTypes"] as? [String], !onDropTypes.isEmpty {
1013+
let onDropTargetedActionID = properties["onDropTargetedActionID"] as? String
1014+
modifiedView = DropModifierView(
1015+
content: AnyView(modifiedView),
1016+
onDropActionID: onDropActionID,
1017+
onDropTypes: onDropTypes,
1018+
onDropTargetedActionID: onDropTargetedActionID,
1019+
windowUUID: windowUUID,
1020+
elementID: element.id
1021+
)
1022+
}
1023+
9641024
// Apply toolbar modifier if the element has a "toolbar" subview array with items
9651025
if let toolbarItems = element.subviews?["toolbar"] as? [any ActionUIElementBase],
9661026
!toolbarItems.isEmpty {

ActionUISwiftTestApp/ActionUISwiftTestApp.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,69 @@ struct ActionUISwiftTestApp: App {
284284
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 99, value: "Discarded.")
285285
}
286286

287+
// HoverDrop demo handlers
288+
// IDs: 1=hover card, 2=hover status text,
289+
// 3=text drop zone, 4=text zone label, 5=text result panel, 6=text result content,
290+
// 7=file drop zone, 8=file zone label, 9=file result panel, 10=file result content
291+
292+
ActionUISwift.registerActionHandler(actionID: "demo.card.hovered") { _, windowUUID, _, _, context in
293+
let isHovering = (context as? [String: Any])?["isHovering"] as? Bool ?? false
294+
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 2,
295+
value: isHovering ? "Pointer is over the card" : "Move the pointer over this card")
296+
ActionUISwift.setElementProperty(windowUUID: windowUUID, viewID: 1,
297+
propertyName: "background",
298+
value: isHovering ? "fill.secondary" : "background.secondary")
299+
}
300+
301+
ActionUISwift.registerActionHandler(actionID: "demo.drop.targeted") { _, windowUUID, _, _, context in
302+
let isTargeted = (context as? [String: Any])?["isTargeted"] as? Bool ?? false
303+
let border: [String: Any] = isTargeted
304+
? ["color": "accentcolor", "width": 2.0]
305+
: ["color": "separator", "width": 1.0]
306+
ActionUISwift.setElementProperty(windowUUID: windowUUID, viewID: 3,
307+
propertyName: "border", value: border)
308+
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 4,
309+
value: isTargeted ? "Release to drop ↓" : "Drop text here")
310+
}
311+
312+
ActionUISwift.registerActionHandler(actionID: "demo.drop.received") { _, windowUUID, _, _, context in
313+
let dict = context as? [String: Any]
314+
let items = dict?["items"] as? [String] ?? []
315+
ActionUISwift.setElementProperty(windowUUID: windowUUID, viewID: 5,
316+
propertyName: "hidden", value: false)
317+
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 6,
318+
value: items.first ?? "(no text content)")
319+
// Reset drop zone appearance
320+
ActionUISwift.setElementProperty(windowUUID: windowUUID, viewID: 3,
321+
propertyName: "border", value: ["color": "separator", "width": 1.0] as [String: Any])
322+
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 4, value: "Drop text here")
323+
}
324+
325+
ActionUISwift.registerActionHandler(actionID: "demo.file.drop.targeted") { _, windowUUID, _, _, context in
326+
let isTargeted = (context as? [String: Any])?["isTargeted"] as? Bool ?? false
327+
let border: [String: Any] = isTargeted
328+
? ["color": "accentcolor", "width": 2.0]
329+
: ["color": "separator", "width": 1.0]
330+
ActionUISwift.setElementProperty(windowUUID: windowUUID, viewID: 7,
331+
propertyName: "border", value: border)
332+
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 8,
333+
value: isTargeted ? "Release to drop ↓" : "Drop files or folders here")
334+
}
335+
336+
ActionUISwift.registerActionHandler(actionID: "demo.file.drop.received") { _, windowUUID, _, _, context in
337+
let dict = context as? [String: Any]
338+
let items = dict?["items"] as? [String] ?? []
339+
print("[HoverDrop] file drop received — items: \(items), context: \(String(describing: context))")
340+
ActionUISwift.setElementProperty(windowUUID: windowUUID, viewID: 9,
341+
propertyName: "hidden", value: false)
342+
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 10,
343+
value: items.isEmpty ? "(no items)" : items.joined(separator: "\n"))
344+
// Reset drop zone appearance
345+
ActionUISwift.setElementProperty(windowUUID: windowUUID, viewID: 7,
346+
propertyName: "border", value: ["color": "separator", "width": 1.0] as [String: Any])
347+
ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 8, value: "Drop files or folders here")
348+
}
349+
287350
if shouldResetState {
288351
// Clear custom state
289352
UserDefaults.standard.removeObject(forKey: "openWindows")

0 commit comments

Comments
 (0)