← 回總覽

SwiftUI 手势实战指南:从点击到组合交互

📅 2026-03-18 21:06 Pavel Andreev 软件编程 20 分鐘 23870 字 評分: 83
SwiftUI iOS 开发 手势 API UI/UX 移动开发
📌 一句话摘要 一份掌握 SwiftUI 手势 API 的全面实战指南,涵盖基础交互、使用 @GestureState 和 Transaction 进行高级状态管理,以及复杂的手势组合技术。 📝 详细摘要 本文深入探讨了 SwiftUI 手势 API,通过结构化的方式,从点击 (Tap)、长按 (LongPress) 和拖拽 (Drag) 等基础交互,进阶到放大 (Magnify) 和旋转 (Rotation) 等高级概念。文章强调了理解手势生命周期的重要性,特别突出了 @GestureState 在瞬态交互中的强大功能,以及 Transaction 在注入动画上下文中的作用。此外,指南还

If there is one thing that defines a truly great iOS app, it’s how it feels under the user’s fingertips. Fluid, intuitive, and responsive interactions are what separate good apps from exceptional ones. In SwiftUI, building these interactions revolves entirely around the Gesture API.

While adding a simple.onTapGestureis something we all learn on day one, truly mastering the SwiftUI gesture system—understanding gesture states, transaction animations, and complex composition—unlocks a whole new level of UI development.

In this comprehensive guide, we are going to dive deep into the mechanics of SwiftUI gestures. We’ll start by demystifying the core interactions (Tap, LongPress, Drag, Magnify, and Rotation), exploring their hidden properties and best practices. Finally, we will move into advanced territory: composing multiple gestures using.map,.simultaneously(with:), and.sequenced(before:)to create professional-grade, multi-step interactions.

Let’s get started!

1. TapGesture

In this example, theonTapGesturemodifier uses two highly useful parameters: ThecountparameterThis parameter determines the number of taps required to trigger the gesture. By default, its value is1(a standard single tap). However, if you need to implement a double or triple tap on a specific element, you simply increase this number. It's important to note that if you set the count to2or higher, single taps will be ignored, and the action will only fire once the exact number of taps is reached in quick succession. ThecoordinateSpaceparameterThis parameter uses theCoordinateSpaceenum to define the reference frame for the tap's location. The enum consists of three main cases:

* global: The global coordinate space at the root of the view hierarchy. * local: The local coordinate space of the current view. * named(AnyHashable): A named reference to a view's custom local coordinate space.

By specifying thecoordinateSpace(in our case,.local), the action closure provides the exactlocation(aCGPoint) of the tap relative to the chosen coordinate system. This is incredibly useful if you need to know exactly where the user touched the view.

\

