Exclusively modifier: Composing Gestures in SwiftUI

DevTechie Inc
May 11, 2023

Gestures provide by SwiftUI can be combined to build complex interactions and this is where composing gesture modifiers comes into picture.

Today, we will look at exclusively(before:) gesture modifier which combines two gestures exclusively to create a new gesture where only one gesture succeeds, giving precedence to the first gesture. In other words, we can have two gestures applied on the view but only one or the other will activate at a time.

Let’s understand this better with an example.

We will start with a simple Text view.

struct DevTechieExclusivelyModifier: View {    
    var body: some View {
        VStack {
            Text("DevTechie")
                .font(.largeTitle)
                .padding()
                .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 20))
        }
    }
}

Next, we will add DragGesture to this view. DragGesture gives us access to translation value while view is being dragged so we will use that to update the offset of the view. We will also add animation.

struct DevTechieExclusivelyModifier: View {
    @State var offset = CGSize.zero
    
    var body: some View {
        VStack {
            Text("DevTechie")
                .font(.largeTitle)
                .padding()
                .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 20))
                .offset(offset)
                .gesture(DragGesture().onChanged({ value in
                    offset = value.translation
                }))
                .animation(.easeInOut, value: offset)
        }
    }
}

If we add magnification gesture on the top of drag gesture, it would interfere with the drag gesture.

struct DevTechieExclusivelyModifier: View {
    @State var offset = CGSize.zero
    @State var scale = 1.0
    
    var body: some View {
        VStack {
            Text("DevTechie")
                .font(.largeTitle)
                .padding()
                .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 20))
                .offset(offset)
                .gesture(DragGesture().onChanged({ value in
                    offset = value.translation
                }))
                .scaleEffect(scale)
                .gesture(MagnificationGesture().onChanged({ value in
                    scale = value
                }))
                .animation(.easeInOut, value: offset)
        }
    }
}

In order to solve this, we can tell SwiftUI that we only want one gesture to be active at one point and we can do that by using exclusively modifier.

We will also create a struct for our State properties so both properties can be wrapped inside a single model.

struct DevTechieExclusivelyModifier: View {
    @State var gesture = DragOrMagnify()
    
    var body: some View {
        VStack {
            Text("DevTechie")
                .font(.largeTitle)
                .padding()
                .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 20))
                .offset(gesture.offset)
                .scaleEffect(gesture.scale)
                .gesture(DragGesture().onChanged({ value in
                    gesture.offset = value.translation
                }).exclusively(before: MagnificationGesture().onChanged({ value in
                    gesture.scale = value
                })))
                .animation(.easeInOut, value: gesture)
        }
    }
    
    struct DragOrMagnify: Equatable {
        var scale = 1.0
        var offset: CGSize = .zero
    }
}

If we want to revert the view back to its original state, we can change the State property wrapper to GestureState property wrapper.

GestureState is a property wrapper that updates a value while the user performs a gesture. When the gesture ends, the wrapped value reverts back to its initial value automatically.

struct DevTechieExclusivelyModifier: View {
    @GestureState var gesture = DragOrMagnify()
    
    var body: some View {
        VStack {
            Text("DevTechie")
                .font(.largeTitle)
                .padding()
                .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 20))
                .offset(gesture.offset)
                .scaleEffect(gesture.scale)
                .gesture(DragGesture().updating($gesture, body: { value, state, transaction in
                    state.offset = value.translation
                }).exclusively(before: MagnificationGesture().updating($gesture, body: { value, state, transaction in
                    state.scale = value
                })))
                .animation(.easeInOut, value: gesture)
        }
    }
    
    struct DragOrMagnify: Equatable {
        var scale = 1.0
        var offset: CGSize = .zero
    }
}