Chapters

Hide chapters

SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.2

Section I: Your First App: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your Second App: Cards

Section 2: 9 chapters
Show chapters Hide chapters

14. Gestures
Written by Caroline Begbie

Gestures are the main interface between you and your app. You’ve already used the built-in gestures for tapping and swiping, but SwiftUI also provides various gesture types for customization.

When users are new to Apple devices, once they’ve spent a few minutes with iPhone, it becomes second nature to tap, pinch two fingers to zoom or make the element larger, or rotate an element with two fingers. Your app should use these standard gestures. In this chapter, you’ll explore how to drag, magnify and rotate elements with the built-in gesture recognizers.

Back of the napkin design
Back of the napkin design

In the single card view, you’ll drag around and resize photo and text elements. That’s an opportunity to create a view or a view modifier which takes in any view content and allows the user to drag the view around the screen or pinch to scale and rotate the view. Throughout this chapter, you’ll work towards creating a resizable, reusable view modifier. You’ll be able to use this in any of your future apps.

Creating the Resizable View

To start with, the resizable view will simply show a colored rectangle but, later on, you’ll change it to show any view content.

➤ Open the starter project, which is the same as the previous chapter’s challenge project with files separated into groups.

➤ Create a new SwiftUI View file named ResizableView.swift. Replace ResizableView with this code:

struct ResizableView: View {
  // 1
  private let content = RoundedRectangle(cornerRadius: 30.0)
  private let color = Color.red

  var body: some View {
    // 2
    content
      .frame(width: 250, height: 180)
      .foregroundColor(color)
  }
}

Going through the code:

  1. Create a RoundedRectangle view property. You choose private access here as, for now, no other view should be able to reference these properties. Later on, you’ll change the access to pass in any view.
  2. Use content as the required View in body and apply modifiers to it.

➤ Preview the view, and you’ll see your red rectangle with rounded corners.

Preview rounded rectangle
Preview rounded rectangle

Creating Transforms

Skills you’ll learn in this section: transformation

Each card in your app will hold multiple images and pieces of text called, generically, elements. For each element, you’ll store a size, a location on the screen and a rotation angle. In mathematics, you refer to these spatial properties collectively as a transformation or transform.

➤ Create a new group called Model that will hold data structure files.

➤ In the Model group, create a new Swift file called Transform.swift to hold the transformation data.

➤ Replace the code in the file and create a structure with initialized spatial properties:

import SwiftUI

struct Transform {
  var size = CGSize(width: 250, height: 180)
  var rotation: Angle = .zero
  var offset: CGSize = .zero
}

You set up defaults for size, rotation and offset. Angle is a SwiftUI type which conveniently works with both degrees and radians.

Notice the use of .zero here. Angle.zero and CGSize.zero are both type properties that return zero values. You’ll discover more about type properties later in this chapter. When the type is obvious to the compiler, as it is here, the compiler will work out which type to use for .zero.

Often, transforms hold a scale value too, but in this case you’ll update the size of the element instead of holding a scale value.

➤ Open ResizableView.swift and add a new property:

@State private var transform = Transform()

You hold the transform that you will apply to ResizableView as a state property. Later on, you’ll pass the element’s saved transform in, but for now, just hold the transform locally.

➤ Change frame(width:height:alignment:) to use transform instead of the hard-coded size:

.frame(
  width: transform.size.width,
  height: transform.size.height)

Because transform holds the same default size, the view does not change. Now you’re ready to create gestures to move your view around.

Rounded rectangle with transform sizing
Rounded rectangle with transform sizing

Creating a Drag Gesture

Skills you’ll learn in this section: drag gesture; operator overloading

You’ll start off with the drag gesture, where the user moves one finger across the screen. This is also called a pan gesture. When the user touches down on a ResizableView and drags a finger, the view will follow that finger. When they lift the finger, the view will remain at that location.

You’ll give the view a modifier which will update the offset of ResizableView from the center of its parent view. To position the view, you have a choice of using either position(_:) or offset(_:) view modifier. You’re saving an offset value into transform, so that’s what you’ll use here.

➤ Create a new Gesture property in ResizableView:

var dragGesture: some Gesture {
  DragGesture()
    .onChanged { value in
      transform.offset = value.translation
    }
}

