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.
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:
- Create a
RoundedRectangleview 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. - Use
contentas the requiredViewinbodyand apply modifiers to it.
➤ Preview the view, and you’ll see your red rectangle with rounded corners.
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.
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.
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.
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.)
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 changinganchor.
➤ 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
➤ 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.
Creating Custom View Modifiers
Skills you’ll learn in this section: creating a
ViewModifier;Viewextension; 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.
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.
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.
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.
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.
Swift Tip:
CGPoint.zerois defined as a computed property. It has a return value ofCGPoint(x: 0, y: 0), and you can’t set it to any other value. There is no effective difference between defining.zeroas a computed property or asstatic 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 varcomputed 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.
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:
- Create a new file to hold the view modifier.
- Create a structure
CardToolbar: ViewModifierand create a new methodbodythat returnscontent, as you did when you madeResizableViewaViewModifier. - Remove the preview, as it doesn’t make sense to have one for this modifier.
- For
body, cut the toolbar and sheet modifier code fromSingleCardViewand paste the modifiers onCardToolbar’scontent. - In
CardToolbar, you’ll need thedismissenvironment object andcurrentModalas a binding. - In
SingleCardView, inbody, add tocontentyour 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
MagnificationGesturein 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 aViewextension and use that method to modify a view. -
staticor 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.selfrefers to the instance of the type. Apple uses type properties and methods extensively. For example,Color.yellowis 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.