Combine DragGesture, RotateGesture, and MagnifyGesture with simultaneously(with:), then render their values through one transform pipeline. For a Mac canvas, keep the input surface stationary and store each gesture’s temporary change separately from its committed result.
This tutorial builds a small shape viewer inside your own macOS app: click-drag to pan, pinch to zoom, and use a trackpad rotation gesture to turn the shape. It uses only an in-memory rectangle and circle. The composition permits overlapping recognized gestures; which inputs actually overlap depends on the events the device delivers.
What does simultaneous mean on a Mac?
Apple’s simultaneously(with:) combines two gestures into another gesture, so the result can be combined with a third. This supplies the composition needed for pan, rotation, and zoom without requiring a long press or another preliminary action. Apple’s simultaneous gesture documentation describes this combination.
Choose the meaning of pan before writing the code. Here it means dragging with the primary mouse button held, including a trackpad click-drag. Two fingers sliding across a trackpad normally produce scrolling input. Apple’s archived event guide explicitly distinguishes that scrolling stream from magnify and rotate gestures; current AppKit documentation continues to expose scrolling through scrollWheel(with:). Adding a drag gesture does not convert scrolling into dragging. Handling Trackpad Events, scrollWheel(with:)
Use this decision table to choose the input layer for your canvas:
| Desired interaction | Starting point | Boundary to retain |
|---|---|---|
| Click-drag pan, pinch zoom, trackpad rotation | The SwiftUI example below | Composition permits overlap among recognized gesture streams. |
| Two-finger scroll to pan | An AppKit canvas embedded in SwiftUI | Scrolling remains a scroll-event stream. |
| A guaranteed three-component transform from one finger pattern | A separately specified custom interaction | Simultaneous composition alone does not establish that hardware or macOS will emit every component. |
The example requires macOS 14 or later, where both MagnifyGesture and RotateGesture are available. It uses their basic initializers rather than newer input-kind configuration APIs. MagnifyGesture, RotateGesture
How should the transform state be organized?
Give each component a committed value and a live gesture value. Translation and rotation combine by addition; scale combines by multiplication. The following equations define the example’s behavior, rather than a requirement imposed by SwiftUI:
displayed translation = committed translation + live drag translation
displayed rotation = committed rotation + live rotation
displayed scale = clamp(committed scale × live magnification)
Use @GestureState for the live values. Apple documents that it resets when a gesture becomes inactive. Commit each component through its own onEnded callback, which runs only after successful completion. This gives cancellation a clear meaning: discard that component’s unfinished preview and retain its previous committed value. GestureState, onEnded(_:)
Keep these callbacks on the individual gestures before composing them. Each callback then receives its own typed value and changes only the property it owns. There is no shared starting-transform snapshot for a later-starting gesture to overwrite.
Drag translation needs particular care: its value is the total movement since the gesture started. Adding that total to the committed offset on every update would count earlier movement repeatedly. Assign the total to the live preview, then add the final total once on completion. DragGesture.Value.translation
Build the complete SwiftUI example
Create a macOS SwiftUI app with a deployment target of macOS 14 or later. Replace the generated app entry and content view with the following single file; keep exactly one @main declaration in the target. No assets, packages, or external data are required.
The gray canvas owns the gesture. Its child shape receives the visual transforms, so panning or rotating the shape does not transform the gesture’s input surface. The example deliberately zooms and rotates around the shape’s center.
import SwiftUI
@main
struct TransformCanvasApp: App {
var body: some Scene {
WindowGroup {
TransformCanvas()
.frame(minWidth: 400, minHeight: 300)
}
.defaultSize(width: 720, height: 500)
}
}
struct TransformCanvas: View {
@State private var offset = CGSize.zero
@State private var angle = Angle.zero
@State private var scale: CGFloat = 1
@GestureState private var liveOffset = CGSize.zero
@GestureState private var liveAngle = Angle.zero
@GestureState private var liveScale: CGFloat = 1
private func boundedScale(_ value: CGFloat) -> CGFloat {
min(max(value, 0.25), 4)
}
private var pan: some Gesture {
DragGesture(minimumDistance: 3, coordinateSpace: .local)
.updating($liveOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
offset = CGSize(
width: offset.width + value.translation.width,
height: offset.height + value.translation.height
)
}
}
private var rotation: some Gesture {
RotateGesture()
.updating($liveAngle) { value, state, _ in
state = value.rotation
}
.onEnded { value in
angle = angle + value.rotation
}
}
private var zoom: some Gesture {
MagnifyGesture()
.updating($liveScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
scale = boundedScale(scale * value.magnification)
}
}
private var manipulation: some Gesture {
pan.simultaneously(with: rotation)
.simultaneously(with: zoom)
}
var body: some View {
ZStack {
Color.gray.opacity(0.12)
RoundedRectangle(cornerRadius: 18)
.fill(Color.blue)
.overlay(alignment: .topLeading) {
Circle()
.fill(Color.white)
.frame(width: 24, height: 24)
.padding(16)
}
.frame(width: 180, height: 120)
.scaleEffect(
boundedScale(scale * liveScale),
anchor: .center
)
.rotationEffect(angle + liveAngle, anchor: .center)
.offset(CGSize(
width: offset.width + liveOffset.width,
height: offset.height + liveOffset.height
))
.allowsHitTesting(false)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.clipped()
.gesture(manipulation)
}
}
Apple documents the underlying drag, magnify, and rotation gesture types and attaching gestures to views. The sample combines those APIs with an explicit state and coordinate policy. SwiftUI was unavailable in the research environment, so this program was inspected against the documentation but was not compiled or run here. DragGesture, MagnifyGesture, RotateGesture
Why scale, then rotate, then offset?
The intended mapping is to scale the shape around its center, rotate it around that same center, and finally translate it in canvas coordinates. Applying the offset last keeps the pan outside the shape’s scale and rotation. Moving the pointer horizontally should move the shape horizontally even after it has turned.
This ordering is a design choice derived from the sample’s transform pipeline. Apple documents that scaleEffect scales rendered output around an anchor, rotationEffect rotates rendered output, and offset shifts displayed content. These visual effects do not turn the stationary canvas into a transformed layout container. scaleEffect(_:anchor:), rotationEffect(_:anchor:), offset(_:)
The limits of 0.25 and 4 are illustrative application settings. The same clamp runs during preview and commit, so releasing the gesture preserves the displayed scale. Swift’s min and max select the lesser and greater values used to express those bounds. min(::), max(::)
Check the interaction with concrete expectations
Start with each component separately, then attempt overlap using the available input devices. The white circle makes rotation visible even when the rectangle is small.
- Click-drag on the canvas, release, and drag again. The second drag should begin from the first drag’s committed position.
- Pinch to enlarge the shape, release, and pinch again. Each gesture should multiply the retained scale.
- Rotate, release, and rotate again. The angle should accumulate.
- After zooming and rotating, drag horizontally. The shape should still translate along the canvas’s horizontal axis.
- Where devices deliver overlapping inputs, end one component while continuing another. The remaining component should keep updating its own property.
- Interrupt an active gesture, for example by switching applications. Any gesture that becomes inactive without successful completion should lose its live preview rather than leave temporary state stuck.
As an illustrative arithmetic check, suppose the retained scale is 1.5 and a pinch reports 1.2. The displayed result is 1.8. If the retained angle is 30 degrees and the live rotation is 15 degrees, the displayed angle is 45 degrees. A drag translation of 40 points remains a 40-point canvas offset regardless of that scale and angle.
This sample commits components independently. If rotation finishes successfully and a still-active drag is later cancelled, the completed rotation remains while the unfinished drag rolls back. An editor requiring one atomic operation across all components needs an additional interaction-session model with an explicit commit and rollback policy.
The shape can also be dragged outside the visible canvas because translation is intentionally unbounded. The canvas remains draggable, allowing movement back in the opposite direction. A finished viewer can add a separate reset command or constrain translation according to its own content bounds.
When should you use an AppKit canvas instead?
Choose an AppKit input layer when the task specifically requires two-finger scroll panning or direct control of scroll momentum. Embed an owned AppKit view through NSViewRepresentable, which Apple provides for creating, updating, and dismantling AppKit views within SwiftUI. NSViewRepresentable
Keep a single owner for each input stream. In that AppKit canvas, handle scrolling through scrollWheel(with:) and use recognizers for magnification and rotation. Forward the resulting changes into the same translation, angle, and scale model. Avoid attaching another SwiftUI handler that also applies the same input to that model.
For AppKit recognizers that should coexist, implement gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) and return true only for the intended recognizer pairs belonging to your canvas. Apple documents that the delegate method permits simultaneous recognition; it is not a way to manufacture a missing input stream. Simultaneous recognition delegate method
Preserve the value conventions at the bridge. AppKit’s magnification recognizer reports an amount that must be added to 1 to obtain a scale factor, while its rotation recognizer exposes radians. Do not copy the SwiftUI value assignments across unchanged. NSMagnificationGestureRecognizer.magnification, NSRotationGestureRecognizer.rotation
For scrolling, Apple identifies scrollingDeltaX as the preferred horizontal delta property and notes that nonprecise deltas may need adjustment. Its momentum documentation describes scroll events continuing after the physical gesture stops. Decide whether the canvas accepts that continuation, and route each accepted event once. scrollingDeltaX, momentumPhase
The bridge should change your canvas’s content transform, while SwiftUI retains ownership of the represented view’s layout. Apple explicitly warns against directly setting the represented view’s frame or bounds. Use the representable’s coordinator to carry delegate and target-action changes back to SwiftUI. NSViewRepresentable
For the click-drag viewer, the SwiftUI implementation supplies a complete starting point: one stationary input surface, three independently managed gesture components, and a deliberate transform order. Keep those boundaries when extending it, and choose the AppKit path when the reader-facing task calls for scroll panning.
Sources
- simultaneously(with:) — Apple
- Handling Trackpad Events — Apple, Documentation Archive
- scrollWheel(with:) — Apple
- MagnifyGesture — Apple
- RotateGesture — Apple
- GestureState — Apple
- onEnded(_:) — Apple
- translation — Apple
- DragGesture — Apple
- scaleEffect(_:anchor:) — Apple
- rotationEffect(_:anchor:) — Apple
- offset(_:) — Apple
- min(::) — Apple
- max(::) — Apple
- NSViewRepresentable — Apple
- gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) — Apple
- magnification — Apple
- rotation — Apple
- scrollingDeltaX — Apple
- momentumPhase — Apple