The gesture updates transform’s offset property as the user drags the view.

onChanged(_:) has one parameter of type Value, which contains the gesture’s current touch location and the translation since the start of the touch.

The center of the screen is at offset.zero.

Offset and translation
Offset and translation

The amount of translation is the amount to offset the view. The translation is a CGSize, so when you travel across the screen, that’s translation.width, and up and down the screen is translation.height.

➤ Add new modifiers to content at the end of body:

.offset(transform.offset)
.gesture(dragGesture)

Order of modifiers is important — gesture(_:) needs to go after any positioning modifiers.

➤ Live preview the view and drag it around the screen.

Dragging the view
Dragging the view

The first drag works well, but on second and subsequent drags, the view does a jump at the start of the drag. This is because the drag gesture sets value.translation to zero at the start of the drag, so you’ll need to take into account any previous translations.

➤ Add a new property to ResizableView to hold the transform’s offset before you start dragging:

@State private var previousOffset: CGSize = .zero

➤ Change dragGesture to:

var dragGesture: some Gesture {
  DragGesture()
    .onChanged { value in
      transform.offset = CGSize(
        width: value.translation.width + previousOffset.width,
        height: value.translation.height + previousOffset.height)
    }
    .onEnded { _ in
      previousOffset = transform.offset
    }
}

In onChanged(_:), you update transform with the user’s drag translation amount and include any previous dragging.

In onEnded(_:), you replace the old previousOffset with the new offset, ready for the next drag. You don’t need to use the value provided, so you use _ as the parameter for the action method.

➤ Try it out in the live preview again.

This works well. You can now drag your view around and position it wherever you want.

The CGSize code is a bit long-winded though, with having to do the math on both width and height. You can shorten this code by overloading the + operator.

Operator Overloading

Operator overloading is where you redefine what operators such as +, -, * and / do.

To add translation to offset, you must add width to width and, at the same time, add height to height. To do this, you’ll redefine + with a new method.

➤ Create a new Swift file called Operators.swift. Any time you want to overload an operator for a particular type, you can add the method in this file.

➤ Replace the code with the new method:

import SwiftUI

func + (left: CGSize, right: CGSize) -> CGSize {
  CGSize(
    width: left.width + right.width,
    height: left.height + right.height)
}

Here you specify what the + operator should do for a CGSize type. The parameters are left and right, which are the items to the left and right of the + sign. You return the new CGSize.

This is a simple example of how you want the + sign to work for CGSize. It makes sense here to add the width and height together. However, you can redefine this operator to do anything, and you should be very careful that the method makes sense. Don’t do things like redefining a multiply sign to do division!

➤ Now, return to ResizableView.swift and change dragGesture to:

var dragGesture: some Gesture {
  DragGesture()
    .onChanged { value in
      transform.offset = value.translation + previousOffset
    }
    .onEnded { _ in
      previousOffset = transform.offset
    }
}

You can see how overloading the + operator reduces the code and increases clarity.

Creating a Rotation Gesture

Skills you’ll learn in this section: rotation gesture

Now that you can move your view around the screen, it’s time to rotate it. You’ll use two fingers on the view and set up a RotationGesture to track the angle of rotation.

Just as you did with tracking the previous offset of the view, you’ll track the previous rotation.

➤ In ResizableView.swift, set up a new property for this:

@State private var previousRotation: Angle = .zero

This will hold the angle of rotation of the view going into the start of the gesture.

➤ Add the new gesture to ResizableView:

var rotationGesture: some Gesture {
  RotationGesture()
    .onChanged { rotation in
      transform.rotation += rotation - previousRotation
      previousRotation = rotation
    }
    .onEnded { _ in
      previousRotation = .zero
    }
}

onChanged(_:) provides the gesture’s angle of rotation as the parameter for the action you provide. You add the current rotation, less the previous rotation, to transform’s rotation.

onEnded(_:) takes place after the user removes his fingers from the screen. Here, you set any previous rotation to zero.

➤ In body, replace .gesture(dragGesture) with:

.rotationEffect(transform.rotation)
.gesture(dragGesture)
.gesture(rotationGesture)