struct TapGestureView: View { 
  @State var backgroundColor: Color = .red

var body: some View { VStack { Circle() .frame(width: 50, height: 50) .onTapGesture(count: 2, coordinateSpace: .local) { location in print(location) backgroundColor = .getRandomColor() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(backgroundColor) }}

public extension Color { static func getRandomColor() -> Color { Color( red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1), opacity: .random(in: 0.5...1) ) } }

\

> _Try it yourself:__> Change the_coordinateSpace_in the code above from_.local_to_.global_and tap the circle again. You'll notice that the values printed in the console are different. This happens because_.global_calculates the tap's coordinates relative to the entire screen (the root view), whereas_.local_evaluates the touch exactly within the bounds of the_Circle_itself._

2. LongPressGesture

TheonLongPressGesturemodifier has several key parameters and closures that give you granular control over user interactions: TheminimumDurationparameterThis argument determines exactly how long the user needs to hold their finger on the screen. The main action closure will only execute after this specific duration has passed. TheonPressingChangedclosureThis closure is incredibly useful for tracking the gesture's lifecycle. It returns a boolean value (press) that updates dynamically depending on the user's touch.

* When the user touches the element and starts holding, it returnstrue. * If the user lifts their finger _before_ theminimumDurationis reached, the value changes tofalse. ThemaximumDistanceparameterThe gesture can also be interrupted if the user's finger moves too far from the initial touch point. By default,maximumDistanceis set to 10 points. If the finger slips past this boundary, the gesture is considered canceled, andonPressingChangedwill returnfalse.

\ \

struct LongPressGestureView: View { 
        @State private var isComplete = false
        @State private var isPressing = false
        @State private var isFailed = false  

var body: some View { VStack { Circle() .fill(isComplete ? Color.green : (isFailed ? Color.red : Color.blue)) .frame(width: 100, height: 100) .scaleEffect(isPressing ? 1.5 : 1.0) .animation(.easeInOut, value: isPressing) .onLongPressGesture( minimumDuration: 2, maximumDistance: 20 ) { print("The action is executed after pressing for 2 seconds") isComplete = true isFailed = false } onPressingChanged: { press in isPressing = press if press { isComplete = false isFailed = false print("Start Pressing") } else if !isComplete { isFailed = true print("Canceled: moved out of bounds or press interrupted") } } } } }

\

> _Tip:__If you don’t care whether the user’s finger wiggles or moves around during the long press, you can increase this value significantly or simply set it to_.infinity_. This ensures the action will only be interrupted if the user physically lifts their finger off the screen._

3. DragGesture

TheDragGestureprovides a highly interactive way to move views around the screen. Let's break down the key parameters and modifiers used in this example:

* minimumDistance: This defines exactly how far (in points) the user's finger must move before the gesture is officially recognized as a drag. Setting it to10means the drag action won't trigger until the finger moves at least 10 points. This is incredibly useful for preventing accidental drags when the user simply intended to tap the screen. * coordinateSpace: Just like with other gestures, this determines the reference frame for the drag's coordinate values. Using.globalcalculates the movement relative to the entire screen, while.localcalculates it relative to the view itself. * The.onChangedclosure: This block executes continuously as the user drags their finger. It provides avalue(of typeDragGesture.Value), which contains real-time data about the drag. In our code, we usevalue.translation(the total distance moved from the start of the drag) to update the circle'soffset, making the view perfectly follow the user's finger. * The.onEndedclosure: This triggers the exact moment the user lifts their finger off the screen, successfully ending the gesture. It's the perfect place to reset states or finalize actions. Here, we usewithAnimation(.spring())to smoothly snap the circle back to its original starting point (offset = .zero).

\

struct DragGestureView: View { 
      @State private var offset = CGSize.zero

var body: some View { Circle() .fill(Color.orange) .frame(width: 100, height: 100) .offset(offset) .gesture( DragGesture(minimumDistance: 10, coordinateSpace: .global) .onChanged { value in offset = value.translation } .onEnded { _ in withAnimation(.spring()) { offset = .zero } } ) } }

*

\

struct DragGestureView: View { 

@GestureState private var dragOffset = CGSize.zero

var body: some View { Circle() .fill(Color.orange) .frame(width: 100, height: 100) .offset(dragOffset) .gesture( DragGesture(minimumDistance: 0) .updating($dragOffset, body: { value, state, transaction in state = value.translation transaction.animation = .interactiveSpring() }) ) } }

\ \ In this advanced example, we take full control of the drag interaction by deeply integrating with the.updatingmodifier. By settingminimumDistance: 0, the gesture responds the exact millisecond the user touches the view.

But the real magic happens inside the closure. Let’s break down the three powerful parameters injected into this block:value,state, andtransaction.

1.value(Type:DragGesture.Value)

This represents the live, real-time data of the gesture at any given frame. It contains several incredibly useful properties:

* translation: The total distance (CGSize) the finger has moved since the gesture started. We use this to calculate how far our circle should move. * location: The exact current coordinate (CGPoint) of the user's finger. * startLocation: The coordinate (CGPoint) where the initial touch occurred. * velocity: The current speed and direction (CGSize) of the drag. This is extremely valuable if you want to implement "flick" gestures (like swiping away a card) where the view continues moving based on how fast the user swiped.

2.state(Type:inout CGSize)

This parameter is aninoutreference directly tied to your@GestureStatevariable (in our case,dragOffset). How it works: It doesn’t have custom attributes; instead, it matches the exact type of your@GestureState. By assigning a new value to it (state = value.translation), you are telling SwiftUI:_"Update the state, which should instantly trigger a view redraw with the new offset."_The benefit: Remember, because it's tied to@GestureState, the moment the user lifts their finger, SwiftUI automatically resets thisstateback to its initial value (.zero).

3.transaction(Type:inout Transaction)

This is the hidden gem of SwiftUI state management. ATransactionis the context that carries all the animation information for the current state update.

* transaction.animation: By injecting an animation directly into the transaction (transaction.animation = .interactiveSpring()), we are specifically animating _this exact state change_. An interactive spring is perfectly tuned for user-driven gestures because it starts quickly and responds fluidly. * transaction.disablesAnimations: A boolean flag. If you set this totrue, SwiftUI will aggressively suppress any animations that might otherwise occur during this state update. * transaction.isContinuous: A boolean indicating whether the state update is part of an ongoing, continuous interaction (like dragging a slider) rather than a single discrete event.

By mutating thetransactiondirectly inside the gesture, we ensure that both the drag movement and the automatic "snap back" to.zero(when the gesture ends) are beautifully animated with a fluid spring effect, all in a few lines of code.

4. MagnifyGesture

If you want to allow users to zoom in and out of content (like photos or maps),MagnifyGestureis exactly what you need. It perfectly tracks standard two-finger pinch interactions.

Just like with theDragGesture, using@GestureStateis the most elegant approach here. Because the defaultmagnificationvalue is1.0(100% scale), the image will automatically spring back to its original size the moment the user lifts their fingers. ExploringMagnifyGesture.ValuePropertiesWhen you use the.updatingor.onChangedmodifiers with this gesture, thevalueparameter provides a wealth of data about the ongoing pinch. Let's look at the properties available to you:

* magnification(CGFloat):This is the core property. It represents the total scale factor of the pinch. It starts at1.0. If the user spreads their fingers apart, it grows (e.g.,1.5for 150% zoom). If they pinch them together, it shrinks (e.g.,0.5for 50% zoom). * velocity(CGFloat):This tells you how fast the user is pinching or spreading their fingers. You can use this to create custom physics-based animations (for example, if the user zooms out extremely fast, you could use that velocity to close the view entirely). * location(CGPoint):This is the current center point (the centroid) between the user's two fingers. If you want the image to scale exactly from the point where the user is pinching (rather than just the center of the image), you can combine this property with the.scaleEffect(anchor:)modifier. * startLocation(CGPoint):The initial center point between the two fingers when the gesture first began.

\

struct MagnificationGesture: View {
      @GestureState private var magnification: CGFloat = 1

var body: some View { Image("BeatifulImg") .resizable() .frame(width: 400, height: 400) .scaleEffect(magnification) .gesture ( MagnifyGesture() .updating($magnification, body: { value, state, transaction in state = value.magnification }) ) } }

\ Watch the short video with example https://youtube.com/shorts/0UVbAkJmNdA

5. RotationGesture

While@GestureStateis perfect for interactions that snap back to their original position (like our previous Magnify and Drag examples), sometimes we want the view to stay exactly where the user left it. To achieve this persistent state with aRotationGesture, we need a different approach using two@Stateproperties. HowRotationGestureWorksTheRotationGesturetracks the circular movement of two fingers across the screen. Unlike other gestures that return complex value objects, the value provided byRotationGesturein its closures is simply anAnglestruct. ThisAnglerepresents the degree (or radian) of rotation relative to the starting position of the fingers. Why do we need two@Statevariables?If we only used one@Statevariable to track the angle, the rectangle would forcefully snap back to0degrees the next time the user touches the screen to rotate it again, because the gesture's angle always starts from zero at the beginning of a new touch.

To solve this, we split the responsibility:

  • currentAngle: This handles the _transient_ state. Inside the.onChangedclosure, it continuously updates with the real-time angle of the user's fingers.
  • finalAngle: This handles the _persistent_ state. Inside the.onEndedclosure, we take the final result ofcurrentAngleand add it (+=) tofinalAngle.
Finally, the most crucial step happens in.onEnded: we resetcurrentAngleback to.zero. Because our.rotationEffectcombines both properties (currentAngle + finalAngle), the visual rotation remains perfectly seamless, and the view is instantly ready for the next rotation gesture without any jarring jumps.

\n

struct RotationGestureExample: View { 

// Tracks the rotation only while the gesture is actively happening @State private var currentAngle = Angle.zero

// Stores the accumulated rotation after the gesture ends @State private var finalAngle = Angle.zero

var body: some View { Rectangle() .fill(Color.yellow) .frame(width: 150, height: 150)

// Combine both angles to get the actual visible rotation .rotationEffect(currentAngle + finalAngle) .gesture( RotationGesture() .onChanged { angle in currentAngle = angle } .onEnded { angle in finalAngle += angle currentAngle = .zero } ) } }

\

Hands-On: Working with Composed Gestures

Understanding the theory of composition is great, but how do we actually extract the data from these combined gestures? Let’s look at practical examples for each method.

1. Using.mapto Clean Up State

Here, we map a complexDragGestureinto a simple, customSwipeDirectionenum. This keeps our view logic clean and decoupled from raw math.

\n

enum SwipeDirection: String { 

case left, right, up, down, none }

struct MapGestureExample: View { @State private var direction: SwipeDirection = .none

var body: some View { Text("Swiped: \(direction.rawValue.capitalized)") .font(.title) .padding() .background(Color.blue.opacity(0.2)) .cornerRadius(10) .gesture( DragGesture(minimumDistance: 20) .map { value -> SwipeDirection in // Transform the raw CGSize into our custom Enum if abs(value.translation.width) > abs(value.translation.height) {

return value.translation.width < 0 ? .left : .right }

else {

return value.translation.height < 0 ? .up : .down } } .onEnded { mappedDirection in // We receive our clean Enum here, not the DragGesture.Value! self.direction = mappedDirection } ) } }

\

2. Handling.simultaneously(with:)Values

When you combine two gestures simultaneously, the resultingvalueis a specialized struct containing.firstand.secondproperties. Because the user might start one gesture slightly before the other (e.g., placing one finger down before the second for a pinch),both properties are Optionals.

\ \

struct SimultaneousGestureExample: View { 

@State private var scale: CGFloat = 1.0 @State private var angle: Angle = .zero

var body: some View { Rectangle() .fill(Color.purple) .frame(width: 200, height: 200) .scaleEffect(scale) .rotationEffect(angle) .gesture( MagnifyGesture() .simultaneously(with: RotationGesture()) .onChanged { value in // Safely unwrap the first gesture's value (Magnify) if let magnifyValue = value.first { scale = magnifyValue.magnification } // Safely unwrap the second gesture's value (Rotation)

if let rotationValue = value.second { angle = rotationValue } } ) } }

\ \

3. Unpacking the.sequenced(before:)Enum

A sequenced gesture returns aSequenceGesture.Value, which is anenumrepresenting a state machine. You must use aswitchstatement to handle its two primary cases:.first(the first gesture is active) and.second(the first gesture completed, and the second is now active or pending).

\

struct SequencedGestureExample: View { 

@State private var isLongPressed = false @State private var offset = CGSize.zero

var body: some View { Circle() .fill(isLongPressed ? Color.red : Color.blue) .frame(width: 100, height: 100) .offset(offset) .gesture( LongPressGesture(minimumDuration: 0.5) .sequenced(before: DragGesture()) .onChanged { value in // Switch on the Enum to determine our current state switch value { case .first(let isPressing): // State 1: Long press is in progress print("Waiting for long press...") case .second(let pressCompleted, let dragValue): // State 2: Long press finished, drag can begin if pressCompleted { withAnimation { isLongPressed = true } } // Safely unwrap the drag value, as the drag might not have started yet if let drag = dragValue {

offset = drag.translation

} } } .onEnded { _ in // Reset everything when the user lifts their finger withAnimation { isLongPressed = false offset = .zero } } ) } }

\ \

Conclusion

Mastering gestures in SwiftUI is like unlocking a superpower for your UI/UX design. We’ve covered a massive amount of ground today — from foundational taps to advanced state management with@GestureStateandTransaction, all the way to building complex state machines with sequenced gestures.

The secret to getting comfortable with these tools is experimentation. I highly encourage you to take the code snippets from this article, drop them into a fresh SwiftUI project, and start tweaking the parameters. Change theminimumDistance, play with different.interactiveSpring()animations in the transaction, and try combining gestures in creative ways. The more you experiment, the more natural it will feel.

If you found this deep dive helpful, please give it a few claps 👏, save it for your next project, and follow me for more advanced iOS development insights. Have any questions or cool gesture tricks of your own? Drop them in the comments below!

Happy coding!

\

查看原文 → 發佈: 2026-03-18 21:06:48 收錄: 2026-03-19 04:00:51

🤖 問 AI

針對這篇文章提問,AI 會根據文章內容回答。按 Ctrl+Enter 送出。