Attach the parent’s drag with simultaneousGesture(_:including:) to allow it to participate alongside child interactions. Keep click actions in child buttons, give dragging a movement threshold, and decide explicitly whether selection may accompany a drag.

This walkthrough builds a movable panel containing three synthetic item buttons. The reader task is specific: clicking an item selects it, while dragging the panel changes its position inside the same Mac app. The example allows selection and movement to coexist; a separate handle variation provides distinct interaction regions when that is preferable.

Choose the interaction contract before changing gesture priority

Apple documents simultaneousGesture(_:including:) as adding a gesture at the same priority as existing gestures. Its example shows a child tap handler and an enclosing container’s tap handler both responding to a click. This is the relevant composition mechanism when the parent should participate without deliberately taking precedence over the child. Apple’s simultaneous gesture documentation

However, simultaneous recognition does not itself express the rule “dragging must cancel every child action.” Nor does Apple’s example establish that every button style and every container arbitrates a button and a drag identically. Treat gesture participation and application behavior as separate decisions.

For this panel, the parent owns the panel offset and selected item. Each child owns a button that requests selection through a closure. A selection request remains acceptable even if the same pointer interaction moves the panel. If your required behavior forbids activation during movement, use the separate handle variation below rather than depending on undocumented callback ordering.

Use a Button for the child action. Apple explicitly recommends buttons when an interaction needs button behavior, including standard accessibility behavior. A custom shape can remain the button’s label; it does not need to become a bare tap gesture. Apple’s Gestures documentation

Build a complete movable panel with child buttons

Create a macOS SwiftUI app in Xcode, set its deployment target to macOS 14 or later, and replace the generated app and content view with the following single file. Keep only one app entry point in the target. The macOS 14 requirement covers the modern coordinateSpace(_:) modifier used here. Apple’s coordinate space documentation

All items and state exist in memory. The example moves the whole panel, including its buttons, within a fixed stage. It does not implement item transfer or move the application window.

import SwiftUI

@main
struct GesturePanelApp: App {
    var body: some Scene {
        WindowGroup {
            PanelDemo()
        }
    }
}

private struct DemoItem: Identifiable {
    let id: Int
    let title: String
    let color: Color
}

private struct ItemTarget: View {
    let item: DemoItem
    let isSelected: Bool
    let select: () -> Void