To test your rotation effect in the live preview, because you don’t have a touch screen available to you, you can simulate two fingers by holding down the Option key. Two dots will appear, representing two fingers. (You may have to click the preview before they show up.)

Two fingers to rotate
Two fingers to rotate

Move your mouse or trackpad to change the distance between the two dots. Make sure that they are both on the rectangle View, and click and drag. Your view should rotate. If you have the distance between the dots correct, but you want the dots to be elsewhere on the screen, you can hold down the Shift key as well as Option to move the dots. Still holding Option, let go the Shift key when they are in the right place.

Order of modifiers is again important here. The pivot point of the rotation is around the center of the view without taking any offset into consideration.

➤ Drag the view and then rotate it, and you’ll see that the view’s pivot point is around the center of the screen. This is the view’s center point without the offset applied.

Sometimes this may be what you want. But in your case here, you want to rotate the view before offsetting it.

Swift Tip: rotationEffect(_:anchor:) by default rotates around the center of the view, but you can change that to another point in the view by changing anchor.

➤ Move .offset(transform.offset) to after rotationEffect(transform.rotation), but before gesture(dragGesture).

Note: The shortcut keys Option-Command-[ and Option-Command-] move lines of code up and down.

The order of gestures is also important. If you place the drag gesture after the rotation gesture, then the rotation gesture will swallow up the touches.

➤ Try rotating the view in the live preview

Rotation
Rotation

➤ Gestures always feel better on a real device, so to run this on a device, open CardsApp.swift

➤ Temporarily, change CardsListView() to:

ResizableView()

➤ Change the run destination to your device.

Note: If you haven’t yet run an app on your device, take a look at Running your Apps on an iOS Device in Chapter 2, “Planning a Paged App”. You’ll need an Apple developer account set up in Settings to run the app on a device.

➤ Set your team identifier on the Cards app’s Signing & Capabilities tab.

➤ Build and run and try your gestures to see how fluid they feel. Two fingers on the device feels much more natural than trying to manipulate the simulator gesture dots.

Creating a Scale Gesture

Skills you’ll learn in this section: magnification gesture; simultaneous gestures

Finally, you’ll scale the view up and down. MagnificationGesture operates as a pinch gesture, so you’ll be able to rotate and scale at the same time, using two fingers.

You’ll do the scale slightly differently from rotate and offset. The view will always be at a scale of 1.0 unless the user is currently scaling. At the end of the scaling operation, you’ll calculate the new size of the view and set the scale back to 1.0.

➤ Open ResizableView.swift, and create a property to hold the current scale:

@State private var scale: CGFloat = 1.0

➤ Add the scale gesture property to ResizableView:

var scaleGesture: some Gesture {
  MagnificationGesture()
    .onChanged { scale in
      self.scale = scale
    }
    .onEnded { scale in
      transform.size.width *= scale
      transform.size.height *= scale
      self.scale = 1.0
    }
}

onChanged(_:) takes the current gesture’s scale and stores it in the state property scale. To differentiate between the two properties called the same name, use self to describe ResizableView’s @State property.

When the user has finished the pinch and raises his fingers from the screen, onEnded(_:) takes the gesture’s scale and changes transform’s width and height. You then reset ResizableView.scale to 1.0 to be ready for the next scale.

➤ In body, after .rotationEffect(transform.rotation), add the scale modifier:

.scaleEffect(scale)

Creating a Simultaneous Gesture

Whereas the drag is a specific gesture with one finger, you can do rotation and scale at the same time with two fingers. To do this, change .gesture(rotationGesture) to:

.gesture(SimultaneousGesture(rotationGesture, scaleGesture))

You can now perform the two gestures at the same time.

➤ Try your three gestures in Live Preview. Then, build and run your app and try them in Simulator or, if possible, on a device.

Completed gestures
Completed gestures

Creating Custom View Modifiers

Skills you’ll learn in this section: creating a ViewModifier; View extension; using a view modifier; advantages of a view modifier

You’ve made a very useful view, one that can be used in many app contexts. Rather than hard-coding the view you want to resize, you can change this view and make it a modifier that acts on other views.

