SegmentedControl Animation

118 Views Asked by At

I'm trying to make my custom segmented control. With UI and functionality I'm ok but I've some problems with animations. I want that white circle moves to new tapped value with a fluid animation perhaps stretching and then returning to a circle once the right position is reached. enter image description here

struct SegmentedControl: View{
    
    @State var position: RotationCase = .P1
    
    
    var body: some View {
        ZStack {
            HStack {
                    ForEach(RotationCase.allCases, id: \.self) { rcase in
                        Text(rcase.rawValue)
                            .foregroundColor(rcase == position ? .black : .white)
                            .padding()
                            .background(rcase == position ? .white : .black)
                            .clipShape(Circle())
                            .onTapGesture {
                                withAnimation {
                                    position = rcase
                                }
                            }
                           
                        
                    }
                }
        }
        .frame(height: 45)
        .padding(10)
        .background(.black)
        .clipShape(Capsule())
        .shadow(radius: 8)
            
    }
}

enum RotationCase:String, CaseIterable {
    case P1 = "P1", P2 = "P2" , P3 = "P3" , P4 = "P4" , P5 = "P5" , P6 = "P6"
}

Did you have a solution or a tutorial ref for this kind of animations?

1

There are 1 best solutions below

0
Yrb On

I am going to preface my answer by pointing you to a wonderful post on creating an animating shapes: Blob, the Builder - A Step by Step Guide to SwiftUI Animation that does a great job of creating a shape and animating it. However, in the end, what he created was a Capsule() into which he could send inputs to change the size. That seemed a bit of overkill when we can do essentially the same thing with a Capsule() and a .frame().

The other thing I wanted to avoid was a GeometryReader. I like them and use them, but used incorrectly things can go sideways pretty quickly. This answer is all computed with just one spacer and one capsule each in its own frame underneath of the buttons. The code is commented below.

struct FluidSegmentedControl: View {
    
    // I converted your position variable to an array of RotationCase that represents the total range covered by the `Capsule`. At rest, it contains one element, the position.
    @State var range: [RotationCase] = [.P1]
    // The HStack below has no spacing in it. That was trick #1 to be able to line things up, so we need to add our padding into the width of the button.
    let textWidth: CGFloat = 50
    let textHeight: CGFloat = 44
    
    // This is the animation for the movement. You can play around with it.
    // Amos Gyamfi has an excellent resource at https://medium.com/@amosgyamfi/learning-swiftui-spring-animations-the-basics-and-beyond-4fb032212487
    let animation: Animation = .spring(response: 0.7, dampingFraction: 0.7, blendDuration: 1.5)
    // This is how long the range contains the full array from start to finish, until it reverts to the single position element.
    let movementDuration = 0.4
    
    // This allows us to line up the capsule properly.
    var offset: CGFloat {
        (textWidth - textHeight) / 2
    }
    
    var body: some View {
        ZStack {
            // It is very important that the spcaing is set to 0 for both HStacks. We need to control it.
            HStack(spacing: 0) {
                ForEach(RotationCase.allCases) { rcase in
                    Text(rcase.rawValue)
                        .foregroundColor(.white)
                        // Rather than change the color based on the selection to get the right contrast, a .blendMode(.difference) will cause the contrast to be pixel perfect
                        .blendMode(.difference)
                        .clipShape(Circle())
                        .onTapGesture {
                                updateRange(rcase: rcase)
                        }
                        .frame(width: textWidth, height: textHeight)
                }
            }
            // There is only one capsule, and it is contained in an HStack in the background.
            .background(
                HStack(spacing: 0) {
                    // By controlling the size of this Spacer(), we control where the capsule starts
                    Spacer()
                        .frame(width: leadingSpacerWidth())
                    Capsule()
                        .fill(.white)
                        .frame(width: capsuleWidth())
                        .offset(x: offset) // the capsule always needs to be offset a bit to be centered.
                    Spacer()
                }
            )
            
        }
        .frame(height: textHeight)
        .padding(10)
        .background(.black)
        .clipShape(Capsule())
        .shadow(radius: 8)
        
    }
    
    // The capuleWidth is the number of elements in the array times the textWidth. This makes it stretch while there are multiple elements.
    private func capsuleWidth() -> CGFloat {
        let spaces = CGFloat(range.count)
        if spaces > 1 {
            return (spaces * textWidth) - (2 * offset)
        } else {
            return textHeight
        }
    }
    
    // Whatever the first element of the array is determines how many spaces we push the capsule over. The index is 0 based, so we don't push it at all for the first.
    private func leadingSpacerWidth() -> CGFloat {
        guard let first = range.first else { return 0 }
        return CGFloat(first.index) * textWidth
    }
    
    func updateRange(rcase: RotationCase) {
        // It may be possible for the user to click to fast before the animation is complete. This prevents that with an immediate return.
        if range.count > 1 {
            return
        }
        
        guard let position = range.first else {
            return
        }
        
        // We are moving to the right, so the rcase is the upper end in the array
        if rcase > position {
            withAnimation(animation) {
                // We sent the range to be an array slice of the total array of RotationCase
                range = Array(RotationCase.allCases[position.index...rcase.index])
            }
            // We are moving to the left, so the rcase is the lower end in the array
        } else {
            withAnimation(animation) {
                range = Array(RotationCase.allCases[rcase.index...position.index])
            }
        }
        // after some time, we tell the array to include only the position as an element.
        DispatchQueue.main.asyncAfter(deadline: .now() + movementDuration) {
            withAnimation(animation) {
                range = [rcase]
            }
        }
    }
}

enum RotationCase: String, CaseIterable, Identifiable, Comparable {
    case P1, P2, P3, P4, P5, P6
    
    // This makes it Identifiable so we can remove the
    var id: String {
        self.rawValue
    }
    
    // This allows us to obtain the index for the enum
    var index: Int {
        switch self {
        case .P1:
            return 0
        case .P2:
            return 1
        case .P3:
            return 2
        case .P4:
            return 3
        case .P5:
            return 4
        case .P6:
            return 5
        }
    }
    
    static var total: Int {
        allCases.count
    }
    
    static func < (lhs: RotationCase, rhs: RotationCase) -> Bool {
        lhs.index < rhs.index
    }
}