Skip to main content

SwiftUI animation: move up/down on press, spring on release - how to do the "move up" part

How can I get the blue circles to first move away from the green circle before getting back to it?

The animation should be:

  • press and hold:
    • the green circle scales down
    • the blue circles, while scaling down as well, first move "up" (away from their resting position, as if pushed away by the pressure applied) and then down (to touch the green circle again, as if they were pulled back by some gravitational force)
  • release
    • everything springs back into place
    • (bonus) ideally, the blue circles are ejected as the green circle springs up, and they fall back down in their resting position (next to the surface)

I got everything working except the blue circles moving up part.

This is the current animation: enter image description here

And its playground.

import SwiftUI
import PlaygroundSupport

struct DotsView: View {
    var diameter: CGFloat = 200
    var size: CGFloat = 25
    var isPressed: Bool = false

    var body: some View {
        ZStack {
            ForEach(0...5, id: \.self) { i in
                Circle()
                    .fill(Color.blue)
                    .frame(width: size, height: size)
                    .offset(x: 0, y: -(diameter)/2 - size/2)
                    .rotationEffect(.degrees(CGFloat(i * 60)))
            }
        }
        .frame(width: diameter, height: diameter)
        .animation(.none)
        .scaleEffect(isPressed ? 0.8 : 1)
        .animation(
            isPressed ? .easeOut(duration: 0.2) : .interactiveSpring(response: 0.35, dampingFraction: 0.2),
            value: isPressed
        )
        .background(
            Circle()
                .fill(Color.green)
                .scaleEffect(isPressed ? 0.8 : 1)
                .animation(isPressed ? .none : .interactiveSpring(response: 0.35, dampingFraction: 0.2), value: isPressed)
        )
    }
}

struct ContentView: View {
    @State private var isPressed: Bool = false

    var body: some View {
        DotsView(
            diameter: 200,
            isPressed: isPressed
        )
        .frame(width: 500, height: 500)
        .simultaneousGesture(
            DragGesture(minimumDistance: 0)
                .onChanged { _ in
                    isPressed = true
                }
                .onEnded { _ in
                    isPressed = false
                }
        )
    }
}

let view = ContentView()
PlaygroundPage.current.setLiveView(view)

Thanks

Answer

Actually all is needed is to replace linear scaleEffect with custom geometry effect that gives needed scale curve (initially growing then falling).

Here is a demo of possible approach (tested with Xcode 13.4 / iOS 15.5)

demo

Main part:

struct JumpyEffect: GeometryEffect {
    let offset: Double
    var value: Double

    var animatableData: Double {
        get { value }
        set { value = newValue }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        let trans = (value + offset * (pow(5, value - 1/pow(value, 5))))
        let transform = CGAffineTransform(translationX: size.width * 0.5, y: size.height * 0.5)
            .scaledBy(x: trans, y: trans)
            .translatedBy(x: -size.width * 0.5, y: -size.height * 0.5)
        return ProjectionTransform(transform)
    }
}

and usage

.modifier(JumpyEffect(offset: isPressed ? 0.3 : 0, value: isPressed ? 0.8 : 1))

Complete code on GitHub

Comments