Solving SwiftUI ScrollView Off‑by‑One Bug with viewAligned

Summary

A critical synchronization bug was identified in a SwiftUI implementation featuring a bidirectional binding between a horizontal ScrollView and a button navigation bar. While manual user gestures (swiping) synchronized the UI correctly, programmatically triggering scrolls via button taps resulted in incorrect index alignment and “off-by-one” errors. This behavior specifically occurred when using .viewAligned scroll target behavior combined with containerRelativeFrame.

Root Cause

The issue stems from a race condition and state conflict within the SwiftUI view update cycle. When a button is tapped, two competing state changes occur:

  • State Update Loop: The selectedButtonsIndex is updated by the button action. This triggers an .onChange modifier that attempts to update scrollPosition.
  • Layout-State Conflict: Because the items use .containerRelativeFrame, the actual geometry of the scroll view is heavily dependent on the current scroll offset and safe area padding.
  • The “Fighting” State: When scrollPosition is updated programmatically, SwiftUI attempts to animate the scroll. Simultaneously, the .onChange(of: scrollPosition) modifier detects a change and tries to update selectedButtonsIndex.
  • Incorrect Target Calculation: Because .viewAligned behavior calculates the “snap” target based on the current visible geometry, a programmatic jump that hasn’t completed its layout pass can cause the scroll engine to snap to the nearest valid alignment point relative to the previous position, rather than the intended target.

Why This Happens in Real Systems

In complex production interfaces, we often implement dual-source-of-truth patterns where the UI must reflect both user input and programmatic commands. This becomes problematic due to:

  • Asynchronous Layout Passes: Changes to state often trigger a layout pass that is not instantaneous. If you update State A, which triggers State B, which in turn affects the layout of State A, you create a feedback loop.
  • Declarative vs. Imperative Conflict: We are telling SwiftUI what the state should be (scrollPosition = newIndex), but the underlying UIScrollView (which powers SwiftUI’s ScrollView) is performing an imperative animation to get there. If the state updates faster than the animation finishes, the logic breaks.
  • Geometry Dependencies: When scroll targets depend on containerRelativeFrame or safeAreaPadding, the “correct” scroll offset is a moving target during an animation.

Real-World Impact

  • Broken User Trust: Users feel the app is “glitchy” when tapping a specific item (e.g., “Item 3”) results in the wrong content being displayed.
  • Accessibility Failures: Screen readers may announce the wrong index if the internal state and visual position are out of sync.
  • Degraded UX in High-Performance Apps: In apps like E-commerce carousels or Photo Galleries, these “jumpy” animations make the interface feel unpolished and unprofessional.

Example or Code

import SwiftUI

struct FixedContentView: View {
    let items = Array(1...8)
    @State private var selectedButtonsIndex: Int? = 0
    @State private var scrollPosition: Int? = 0

    // We use a flag to prevent the feedback loop during programmatic updates
    @State private var isProgrammaticScroll = false

    var body: some View {
        VStack(spacing: 10) {
            LazyHStack(spacing: 15) {
                ForEach(Array(items.enumerated()), id: \.element) { index, item in
                    Button(action: {
                        performProgrammaticScroll(to: index)
                    }) {
                        Text("\(item)")
                            .font(.title2.bold())
                            .foregroundColor(selectedButtonsIndex == index ? .white : .blue)
                            .frame(width: 30, height: 30)
                            .background(
                                Circle()
                                    .fill(selectedButtonsIndex == index ? Color.blue : Color.blue.opacity(0.2))
                            )
                    }
                    .buttonStyle(PlainButtonStyle())
                }
            }
            .padding(.horizontal)

            ScrollView(.horizontal) {
                LazyHStack(spacing: 10) {
                    ForEach(Array(items.enumerated()), id: \.element) { index, item in
                        CardView(number: item, isSelected: selectedButtonsIndex == index)
                            .containerRelativeFrame(.horizontal) { width, _ in width * 0.95 }
                            .id(index)
                            .onTapGesture {
                                withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                                    selectedButtonsIndex = index
                                }
                            }
                    }
                }
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.viewAligned)
            .safeAreaPadding(.horizontal, 20)
            .scrollPosition(id: $scrollPosition)
            .onChange(of: selectedButtonsIndex) { _, newValue in
                if let newIndex = newValue, !isProgrammaticScroll {
                    // Manual tap on card updates button
                    scrollPosition = newIndex
                }
            }
            .onChange(of: scrollPosition) { _, newValue in
                // If the scroll position changed via gesture, update the button
                if newValue != selectedButtonsIndex {
                    selectedButtonsIndex = newValue
                }
            }

            HStack {
                ForEach(0..<items.count, id: \.self) { index in
                    Circle()
                        .fill(index == selectedButtonsIndex ? Color.blue : Color.gray.opacity(0.3))
                        .frame(width: 8, height: 8)
                }
            }
        }
        .padding(.vertical)
    }

    private func performProgrammaticScroll(to index: Int) {
        isProgrammaticScroll = true
        selectedButtonsIndex = index
        withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
            scrollPosition = index
        }
        // Reset the flag after the animation duration to allow manual sync again
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
            isProgrammaticScroll = false
        }
    }
}

struct CardView: View {
    let number: Int
    let isSelected: Bool
    var body: some View {
        RoundedRectangle(cornerRadius: 25)
            .fill(LinearGradient(colors: isSelected ? [.blue, .purple] : [.gray, .blue.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing))
            .overlay(Text("\(number)").font(.system(size: 80, weight: .bold)).foregroundColor(.white))
    }
}

How Senior Engineers Fix It

  1. Identify the Feedback Loop: Recognize that selectedButtonsIndex and scrollPosition are mutually dependent.
  2. Introduce a Guard/Flag: Use a transient state variable (like isProgrammaticScroll) to differentiate between a user-initiated gesture and a code-initiated command. This prevents the “echo” effect where a programmed change triggers a secondary reaction that interferes with the first.
  3. Decouple Update Logic: Instead of relying purely on .onChange, use explicit functions for programmatic actions that manage the lifecycle of the state change.
  4. Timing Management: Use DispatchQueue or Task.sleep to reset synchronization flags only after the animation/layout cycle has physically completed.

Why Juniors Miss It

  • Over-reliance on Declarative State: Juniors often assume that if they update a @State variable, SwiftUI will “magically” handle the sequence of events correctly. They miss the fact that layout and animation are asynchronous processes.
  • Ignoring the Animation Lifecycle: They treat withAnimation as a way to make things look pretty, rather than understanding it as a temporal period where the view’s properties (like offset) are in flux.
  • Lack of Loop Detection: They fail to realize that updating State A $\rightarrow$ State B $\rightarrow$ State A is a recipe for non-deterministic UI behavior.

Leave a Comment