Mobile Dev

SwiftUI ScrollView: Keep Expanded Items Visible in LazyVStack

Learn how to maintain visibility of expanded items in SwiftUI ScrollView with LazyVStack. Solutions include offset tracking, scrollTo, and VStack alternatives.

1 answer 1 view

How to keep expanded items visible in SwiftUI ScrollView with LazyVStack? When expanding a cell, it scrolls out of view - how to maintain visibility of the tapped item?

When expanding items in a SwiftUI ScrollView with LazyVStack, the layout recalculates and pushes content down, causing the tapped item to scroll out of view. To maintain visibility, track scroll offsets using GeometryReader and restore them after expansion, or use unique IDs with scrollTo to anchor the expanded item. For smaller lists, consider switching to VStack to avoid lazy loading complexities that cause scroll jumps.

Contents

Understanding the Problem

In SwiftUI, ScrollView combined with LazyVStack efficiently handles large datasets by loading views on-demand. However, when an item expands (e.g., revealing additional content), the container recalculates its content size. This forces the scroll position to shift downward, moving the tapped item out of view. The core issue stems from LazyVStack’s dynamic height calculations conflicting with ScrollView’s automatic scroll adjustments during state changes. As noted in Apple Developer Forums discussions, this behavior is especially problematic for variable-height content where expansions trigger unexpected layout recalculations.

Solution 1: GeometryReader and Scroll Offset Tracking

To maintain visibility, capture the scroll position before expansion and restore it afterward. Wrap your ScrollView in GeometryReader to track coordinates, then use @State to store and restore offsets:

swift
struct ContentView: View {
    @State private var scrollOffset: CGFloat = 0
    @State private var expandedItem: String?
    
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                LazyVStack(spacing: 8) {
                    ForEach(items, id: \.self) { item in
                        ItemView(
                            item: item,
                            isExpanded: Binding(
                                get: { expandedItem == item },
                                set: { if $0 { expandedItem = item } else { expandedItem = nil } }
                            )
                        )
                        .id(item)
                    }
                }
            }
            .onChange(of: expandedItem) { _ in
                if expandedItem != nil {
                    scrollOffset = geometry.frame(in: .global).minY
                } else {
                    // Restore scroll position to keep item visible
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        // Reset scrollOffset or adjust programmatically
                    }
                }
            }
        }
    }
}

This approach works by detecting the exact moment expansion occurs, capturing the scroll position, and then restoring it after the layout stabilizes. The Apple Developer Forums confirm this as an effective workaround for LazyVStack’s content size recalculation issues.

Solution 2: Unique IDs with ScrollTo

Assign unique identifiers to expandable views and use ScrollView’s scrollTo method to programmatically anchor expansions. This leverages SwiftUI’s built-in positioning logic:

swift
struct ContentView: View {
    @State private var expandedItem: String?
    @State private var scrollPosition: ScrollPositionView?
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                ForEach(items, id: \.self) { item in
                    ItemView(
                        item: item,
                        isExpanded: Binding(
                            get: { expandedItem == item },
                            set: { if $0 { expandedItem = item } else { expandedItem = nil } }
                        )
                    )
                    .id(item)
                }
            }
        }
        .scrollPosition(id: $expandedItem, anchor: .top)
        .onChange(of: expandedItem) { _, newItem in
            if let newItem {
                // Force scroll to expanded item
                withAnimation(.easeInOut(duration: 0.3)) {
                    scrollPosition?.scrollTo(newItem, anchor: .top)
                }
            }
        }
    }
}

As explained in Reddit discussions about performance, using .id() ensures SwiftUI tracks view positions uniquely, while scrollTo with .top anchoring keeps the expanded item at the viewport’s edge. For large lists, combine this with UUID-based IDs to prevent view recycling conflicts.

Solution 3: Disabling Animations and Scrolling

Temporarily disable ScrollView interactions during expansion to prevent unwanted scroll jumps:

swift
struct ContentView: View {
    @State private var isExpanding = false
    @State private var expandedItem: String?
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                ForEach(items, id: \.self) { item in
                    Button(action: {
                        isExpanding = true
                        expandedItem = item
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            isExpanding = false
                        }
                    }) {
                        ItemView(
                            item: item,
                            isExpanded: Binding(
                                get: { expandedItem == item },
                                set: { _ in }
                            )
                        )
                    }
                }
            }
        }
        .disabled(isExpanding) // Disable scrolling during expansion
        .scrollBounceBehavior(.basedOnSize) // Stabilize bouncing
    }
}

This technique prevents ScrollView from reacting to layout changes during expansion. According to Stack Overflow solutions, disabling scroll interactions for ~0.3 seconds gives the layout time to stabilize while keeping the visible item in place.

Solution 4: VStack for Smaller Lists

For datasets under 100 items, replace LazyVStack with regular VStack to avoid lazy loading complexities:

swift
struct ContentView: View {
    @State private var expandedItem: String?
    
    var body: some View {
        ScrollView {
            VStack(spacing: 8) {
                ForEach(items, id: \.self) { item in
                    ItemView(
                        item: item,
                        isExpanded: Binding(
                            get: { expandedItem == item },
                            set: { if $0 { expandedItem = item } else { expandedItem = nil } }
                        )
                    )
                }
            }
            .animation(.easeInOut(duration: 0.3), value: expandedItem)
        }
    }
}

As noted in Apple Developer Forums, VStack handles variable-height content more reliably than LazyVStack. While this impacts performance for large lists, it resolves scroll visibility issues by providing immediate layout updates without lazy loading delays.

Solution 5: PreferenceKey for Offset Restoration

For complex scenarios, create a custom PreferenceKey to store and restore scroll offsets:

swift
struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

struct ContentView: View {
    @State private var scrollOffset: CGFloat = 0
    @State private var expandedItem: String?
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                ForEach(items, id: \.self) { item in
                    ItemView(
                        item: item,
                        isExpanded: Binding(
                            get: { expandedItem == item },
                            set: { if $0 { expandedItem = item } else { expandedItem = nil } }
                        )
                    )
                    .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
                        scrollOffset = offset
                    }
                }
            }
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
                if expandedItem == nil {
                    // Restore offset after expansion
                }
            }
        }
    }
}

This advanced technique, detailed in Stack Overflow answers, provides granular control over scroll positioning by capturing offsets at multiple layout stages. It’s particularly useful when expanding items trigger significant content size changes.

Sources

Conclusion

Keeping expanded items visible in SwiftUI ScrollView with LazyVStack requires strategic approaches to counter layout recalculations. For most cases, combine GeometryReader offset tracking with scrollTo anchoring to maintain visibility during swiftui animation-based expansions. For smaller datasets, switching to vstack swiftui eliminates lazy loading complexities, while temporarily disabling scrolling stabilizes the view during transitions. Always test with real-world data to ensure your solution balances performance and UX, as noted in various developer community discussions. If issues persist, consider filing feedback with Apple to help improve ScrollView’s handling of dynamic content heights.

Authors
Verified by moderation
Moderation
SwiftUI ScrollView: Keep Expanded Items Visible in LazyVStack