Search code examples
iosswiftswiftui

Combine spring animation with easeInOut in SwiftUI


I'm animating a line horizontally and it works fine, except that the effect I'm aiming for is to have a slight springy bounce before it alternates sides. I thought the .easeInOut effect would emulate that but it doesn't feel springy. How can I combine the .spring animation such that the line sort of bounces for a fraction of a second at each end before it moves to the alternate side?

struct Line: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 0, y: rect.size.height / 2))
        path.addLine(to: CGPoint(x: rect.size.width, y: rect.size.height / 2))
        return path
    }
}

struct LineView: View {
    let height: CGFloat
    @State private var animating = false
    
    var body: some View {
        GeometryReader { geometry in
            let lineWidth = geometry.size.width / 3
            
            Line()
                .stroke(Color.black, lineWidth: height)
                .frame(width: lineWidth)
                .offset(x: animating ? 0 : geometry.size.width - lineWidth)
                .animation(.easeInOut(duration: 1.0).repeatForever(), value: animating)
                .onAppear {
                    animating.toggle()
                }
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            LineView(height: 5.0)
                .frame(width: 200)
        }
    }
}

Any help is appreciated.

The intended effect (blue line): https://streamable.com/ijwe1w


Solution

  • You are on the right track, but you are over engineering your solution. Essentially, you need to make 3 layers. The first is a background as perceived by the user, but it is really the base view. On top of that, you add your visual. For simplicity, I used capsules. The top layer is a mask which is where the magic happens.

    To achieve the look you desire, the middle layer should overshoot the bottom layer in the offset, by how much you want it to "shrink". You then put a mask on top that is the same size as the bottom layer to act as a window that only allows the user to see that much of what is happening below. Here is what I believe you are looking for:

    struct LineView: View {
        let height: CGFloat
        @State private var animating = false
        
        var body: some View {
            GeometryReader { geometry in
                Capsule() // Base layer
                    .fill(.gray.opacity(0.3))
                    .frame(height: height + 2)
                    .overlay(alignment: .leading) {
                        let lineWidth = geometry.size.width / 3
                        Capsule()
                            .fill(.blue)
                            .frame(width: lineWidth)
                            // Here I am overshooting by half the line width in both directions
                            .offset(x: animating ? -(lineWidth / 2) : geometry.size.width - lineWidth / 2)
                            .animation(.easeInOut(duration: 1.0).repeatForever(), value: animating)
                    }
                    // Then mask over with a FillStyle(eoFill: true)
                    .mask(
                        Capsule()
                            .fill(style: FillStyle(eoFill: true))
                            .frame(height: height + 2)
                    )
                    .onAppear {
                        animating.toggle()
                    }
                    .frame(maxHeight: .infinity)
            }
        }
    }
    

    In the end, you get this:

    enter image description here

    (Note, this is a gif, and not as smooth as the actual animation)