➤ In ResizableView.swift, change struct ResizableView: View { to:

struct ResizableView: ViewModifier {

Here, you declare the new view modifier. For the moment, ignore all the compile errors until you’ve completed the modifier.

➤ Change var body: some View { to:

func body(content: Content) -> some View {

Because ViewModifier takes in an existing view, instead of a var, it requires a method with the view content as a parameter. The content will be a view, such as a Rectangle or an Image or any custom view you create.

ResizableView should only operate on expected properties of a view. For resizing, you would expect a Transform property, but color has nothing to do with resizing. You’ll set up color and content outside of the modifier.

➤ Remove:

private let content = RoundedRectangle(cornerRadius: 30.0)
private let color = Color.red

➤ Also remove .foregroundColor(color) from body(content:).

➤ To preview the modifier, change the preview provider at the end of ResizableView.swift

struct ResizableView_Previews: PreviewProvider {
  static var previews: some View {
    RoundedRectangle(cornerRadius: 30.0)
      .foregroundColor(Color.blue)
      .modifier(ResizableView())
  }
}

Here, you set up the content that the view should use and add the modifier(_:) with your custom view modifier.

It’s always a good idea to keep your previews working. With view modifier previews, you can provide an example to future users of your code how to use the modifier. Always remember that “future users” includes you in a few weeks’ time!

➤ In CardsApp.swift, revert ResizableView() back to:

CardsListView()

Your project will now compile.

➤ In ResizableView.swift, resume your live preview and check out your new modifier.

View Modifier preview
View Modifier preview

It works exactly the same as ResizableView, but you can now apply the modifier to any view and make it resizable.

Using Your Custom View Modifier

In the preview, you used .modifier(ResizableView()). You can improve this by adding a “pass-through” method to View.

➤ Add this to the end of ResizableView.swift:

extension View {
  func resizableView() -> some View {
    modifier(ResizableView())
  }
}

You extend the View protocol with a default method. resizableView() is now available on any object that conforms to View. The method simply returns your modifier, but it does make your code easier to read.

➤ In ResizableView_Previews, replace .modifier(ResizableView()) with:

.resizableView()

➤ Open SingleCardView.swift and add a new view property:

var content: some View {
  ZStack {
    Capsule()
      .foregroundColor(.yellow)
      .resizableView()
    Text("Resize Me!")
      .font(.largeTitle)
      .fontWeight(.bold)
      .resizableView()
    Circle()
      .resizableView()
      .offset(CGSize(width: 50, height: 200))
  }
}

➤ In body, replace Color.yellow with:

content

Eventually, content will show card elements, but for now you can test your new resizable view. Here you test your modifier with two different types of views — two Shapes and one Text. The Circle’s offset is applied on top of the offset in resizableView(). Everything is put together inside a ZStack, which is a container view that allows its children to use absolute positioning.

➤ Check out your new resizing abilities in Live Preview.

Resize multiple views
Resize multiple views

There is a problem with the Text. Capsule remembers its size, because of the frame(width:height:alignment:) modifier inside ResizableView. However, Text has a font(_:) modifier. Because the modifier is applied directly to the view, it takes priority over frame(width:height:alignment:).

There is a trick to scaling text on demand. Give the font a huge size, say 500. Then apply a minimum scale factor to it, to reduce it in size.

➤ Remove .font(.largeTitle) from content.

➤ After .fontWeight(.bold), add:

.font(.system(size: 500))
.minimumScaleFactor(0.01)
.lineLimit(1)

.lineLimit(1) ensures the text stays on one line and doesn’t wrap around.

➤ Try resizing the text again in live preview. This time the text retains its size.

Resize the text
Resize the text

View Modifier Advantage

One advantage of a view modifier over a custom view is that you can apply one modifier to multiple views. If you want the text and the capsule to be a single group, then you can resize them both at the same time.

➤ Group Capsule and Text together inside the ZStack, and apply resizableView() to Group instead of the two views:

Group {
  Capsule()
    .foregroundColor(.yellow)
  Text("Resize Me!")
    .fontWeight(.bold)
    .font(.system(size: 500))
    .minimumScaleFactor(0.01)
    .lineLimit(1)
}
.resizableView()

Here, you grouped the two views together so they combine to a single view.

➤ Live Preview the view.

Grouped Views
Grouped Views

When you resize the capsule now, you drag and resize both capsule and text at the same time. This could be useful where you have a caption or a watermark on an image and you want them both at the same scale.

Other Gestures

  • Tap gesture

You used onTapGesture(count:perform:) in the previous chapter when tapping a card. There is also a TapGesture structure where you can use onEnded(_:) in the same way as with the other gestures in this chapter.

  • Long press gesture

Similarly, you can use either the structure LongPressGesture to recognize a long-press on a view, or use onLongPressGesture(minimumDuration:maximumDistance:pressing:perform:) if you don’t need to set up a separate gesture property.

Type Properties

Skills you’ll learn in this section: type properties; type methods

So far, you’ve hard coded the size of the card thumbnail, and also the default size in Transform. In most apps, you’ll want some global settings for sizes or color themes.

You do have the choice of holding constants in global space. You could, for example, create a new file and add this code at the top level:

var currentTheme = Color.red

currentTheme is then accessible to your whole app. However, as your app grows, sometimes it’s hard to immediately identify whether a particular constant is global or whether it belongs to your current class or structure. An easy way of identifying globals, and making sure that they only exist in one place, is to set up a special type for them and add type properties to the type.

Swift Dive: Stored Property vs Type Property

To create a type property, rather than a stored property, you use the static keyword.

You already used the type property CGSize.zero. CGPoint also has a type property of .zero and defines a 2D point with values in x and y. Examine part of the CGPoint structure definition to see both stored and type properties:

public struct CGPoint {
  public var x: CGFloat
  public var y: CGFloat
}

extension CGPoint {
  public static var zero: CGPoint { 
    CGPoint(x: 0, y: 0)
  }
}

This is an example of using a CGPoint:

var point = CGPoint(x: 10, y: 10)
point.x = 20

When you create an instance of the structure CGPoint, you set up x and y properties on the structure. These x and y properties are unique to every CGPoint you instantiate.

To use CGPoint’s type property, you use the name of the type:

let pointZero = CGPoint.zero  // pointZero contains (x: 0, y: 0) 

This sets up an instance of a CGPoint, named pointZero, with x and y values of zero.

When you instantiate a new structure, that structure stores its properties in memory separately from every other structure. A static or type property, however, is constant over all instances of the type. No matter how many times you instantiate the structure, there will only be one copy of the static type property.

In the following diagram, there are two copies of CGPoint, pointA and pointB. Each of them has its own memory storage area. CGPoint has a type property zero which is stored once.

Type property storage
Type property storage

Swift Tip: CGPoint.zero is defined as a computed property. It has a return value of CGPoint(x: 0, y: 0), and you can’t set it to any other value. There is no effective difference between defining .zero as a computed property or as static let zero = CGPoint(x: 0, y: 0). It is a stylistic choice.

Creating Global Defaults for Cards

Going back to your hard coded size values, you’ll now create a file that will hold all your global constants.

➤ Create a new group called Config.

➤ In Config, create a new Swift file called Settings.swift and replace the code with:

import SwiftUI

struct Settings {
  static let cardSize =
    CGSize(width: 1300, height: 2000)
  static let thumbnailSize =
    CGSize(width: 150, height: 250)
  static let defaultElementSize =
    CGSize(width: 250, height: 180)
  static let borderColor: Color = .blue
  static let borderWidth: CGFloat = 5
}

Here you create default values for the final card size, the card thumbnail size, the card element size and for a border that you’ll use later.

Notice that you created a structure. While this works, it could become problematic, because you could instantiate the structure and have copies of Settings throughout your app.

let settings1 = Settings()
let settings2 = Settings()

However, if you use an enumeration, you can’t instantiate it, so it ensures that you will only ever have one copy of Settings.

➤ Change struct Settings { to:

enum Settings {

Using an enumeration and type properties in this way future-proofs your app. Later on, someone else might want to add another setting to your app. They won’t need to change the enumeration itself, but they’ll simply be able to create an extension.

For example, they could add a new type property like this:

extension Settings {
  static let aNewSetting: Int = 0
}

Extensions can hold type properties, but not stored properties.

➤ Open CardThumbnail.swift. Instead of defining the frame size here, you can rely on your settings defaults.

➤ Change .frame(width: 150, height: 250) to:

.frame(
  width: Settings.thumbnailSize.width,
  height: Settings.thumbnailSize.height)

➤ Similarly, open Transform.swift and change var size = CGSize(width: 250, height: 180) to:

var size = CGSize(
  width: Settings.defaultElementSize.width,
  height: Settings.defaultElementSize.height)

If you want to change these sizes later on, you can do it in Settings.

Creating Type Methods

As well as static properties, you can also create static methods. To illustrate this, you’ll extend SwiftUI’s built-in Color type. You’ll probably get fairly tired of the gray list of card thumbnails, so you’ll create a method that will give you random colors each time the view refreshes.

➤ Create a new group and name it Extensions. In Extensions, create a new Swift file called ColorExtensions.swift and replace the code with:

import SwiftUI

extension Color {
  static let colors: [Color] = [
    .green, .red, .blue, .gray, .yellow, .pink, .orange, .purple
  ]
}

You created an array of Colors that’s available throughout the app by referencing Color.colors.

➤ Create a new method inside Color:

static func random() -> Color {
  colors.randomElement() ?? .black
}

This method returns a random element from the colors array and, if the colors array is empty, returns black.

Swift Tip: Astute readers will notice that this method could just as easily have been a static var computed property. However, conventionally, if you’re returning a value that may change often, or there is complex code, use a method.

➤ Open CardThumbnail.swift and change .foregroundColor(.gray) to:

.foregroundColor(.random())

Here you use the static method that you created on Color. Each time you list the thumbnails, they will use different colors.

➤ Preview CardsListView.swift and see your random card colors. Each time you press the Live icon, the colors change.

Random color
Random color

Challenge

Challenge: Make new View Modifiers

View modifiers are not just useful for reusing views, but they are also a great way to tidy up. You can combine modifiers into one custom modifier. Or, as with the toolbar modifier in SingleCardView, if a modifier has a lot of code in it, save yourself some code reading fatigue, and separate it into its own file.

Your challenge is to create a new view modifier that takes the toolbar code and moves it into a modifier called CardToolbar.

To do this, you’ll:

  1. Create a new file to hold the view modifier.
  2. Create a structure CardToolbar: ViewModifier and create a new method body that returns content, as you did when you made ResizableView a ViewModifier.
  3. Remove the preview, as it doesn’t make sense to have one for this modifier.
  4. For body, cut the toolbar and sheet modifier code from SingleCardView and paste the modifiers on CardToolbar’s content.
  5. In CardToolbar, you’ll need the dismiss environment object and currentModal as a binding.
  6. In SingleCardView, in body, add to content your new custom modifier: .modifier(CardToolbar(currentModal: $currentModal)).

When you’ve completed the challenge, your code should work the same, but, with this refactoring, SingleCardView is easier to read.

As always, you’ll find the solution in the challenge folder for this chapter.

Key Points

  • Custom gestures let you interact with your app in any way you choose. Make sure the gestures make sense. Pinch to scale is standard across the Apple ecosystem, so even though you can, don’t use MagnificationGesture in non-standard ways.
  • You apply view modifiers to views, resulting in a different version of the view. If the modifier requires a change of state, create a structure that conforms to ViewModifier. If the modifier doesn’t require a change of state, you can make code more readable by adding a method to a View extension and use that method to modify a view.
  • static or type properties and methods exist on the type. Stored properties exist per instance of the type. Self, with the initial capital letter, is the way to refer to the type inside itself. self refers to the instance of the type. Apple uses type properties and methods extensively. For example, Color.yellow is a type property.

Where to Go From Here?

By now you should be able to understand a lot of technical jargon. It’s time to check out Apple’s documentation and articles. Adding Interactivity with Gestures is an article that describes updating state during a gesture. Read this article and check your understanding of the topic so far.

The Apple article Composing SwiftUI Gestures, describes combining gestures in various ways.

Create your own modifiers. Any time you repeat your view’s design, you should look at creating a method or a modifier that encapsulates that code.

Think about parts of your app in modules. In this chapter, you created a useful resizable view modifier which you can now use in any app that you create. When creating views, consider how you could abstract them and make them more generic.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.