SwiftUI: stepper with animated bar

707 Views Asked by At

I would like to create a stepper component with an animated bar. Here is the result I get:

enter image description here

The idea is that the bar should always be centered, and also I would like to animate the blue bar when the value changes, but I can't get it working.

Here is my code:

struct Stepper: View {
    @Binding var currentIndex: Int
    var total: Int
    
    var body: some View {
        ZStack(alignment: .center) {
            ZStack(alignment: .leading) {
                Color.gray.opacity(0.4)
                Color.blue
                    .frame(width: 175.5 / CGFloat(total) * CGFloat(currentIndex))
            }
            .frame(width: 175.5, height: 2)
            Text("\(currentIndex)")
                .foregroundColor(.black)
                .offset(x: -113)
            Text("\(total)")
                .foregroundColor(.black)
                .offset(x: 113)
        }
        .frame(width: .infinity, height: 18)
    }
    
    init(withTotal total: Int,
         andCurrentIndex currentIndex: Binding<Int>) {
        self._currentIndex = currentIndex
        self.total = total
    }
    
    func update(to value: Int) {
        guard value >= 0, value <= total else {
            return
        }
        withAnimation {
            currentIndex = value
        }
    }
}

And how I call this in a container view:

struct StepperVC: View {
    @State private var currentIndex: Int = 1
    
    var body: some View {
        VStack(spacing: 32) {
            Stepper(withTotal: 8, andCurrentIndex: $currentIndex)
            Button(action: {
                currentIndex += 1
            }, label: {
                Text("INCREMENT")
            })
            Button(action: {
                currentIndex -= 1
            }, label: {
                Text("DECREMENT")
            })
        }
    }
}

Could you help me understanding why the animation doesn't work? Also, is there a better way to layout the UI?

Thank you!

3

There are 3 best solutions below

0
On BEST ANSWER

Update: Xcode 13.4 / iOS 15.5

According to Binding animatable concept for custom controls (to give possibility for control's users to manage if control behavior should be animatable or not) Stepper should handle

Stepper(withTotal: 8, andCurrentIndex: $currentIndex.animation(.default))

and so animatable part be like

ZStack(alignment: .leading) {
    Color.gray.opacity(0.4)
    Color.blue
        .frame(width: 175.5 / CGFloat(total) * CGFloat(currentIndex))
}
.frame(width: 175.5, height: 2)
.animation(_currentIndex.transaction.animation, value: currentIndex) // << here !!

Test module is here

Original

Here is fixed Stepper (tested with Xcode 12.1 / iOS 14.1)

demo

struct Stepper: View {
    @Binding var currentIndex: Int
    var total: Int
    
    var body: some View {
        ZStack(alignment: .center) {
            ZStack(alignment: .leading) {
                Color.gray.opacity(0.4)
                Color.blue
                    .frame(width: 175.5 / CGFloat(total) * CGFloat(currentIndex))
            }
            .frame(width: 175.5, height: 2)
            .animation(.default, value: currentIndex)     // << here !!
            Text("\(currentIndex)")
                .foregroundColor(.black)
                .offset(x: -113)
            Text("\(total)")
                .foregroundColor(.black)
                .offset(x: 113)
        }
        .frame(width: .infinity, height: 18)
    }
    
    init(withTotal total: Int,
         andCurrentIndex currentIndex: Binding<Int>) {
        self._currentIndex = currentIndex
        self.total = total
    }
}
0
On

Here is fixed Stepper centered alignment with animation. Also added validation for INCREMENT - DECREMENT value. You can now use it in your way without the update function. Currently, in your code, one warning appears for this line this is also solved.

 .frame(width: .infinity, height: 18)

Final code :

struct Stepper: View {
    @Binding var currentIndex: Int
    
    private var total: Int
    
    private var mainIndex: Int {
        if currentIndex >= 0 && currentIndex <= total {
            return currentIndex
            
        } else if currentIndex < 0 {
            DispatchQueue.main.async {
                self.currentIndex = 0
            }
            return 0
            
        } else {
            DispatchQueue.main.async {
                self.currentIndex = total
            }
            return total
        }
    }
    
    var body: some View {
        GeometryReader { geometry in
            HStack() {
                Text("\(mainIndex)")
                    .foregroundColor(.black)
                    .frame(width: 30)
                
                ZStack(alignment: .leading) {
                    Color.gray.opacity(0.4).frame(width: geometry.size.width - 60)
                    Color.blue.frame(width: (geometry.size.width - 60) / CGFloat(total) * CGFloat(mainIndex))
                }
                .animation(.default, value: mainIndex)
                .frame(width: geometry.size.width - 60, height: 2)
                
                Text("\(total)")
                    .foregroundColor(.black)
                    .frame(width: 30)
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
            .background(Color.yellow)
            
        }
    }
    
    init(withTotal total: Int,
         andCurrentIndex currentIndex: Binding<Int>) {
        self._currentIndex = currentIndex
        self.total = total
    }
    
    func update(to value: Int) {
        guard value >= 0, value <= total else {
            return
        }
        withAnimation {
            currentIndex = value
        }
    }
}

struct StepperVC: View {
    @State private var currentIndex: Int = 1
    
    var body: some View {
        VStack( alignment: .center,spacing: 32) {
            Stepper(withTotal: 8, andCurrentIndex: $currentIndex)
                .frame(width: UIScreen.main.bounds.size.width - 50, height: 50, alignment: .center)
            Button(action: {
                currentIndex += 1
            }, label: {
                Text("INCREMENT")
            })
            Button(action: {
                currentIndex -= 1
            }, label: {
                Text("DECREMENT")
            })
        }
    }
}
0
On

The simplest solution is to change currentIndex in a withAnimation block like this:

Button(action: {
    withAnimation {
        currentIndex += 1
    }
}, label: {
    Text("INCREMENT")
})