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.
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
- Solution 1: GeometryReader and Scroll Offset Tracking
- Solution 2: Unique IDs with ScrollTo
- Solution 3: Disabling Animations and Scrolling
- Solution 4: VStack for Smaller Lists
- Solution 5: PreferenceKey for Offset Restoration
- Sources
- Conclusion
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:
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:
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:
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:
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:
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
- Reddit: Maintain scroll position when a View within a ScrollView changes size
- Reddit: Why does adding items in a Scroll View cause the view to scroll?
- Apple Developer Forums: LazyVStack with ScrollView’s new features
- Reddit: Improving performance of a ScrollView LazyVStack
- Stack Overflow: SwiftUI ScrollView scrolls when items added
- Stack Overflow: ScrollView stops components from expanding
- Apple Developer Forums: Content with variable height in LazyVStack
- Apple Developer Forums: ScrollView LazyVStack Performance
- Swift Forums: LazyVStack not refreshing content size correctly
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.