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:
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.
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:
The outer ScrollView
is not needed. However, if it is not there then it is necessary to apply .fixedSize()
to the button labels, to stop the text from truncating.
The outer GeometryReader
is not needed either. In order to ensure that the content is leading-aligned instead of center-aligned, it can be shown as an overlay over a placeholder of Color.clear
. The width of the placeholder is also the width used in the overall layout, so there is no need to apply a width to the VStack
.
The modifier foregroundColor
is deprecated, use .foregroundStyle
instead.
The modifier .cornerRadius
is also deprecated. You could use a clip shape with a RoundedRectangle
instead, or just show a RoundedRectangle
in the background.
Use enumerations of the arrays for the ForEach
, instead of indices into the arrays.
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.