MoveMe - SwiftUI Edition
Taking about gestures and animation with SwiftUI is actually not as intuitive as it sounds. But how hard could it be?
Setup
So like always we need 3 squares nicely lined up in the center of the screen. I’m going to use ZStack
because I want the squares to layout independently of each other. The only thing the parent container view needs to provide is the initial position.
struct SquareView: View {
var position: CGPoint
var body: some View {
RoundedRectangle(cornerRadius: 25.0, style: .continuous)
.frame(width: 100, height: 100)
.position(position)
.foregroundStyle(.blue)
}
}
struct ContentView: View {
var body: some View {
ZStack {
ForEach(0..<3) { idx in
GeometryReader { geometry in
SquareView(position: CGPoint(
x: geometry.size.width * 0.5,
y: geometry.size.height * (CGFloat(idx + 1) / 4.0)
))
}
}
}
}
}
Gestures
Next we need a tap gesture and when detected the selected square should become red. The obvious solution would be to use a TapGesture
but the TapGesture
only activates at touch end and what we need is a way to detect touch began. The actual solution I found is to use the DragGesture
with minimumDistance
set to 0
.
struct SquareView: View {
var position: CGPoint
@State var isSelected = false
var body: some View {
RoundedRectangle(cornerRadius: 25.0, style: .continuous)
.frame(width: 100, height: 100)
.position(position)
.foregroundStyle(isSelected ? .red : .blue)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ _ in isSelected = true })
.onEnded({ _ in isSelected = false })
)
}
}
Next we need to square to scale up when selected. Again the obvious solution is to use the scaleEffect
.
struct SquareView: View {
var position: CGPoint
@State var isSelected = false
var body: some View {
RoundedRectangle(cornerRadius: 25.0, style: .continuous)
.frame(width: 100, height: 100)
.position(position)
.foregroundStyle(isSelected ? .red : .blue)
.scaleEffect(isSelected ? 1.2 : 1)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ _ in isSelected = true })
.onEnded({ _ in isSelected = false })
)
}
}
But this brings another problem. Notice how the squares are not centered when scaling up.
This is because the view tree in SwiftUI is inverted because each modifier creates a new View
and wraps the invoking object as its child. So these two are equivalent:
Box()
.firstModifier()
.secondModifier()
SecondModifierView(
FirstModifierView(
Box()
)
)
So back in our solution above the scaleEffect
is applied first and then the position
. This make the center to become off centered.
The solution is to apply the position
modifier after the scaleEffect
.
struct SquareView: View {
var position: CGPoint
@State var isSelected = false
var body: some View {
RoundedRectangle(cornerRadius: 25.0, style: .continuous)
.frame(width: 100, height: 100)
.scaleEffect(isSelected ? 1.2 : 1)
.position(position)
.foregroundStyle(isSelected ? .red : .blue)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ _ in isSelected = true })
.onEnded({ _ in isSelected = false })
)
}
}
And finally we want the square to move around with the finger. This part is simple since we already have drag gesture, we just need to update the position of the square.
struct SquareView: View {
@State var position: CGPoint
@State var isSelected = false
var body: some View {
RoundedRectangle(cornerRadius: 25.0, style: .continuous)
.frame(width: 100, height: 100)
.scaleEffect(isSelected ? 1.2 : 1)
.position(position)
.foregroundStyle(isSelected ? .red : .blue)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ value in
isSelected = true
position = value.location
})
.onEnded({ _ in isSelected = false })
)
}
}
Animations
For the next part we would like the transitions to animate. For this we can either use the withAnimation
block
struct SquareView: View {
@State var position: CGPoint
@State var isSelected = false
var body: some View {
RoundedRectangle(cornerRadius: 25.0, style: .continuous)
.frame(width: 100, height: 100)
.scaleEffect(isSelected ? 1.2 : 1)
.position(position)
.foregroundStyle(isSelected ? .red : .blue)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ value in
withAnimation {
isSelected = true
}
position = value.location
})
.onEnded({ _ in
withAnimation {
isSelected = false
}
})
)
}
}
But look our favorite bug is back again. Notice how the squares are off centered when selected.
But now we know why this bug exists. We need to guarantee that the position is applied after the scaling. But since we are using the withAnimation
block it sets some flag for the next draw pass to be done with animation. And that means all the changes are animated. But what we want is to have only have the scaling change as animated and everything else non animated.
To achieve this we can use the animation
modifier. It does the same thing as withAnimation
block but we can control where in the View
hierarchy we want the animation to happen.
struct SquareView: View {
@State var position: CGPoint
@State var isSelected = false
var body: some View {
RoundedRectangle(cornerRadius: 25.0, style: .continuous)
.scaleEffect(isSelected ? 1.2 : 1, anchor: .center)
.animation(.easeOut, value: isSelected)
.frame(width: 100, height: 100)
.position(position)
.foregroundStyle(isSelected ? .red : .blue)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ value in
isSelected = true
position = value.location
})
.onEnded({ _ in
isSelected = false
})
)
}
}
The solution is available on https://github.com/chunkyguy/MoveMe