skip to Main Content

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

2

Answers


  1. 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)

    Login or Signup to reply.
  2. It works quite well if you just animate the leading and trailing padding and use negative padding at the sides. The shape then needs to be clipped, so that the effect of the negative padding is to make the line look shorter.

    It is not really necessary to use a Line shape. You can just use a Rectangle as both the filled shape and the clip shape. Alternatively, if you would like rounded corners, use a Capsule.

    struct LineView: View {
        let height: CGFloat
        @State private var animating = false
    
        var body: some View {
            GeometryReader { geometry in
                let w = geometry.size.width
                let maxWidth = w / 3
                let minWidth = maxWidth / 2
                Capsule()
                    .fill(.blue)
                    .padding(.leading, animating ? minWidth - maxWidth : w - minWidth)
                    .padding(.trailing, animating ? w - minWidth : minWidth - maxWidth)
                    .clipShape(Capsule())
                    .frame(height: height)
                    .frame(maxHeight: .infinity)
                    .animation(.easeInOut(duration: 1.0).repeatForever(), value: animating)
                    .onAppear {
                        animating.toggle()
                    }
            }
        }
    }
    

    Animation

    It can be done by setting the x-offset instead of padding. This works too (and is closer to what you originally had):

    Capsule()
        .fill(.blue)
        .frame(width: maxWidth, height: height)
        .offset(x: animating ? minWidth - maxWidth : w - minWidth)
        .frame(width: w, alignment: .leading)
        .clipShape(Capsule())
        .frame(maxHeight: .infinity)
        .animation(.easeInOut(duration: 1.0).repeatForever(), value: animating)
        .onAppear {
            animating.toggle()
        }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search