    var body: some View {
        Button(action: select) {
            HStack(spacing: 10) {
                Circle()
                    .fill(item.color)
                    .frame(width: 18, height: 18)
                Text(item.title)
                Spacer()
                if isSelected {
                    Text("Selected")
                        .font(.caption)
                }
            }
            .padding(12)
            .frame(width: 220)
            .background(
                isSelected
                    ? Color.accentColor.opacity(0.18)
                    : Color.gray.opacity(0.12)
            )
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

private struct PanelDemo: View {
    private let items = [
        DemoItem(id: 1, title: "Item A", color: .blue),
        DemoItem(id: 2, title: "Item B", color: .orange),
        DemoItem(id: 3, title: "Item C", color: .purple)
    ]

    @State private var selectedID: Int? = nil
    @State private var committedOffset: CGSize = .zero
    @GestureState private var liveTranslation: CGSize = .zero

    private var pan: some Gesture {
        DragGesture(
            minimumDistance: 8,
            coordinateSpace: .named("stage")
        )
        .updating($liveTranslation) { value, translation, _ in
            translation = value.translation
        }
        .onEnded { value in
            committedOffset = CGSize(
                width: committedOffset.width + value.translation.width,
                height: committedOffset.height + value.translation.height
            )
        }
    }

    var body: some View {
        VStack(spacing: 12) {
            Text("Click an item or drag the panel")

            ZStack {
                Color.gray.opacity(0.06)

                VStack(spacing: 10) {
                    Text("Drag panel")
                        .font(.headline)
                        .frame(width: 244, height: 32)
                        .contentShape(Rectangle())

                    ForEach(items) { item in
                        ItemTarget(
                            item: item,
                            isSelected: selectedID == item.id,
                            select: { selectedID = item.id }
                        )
                    }
                }
                .padding(16)
                .background(Color.gray.opacity(0.15))
                .contentShape(Rectangle())
                .simultaneousGesture(pan, including: .all)
                .offset(
                    x: committedOffset.width + liveTranslation.width,
                    y: committedOffset.height + liveTranslation.height
                )
            }
            .frame(width: 640, height: 420)
            .coordinateSpace(.named("stage"))

            Button("Reset panel") {
                committedOffset = .zero
            }
        }
        .padding(20)
    }
}

This program has been inspected against the retrieved API documentation, but it has not been compiled or interactively tested here: this environment has no Swift compiler or macOS UI runtime. Build it in Xcode and perform the checks below on the macOS versions you support.

Why the drag has a nonzero threshold

DragGesture exposes a minimum movement distance before the gesture succeeds. The illustrative value 8 gives ordinary clicks some movement tolerance before the parent begins dragging. It is an application choice, not an Apple-prescribed threshold or a complete click-versus-drag policy. A zero-distance drag removes that particular distinction. Apple’s DragGesture documentation

Why transient and committed movement are separate

During recognition, updating(_:body:) writes the current translation into liveTranslation. Apple documents that this gesture state resets when the gesture ends or is cancelled. The example commits the final offset only in onEnded, so cancellation discards the uncommitted preview instead of leaving a stale temporary translation. Apple’s updating documentation

The displayed position is the committed offset plus the current translation. Apple defines translation as total displacement from the start of the drag, so adding it repeatedly to the committed offset on every change would overcount movement. For illustration, a committed horizontal offset of 40 and a current translation of 12 produce a displayed offset of 52; completing that drag commits 52 once. Apple’s translation documentation

The named coordinate space belongs to the fixed stage surrounding the moving panel. This makes the movement calculation refer to an explicit reference region. The child buttons do not maintain their own copies of the panel position.

Why selection stays with the parent

The parent stores selectedID and supplies each child with its current selected status and a selection closure. This follows Apple’s guidance to keep state in the highest view that needs it, then pass values or bindings down the hierarchy. Apple’s State documentation

The closure captures the current item and requests an update to the parent’s state. Swift supports closures capturing values from their surrounding context; no AppKit event needs to be copied or forwarded to implement that callback. The Swift Programming Language: Closures

Understand hit regions and gesture masks

contentShape(Rectangle()) defines the hit-testing shape. In the button label, it follows the padding and frame so that the intended rectangular label region is explicit. On the parent, it defines the panel’s interaction shape. It does not set recognizer priority or require two gestures to cooperate. Apple’s content shape documentation

The mask supplied to a gesture modifier determines which gestures remain enabled. These options are easy to misread: subviews means that the added parent gesture is disabled, not that it automatically cooperates with descendants. Apple’s GestureMask documentation

Mask Documented effect Suitability here
.all Enables the added gesture and other gestures on the view and its subviews Starting point for shared participation
.gesture Enables the added gesture and disables gestures in the subview hierarchy Conflicts with preserving child gestures
.subviews Enables subview gestures and disables the added gesture Disables the parent drag
.none Disables gestures in the subview hierarchy, including the added gesture Disables the intended interaction

Avoid treating highPriorityGesture as a general repair for missing parent drag callbacks. Its documented purpose is to give the attached gesture higher precedence. Apple’s example demonstrates the parent taking precedence over a child tap. Choose it when that precedence is the intended behavior. Apple’s high-priority gesture documentation

Use a separate drag handle when activation must stay distinct

For a panel whose buttons should have no shared drag region, retain the same state and movement calculation but attach pan only to the header. Remove .simultaneousGesture(pan, including: .all) from the parent panel.

Replace the existing header expression with this fragment inside the complete program above. It uses the already defined pan property; no other prerequisites change.

Text("Drag panel")
    .font(.headline)
    .frame(width: 244, height: 32)
    .contentShape(Rectangle())
    .gesture(pan)

The header and item buttons now occupy distinct regions, and the buttons no longer have the panel drag attached to an ancestor. Starting on an item is a button interaction; starting on the header is the route to moving the panel. This design is useful when shared participation would make the result ambiguous.

Keep the native button when the child initiates an action. Apple describes Button as an action plus a label and permits custom label views, as used by the synthetic colored items. Replacing it with a hand-built pointer handler would require you to recreate behavior the control already supplies. Apple’s Button documentation

Preserve AppKit event ownership at framework boundaries

If the panel includes an AppKit view, diagnose its hit testing separately from SwiftUI gesture composition. AppKit’s hitTest(_:) returns the deepest matching descendant and helps the window determine the recipient of a mouse-down event. Its input point is expressed in the view’s superview coordinates. Apple’s AppKit hit-testing documentation

Returning a parent from hit testing changes the initial target; it is not the equivalent of letting a child and parent recognize simultaneously. Likewise, returning nil from an overlay’s hit test removes that overlay from consideration—it does not make the overlay a passive observer of the child’s complete interaction.

AppKit documents that the default mouseDown(with:) implementation passes the message to the next responder. That is responder-chain behavior, not a promise that every ancestor independently receives a copy after a control handles the event. Keep control activation and panel movement connected through explicit callbacks or recognizers you own. Apple’s mouse-down documentation

When you own two AppKit gesture recognizers and need simultaneous recognition, AppKit provides gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:). Apple documents that returning true permits the pair to recognize together; returning false does not guarantee exclusion if the other recognizer’s delegate permits it. Limit that policy to the intended recognizer pair. Apple’s recognizer delegate documentation

Verify the interaction with a small behavior matrix

Test the exact button style, hierarchy, and macOS releases your app supports. The following checks separate recognition problems from movement arithmetic and clarify whether the shared surface matches your intended contract.

Action What to check
Click each item without dragging Its selected state changes; the panel stays still
Drag the header past the threshold The panel follows the pointer and retains its final position
Drag from an item in the shared version Parent movement works; determine whether button activation also occurs and confirm that it is acceptable
Make two consecutive drags The second starts from the first committed position without accumulating each intermediate translation
Interrupt an active drag Temporary translation clears; no unfinished movement is committed
Try the handle variation Items activate independently; movement begins from the header
Activate item buttons through keyboard navigation Selection works without requiring a pointer drag

If blank panel space drags correctly but item regions do not, inspect child controls and gesture composition. If clicks disappear, inspect gesture masks, precedence, and hit regions. If the panel accelerates or jumps, inspect translation accumulation and the chosen coordinate space before adding more recognizers.

The example deliberately leaves movement unbounded, so the panel can move beyond the visible stage; the Reset panel button restores its position. For a finished bounded workspace, add an explicit position constraint based on the stage and panel dimensions. Keep the same ownership structure: children request actions, the parent owns movement, and the interaction contract determines whether their recognition regions overlap.

Sources