iosswiftanimationswiftui

How to reset the offset of a list of dynamic size buttons in a horizontal infinite scrollview animation


I have a list of buttons that flow right to left in a horizontal scroll view. The scroll happens automatically (animation fashion) and it should reset when reaching upon last button.

Two issues I'm dealing with:

  1. upon reaching the last button it resets the offset but does a jump animation, no smooth reset
  2. no smooth animation of button flow (a bit laggy)

Below is my code:

 let buttonData = [
        ["🕯️ Did Edison invent the lightbulb?", "🚴‍♂️ How do you ride a bike?"],
        ["⛷️ The best ski resorts in the Alps", "🏟️ The greatest Super Bowl moments", "🎬 Best movies of all time"],
        ["🥊 The best boxing style", "🐩 The best dog breed for apartments","🏖️ Top beach destinations"],
    ]
@State private var offsetX: CGFloat = 0 // To move the buttons horizontally
    @State private var widthOfSingleButton: CGFloat = 0 // Holds the width of one button
    
    var body: some View {
            // Measuring one button's width to manage the scroll loop
            GeometryReader { geometry in
                ScrollView(.horizontal, showsIndicators: false) {
                    VStack{
                        ForEach(0..<buttonData.count, id: \.self) { rowIndex in
                            HStack(spacing: 16) {
                                ForEach(buttonData[rowIndex], id: \.self) { item in
                                    Button(action: {
                                        // Button action
                                    }) {
                                        Text(item)
                                            .padding()
                                            .background(Color.gray.opacity(0.1))
                                            .cornerRadius(5)
                                            .foregroundColor(.black)
                                    }
                                    .background(GeometryReader { buttonGeo -> Color in
                                        DispatchQueue.main.async {
                                            if self.widthOfSingleButton == 0 {
                                                self.widthOfSingleButton = buttonGeo.size.width + 16 // button width + spacing
                                            }
                                        }
                                        return Color.clear
                                    })
                                }
                            }
                            .offset(x: offsetX)
                            .onAppear {
                                startScrolling(totalButtonsWidth: geometry.size.width)
                            }
                        }
                    }
                    
                }
            }
            .frame(height: 200) // Constrain the height of the button area
            .frame(maxWidth: UIScreen.main.bounds.width) // Constrain the width of the button area
        }
    
    
    func startScrolling(totalButtonsWidth: CGFloat) {
        // Animation loop to slowly move the buttons horizontally
        let baseSpeed: CGFloat = 50 // Speed of the scroll (points per second)
        
        Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
            withAnimation(.linear(duration: 0.1)) {
                // Continuously move to the left
                offsetX -= baseSpeed * 0.02
                
                // Calculate the total width of all buttons
                let totalWidth = widthOfSingleButton * CGFloat(buttonData.count)
                
                // Loop the scroll when reaching the end
                if offsetX < -totalWidth {
                    offsetX = 0
                }
            }
        }
    }

The problem: upon reaching showing all buttons it resets with a jump animation and the animation of flowing is not very smooth (laggy)

The expected result: the buttons should flow smoothly and repeat upon reaching the last button without jumping animation.


Solution

  • I would suggest using a repeating linear animation, then you don't need a Timer.

    Also, I am guessing that the rows should scroll independently and repeat seamlessly, so there should never be any large spaces in the rows. If this is the case, it works well to factor-out the buttons for a scrolling row into a separate View. This allows each row to have its own state.

    The only input that is needed for the animation is the width of a row. Instead of using a GeometryReader to measure the width of each button, just measure the full width of the HStack using a GeometryReader in the background. The way you had it before, the buttons were all updating the same state variable widthOfSingleButton, so this may have been a possible cause of unexpected behavior.

    Other suggestions:

    Here is the updated example to show it working this way:

    struct ContentView: View {
        let buttonData = [
            ["🕯️ Did Edison invent the lightbulb?", "🚴‍♂️ How do you ride a bike?"],
            ["⛷️ The best ski resorts in the Alps", "🏟️ The greatest Super Bowl moments", "🎬 Best movies of all time"],
            ["🥊 The best boxing style", "🐩 The best dog breed for apartments","🏖️ Top beach destinations"],
        ]
    
        var body: some View {
    
            // Apply the scrolling content as an overlay to a placeholder,
            // so that it is leading-aligned instead of center-aligned
            Color.clear
                .overlay(alignment: .leading) {
                    VStack(alignment: .leading) {
                        ForEach(Array(buttonData.enumerated()), id: \.offset) { rowIndex, buttonItems in
                            ScrollingButtonRow(buttonItems: buttonItems)
                        }
                    }
                }
                .frame(height: 200) // Constrain the height of the button area
        }
    }
    
    struct ScrollingButtonRow: View {
        let buttonItems: [String]
        let spacing: CGFloat = 16
        let baseSpeed: CGFloat = 50 // Speed of the scroll (points per second)
        @State private var offsetX: CGFloat = 0 // To move the buttons horizontally
    
        var body: some View {
            HStack(spacing: spacing) {
                buttons
                    .background {
    
                        // Measure the width of one set of buttons and
                        // launch scrolling when the view appears
                        GeometryReader { proxy in
                            Color.clear
                                .onAppear {
                                    startScrolling(scrolledWidth: proxy.size.width + spacing)
                                }
                        }
                    }
    
                // Repeat the buttons, so that the follow-on is seamless
                buttons
            }
            .offset(x: offsetX)
        }
    
        private var buttons: some View {
            HStack(spacing: spacing) {
                ForEach(Array(buttonItems.enumerated()), id: \.offset) { offset, item in
                    Button {
                        // Button action
                    } label: {
                        Text(item)
                            .padding()
                            .fixedSize()
                            .foregroundStyle(.black)
                            .background {
                                RoundedRectangle(cornerRadius: 5)
                                    .fill(.gray.opacity(0.1))
                            }
                    }
                }
            }
        }
    
        private func startScrolling(scrolledWidth: CGFloat) {
            withAnimation(
                .linear(duration: scrolledWidth / baseSpeed)
                .repeatForever(autoreverses: false)
            ) {
                offsetX = -scrolledWidth
            }
        }
    }
    

    Here is how the animation looks for a single row. The gif is more jittery than it looks when running in the simulator.

    Animation