Post

Replies

Boosts

Views

Activity

Unexpected behavior in the interaction between LazyVStack and GeometryReader
Hello! I'd like to share a problem and its potential solution. Steps to reproduce: The issue can be reproduced with the following minimal example: struct TestConditionalScrollView: View { var body: some View { ConditionalScrollView { LazyVStack(spacing: 16) { Text("Text 1") .frame(height: 20) Text("Text 2") .frame(height: 30) Text("Text 3") .frame(height: 40) Text("Text 4") .frame(height: 500) Text("Text 5") .frame(height: 400) } .padding() } } } struct ConditionalScrollView<Content: View>: View { let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } @State private var contentHeight: CGFloat = 0 var body: some View { GeometryReader { geo in _ = print("Height: \(contentHeight)") return Group { if contentHeight > geo.size.height { ScrollView { measuredContent } } else { measuredContent } } } } private var measuredContent: some View { content .background( GeometryReader { geo in Color.clear .preference( key: ContentHeightKey.self, value: geo.size.height ) } ) .onPreferenceChange(ContentHeightKey.self) { contentHeight = $0 } } } struct ContentHeightKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } If we hit the breakpoint on the following line: _ = print("Height: (contentHeight)") the output looks like this: Problems observed Different values are reported, and it is unclear where those values originate from. The selected execution branch appears to change multiple times during the layout process. Possible reason As Rens Breur mentioned in the WWDC26 session "Dive into lazy stacks and scrolling with SwiftUI", LazyVStack relies on estimated layout information during certain phases of the layout process. I do not want to rely on or investigate SwiftUI's non-public implementation details, but I would like to explain what I believe is happening internally. To do that, let me show how the value reaches the GeometryReader closure: Step 1 In AttributeGraph, the GeometryReader<...> node and the LazyVStack node appear to be connected as shown below: Step 2 When the LazyVStack node is updated, the layout process appears to follow roughly this logic: `SwiftUICore 'SwiftUI.ForEachState.forEachItem:` n = number of cells to evaluate (initially n == 2) For first n cells: SwiftUICore`SwiftUI.ViewLayoutEngine.sizeThatFits(...) SwiftUI.EstimationCache.add(...) The total size of the lazy stack is then estimated: SwiftUI.LazyStack<...>.sizeThatFits(...): averageCellInfo = EstimationCache.average averageCellInfo.height = (firstCellHeight + secondCellHeight) / 2 totalHeight = firstCellHeight + secondCellHeight + averageCellInfo.height * remainingCells For the sample project, this produces an estimated height of 189. This value then appears to be cached inside a LazyLayoutComputer node. Step 3 When GeometryReader is updated and its closure executes, geo.size.height appears to be resolved from the cached value stored by LazyLayoutComputer. As a result, the reported height is: 189 + 32 (padding) = 221 Step 4 My assumption is that LazyVStack subsequently validates the estimated layout against the actual layout results. It seems to compare: The maximum Y position of the last list's cell The cached sizeThatFits value If those values differ sufficiently, the transaction is not committed and another layout pass is triggered. During a later pass, the real sizes become available and GeometryReader eventually reports the final correct value. If this interpretation is correct, the behavior shown in the logs would be expected: followed later by: Possible solution I could not find a public SwiftUI API that provides an accurate content size during the initial layout pass. I tried the following options: LazyVStack + GeometryReader LazyVStack + ViewThatFits LazyVStack + .scrollBounceBehavior(...) At the same time, SwiftUI itself appears to have information about layout validity. For example, the layout logs contain entries such as: placed(...) -> ... invalid: true This suggests that SwiftUI can determine when an estimated layout result is no longer valid and requires additional layout passes. If SwiftUI knows that the current layout is invalid, is there a way to access this information from within a GeometryReader closure or by some other means? Otherwise, clients may perform layout calculations based on invalid geometry, which can result in a broken dependent layout. Have a good day!
0
0
13
5h
LazyVStack layout issue and potential fix
Hello! Over the last several months I've spent a lot of time investigating various LazyVStack issues. I’d like to share a one problem. Blank Screen + Cell trimming For reference, I've also submitted a Feedback Assistant report for this issue. Let's use the minimal example Copy the following code into a new project and run it: struct ScrollableLazyVStack: View { @StateObject private var viewModel: ScrollableLazyVStackViewModel init(count: Int) { self._viewModel = StateObject( wrappedValue: ScrollableLazyVStackViewModel(count: count) ) } var body: some View { ScrollView { LazyVStack(spacing: 8) { ForEach(viewModel.items, id: \.index) { CellView(by: $0) } } } .padding(.horizontal, 8) } } final class ScrollableLazyVStackViewModel: ObservableObject { @Published var items: [CellModel] = [] init(count: Int) { self.items = (0..<count).map { CellModel(index: $0) } } } private struct CellView: View { let item: CellModel init(by item: CellModel) { self.item = item } var body: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Image(systemName: item.icon) VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.body) Text(item.subtitle) .font(.caption) .foregroundColor(.secondary) if item.index < 100 { Text("Extra Text") .frame(height: 500) } } Spacer(minLength: .zero) } } .padding(16) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(.systemGray5)) ) } } struct CellModel: Hashable { private static let icons = [ "star.fill", "heart.fill", "bolt.fill", "flame.fill", "leaf.fill", "moon.fill", "cloud.fill", "paperplane.fill" ] private static let titles = [ "Random Item", "Sample Entry", "List Element", "Demo Cell", "Example Row", "Test Object" ] private static let subtitles = [ "Additional information", "Secondary description", "Some extra details", "Short explanation", "Supporting text" ] let index: Int let title: String let subtitle: String let icon: String init(index: Int) { self.index = index self.title = "\(index). \(Self.titles.randomElement()!)" self.subtitle = Self.subtitles.randomElement()! self.icon = Self.icons.randomElement()! } } Steps to Reproduce Fast-scroll to the bottom of the list using the scroll indicator. Then fast-scroll back to the top by dragging and holding the scroll indicator. Actual Results Blank Screen During fast scrolling, the visible content may temporarily disappear, resulting in a white screen. In most cases, the content eventually reappears. Truncated Cells After scrolling back toward the top, some of the topmost cells may become partially truncated. Possible Cause Blank Screen I do not want to rely on or investigate SwiftUI's non-public implementation details, but I would like to explain what I believe is happening internally. I'd like to assume what appears to be the underlying algorithm. When fast scrolling is active, the following sequence seems to occur: Step 1 SwiftUICore::resolveIndexAndPosition calculates an anchorIndex. For example: anchorIndex ≈ (scrollOffset / contentSize) * totalRows Step 2 SwiftUICore::resolveIndexAndPosition calculates an anchorPosition. For example: anchorPosition ≈ anchorIndex * (estimatedCellSize + spacing) Step 3 SwiftUI searches for the first visible cell and updates the position of the last laid-out cell (y_max_last_cell). Step 4 (the most important) The layout's correctness validation: Conceptually: d1 = abs(y_last_max - cachedSizeThatFits) d2 = 0.1 * min(y_last_max, cachedSizeThatFits) If: d1 < d2 -> the current CA::Transaction is committed. Otherwise: cachedSizeThatFits = sizeThatFits(...) and the process restarts from Step 1. If logging is enabled via: com.apple.SwiftUI.LazyStackLogging the behavior can be observed in the attached screenshot. Why this may fail The algorithm appears to assume that the estimated cell size does not change dramatically while fast scrolling. However, if the beginning of the list contains very tall cells and the remainder contains much smaller cells, the estimate may become significantly inaccurate. In that situation, comparing cachedSizeThatFits against the position of the last visible cell does not seem sufficient to guarantee a stable result, which may explain the temporary blank screen. Cell Truncation While scrolling upward, the following state can occur (see attached screenshot): SwiftUICore::resolveIndexAndPosition produces: anchorIndex = 2 anchorPosition = -562 The content is translated by 486.47, which corresponds approximately to: estimatedCellSize * 2 As a result, the effective anchor position becomes: -76.32 and the first 0...1 cells are truncated. Possible Solution Blank screen's problem My assumption is that the current layout validation criteria may not be sufficient in all cases. An alternative approach could be to always fill the visible region sequentially: top → bottom while scrolling downward bottom → top while scrolling upward With such a strategy, the layout result could potentially be committed immediately once the visible area is fully covered by realized cells. Cell's Truncation For the truncation issue, it seems that the current translation-based approach may be the source of the problem. One possible alternative would be to use a contentInset-based adjustment combined with clipping against the first visible cell's minY. In that model, negative inset values would not necessarily be problematic, and the visible content could remain correctly aligned. What do you think about this?
Topic: UI Frameworks SubTopic: SwiftUI
0
0
395
1d
Unexpected behavior in the interaction between LazyVStack and GeometryReader
Hello! I'd like to share a problem and its potential solution. Steps to reproduce: The issue can be reproduced with the following minimal example: struct TestConditionalScrollView: View { var body: some View { ConditionalScrollView { LazyVStack(spacing: 16) { Text("Text 1") .frame(height: 20) Text("Text 2") .frame(height: 30) Text("Text 3") .frame(height: 40) Text("Text 4") .frame(height: 500) Text("Text 5") .frame(height: 400) } .padding() } } } struct ConditionalScrollView<Content: View>: View { let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } @State private var contentHeight: CGFloat = 0 var body: some View { GeometryReader { geo in _ = print("Height: \(contentHeight)") return Group { if contentHeight > geo.size.height { ScrollView { measuredContent } } else { measuredContent } } } } private var measuredContent: some View { content .background( GeometryReader { geo in Color.clear .preference( key: ContentHeightKey.self, value: geo.size.height ) } ) .onPreferenceChange(ContentHeightKey.self) { contentHeight = $0 } } } struct ContentHeightKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } If we hit the breakpoint on the following line: _ = print("Height: (contentHeight)") the output looks like this: Problems observed Different values are reported, and it is unclear where those values originate from. The selected execution branch appears to change multiple times during the layout process. Possible reason As Rens Breur mentioned in the WWDC26 session "Dive into lazy stacks and scrolling with SwiftUI", LazyVStack relies on estimated layout information during certain phases of the layout process. I do not want to rely on or investigate SwiftUI's non-public implementation details, but I would like to explain what I believe is happening internally. To do that, let me show how the value reaches the GeometryReader closure: Step 1 In AttributeGraph, the GeometryReader<...> node and the LazyVStack node appear to be connected as shown below: Step 2 When the LazyVStack node is updated, the layout process appears to follow roughly this logic: `SwiftUICore 'SwiftUI.ForEachState.forEachItem:` n = number of cells to evaluate (initially n == 2) For first n cells: SwiftUICore`SwiftUI.ViewLayoutEngine.sizeThatFits(...) SwiftUI.EstimationCache.add(...) The total size of the lazy stack is then estimated: SwiftUI.LazyStack<...>.sizeThatFits(...): averageCellInfo = EstimationCache.average averageCellInfo.height = (firstCellHeight + secondCellHeight) / 2 totalHeight = firstCellHeight + secondCellHeight + averageCellInfo.height * remainingCells For the sample project, this produces an estimated height of 189. This value then appears to be cached inside a LazyLayoutComputer node. Step 3 When GeometryReader is updated and its closure executes, geo.size.height appears to be resolved from the cached value stored by LazyLayoutComputer. As a result, the reported height is: 189 + 32 (padding) = 221 Step 4 My assumption is that LazyVStack subsequently validates the estimated layout against the actual layout results. It seems to compare: The maximum Y position of the last list's cell The cached sizeThatFits value If those values differ sufficiently, the transaction is not committed and another layout pass is triggered. During a later pass, the real sizes become available and GeometryReader eventually reports the final correct value. If this interpretation is correct, the behavior shown in the logs would be expected: followed later by: Possible solution I could not find a public SwiftUI API that provides an accurate content size during the initial layout pass. I tried the following options: LazyVStack + GeometryReader LazyVStack + ViewThatFits LazyVStack + .scrollBounceBehavior(...) At the same time, SwiftUI itself appears to have information about layout validity. For example, the layout logs contain entries such as: placed(...) -> ... invalid: true This suggests that SwiftUI can determine when an estimated layout result is no longer valid and requires additional layout passes. If SwiftUI knows that the current layout is invalid, is there a way to access this information from within a GeometryReader closure or by some other means? Otherwise, clients may perform layout calculations based on invalid geometry, which can result in a broken dependent layout. Have a good day!
Replies
0
Boosts
0
Views
13
Activity
5h
LazyVStack layout issue and potential fix
Hello! Over the last several months I've spent a lot of time investigating various LazyVStack issues. I’d like to share a one problem. Blank Screen + Cell trimming For reference, I've also submitted a Feedback Assistant report for this issue. Let's use the minimal example Copy the following code into a new project and run it: struct ScrollableLazyVStack: View { @StateObject private var viewModel: ScrollableLazyVStackViewModel init(count: Int) { self._viewModel = StateObject( wrappedValue: ScrollableLazyVStackViewModel(count: count) ) } var body: some View { ScrollView { LazyVStack(spacing: 8) { ForEach(viewModel.items, id: \.index) { CellView(by: $0) } } } .padding(.horizontal, 8) } } final class ScrollableLazyVStackViewModel: ObservableObject { @Published var items: [CellModel] = [] init(count: Int) { self.items = (0..<count).map { CellModel(index: $0) } } } private struct CellView: View { let item: CellModel init(by item: CellModel) { self.item = item } var body: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Image(systemName: item.icon) VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.body) Text(item.subtitle) .font(.caption) .foregroundColor(.secondary) if item.index < 100 { Text("Extra Text") .frame(height: 500) } } Spacer(minLength: .zero) } } .padding(16) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(.systemGray5)) ) } } struct CellModel: Hashable { private static let icons = [ "star.fill", "heart.fill", "bolt.fill", "flame.fill", "leaf.fill", "moon.fill", "cloud.fill", "paperplane.fill" ] private static let titles = [ "Random Item", "Sample Entry", "List Element", "Demo Cell", "Example Row", "Test Object" ] private static let subtitles = [ "Additional information", "Secondary description", "Some extra details", "Short explanation", "Supporting text" ] let index: Int let title: String let subtitle: String let icon: String init(index: Int) { self.index = index self.title = "\(index). \(Self.titles.randomElement()!)" self.subtitle = Self.subtitles.randomElement()! self.icon = Self.icons.randomElement()! } } Steps to Reproduce Fast-scroll to the bottom of the list using the scroll indicator. Then fast-scroll back to the top by dragging and holding the scroll indicator. Actual Results Blank Screen During fast scrolling, the visible content may temporarily disappear, resulting in a white screen. In most cases, the content eventually reappears. Truncated Cells After scrolling back toward the top, some of the topmost cells may become partially truncated. Possible Cause Blank Screen I do not want to rely on or investigate SwiftUI's non-public implementation details, but I would like to explain what I believe is happening internally. I'd like to assume what appears to be the underlying algorithm. When fast scrolling is active, the following sequence seems to occur: Step 1 SwiftUICore::resolveIndexAndPosition calculates an anchorIndex. For example: anchorIndex ≈ (scrollOffset / contentSize) * totalRows Step 2 SwiftUICore::resolveIndexAndPosition calculates an anchorPosition. For example: anchorPosition ≈ anchorIndex * (estimatedCellSize + spacing) Step 3 SwiftUI searches for the first visible cell and updates the position of the last laid-out cell (y_max_last_cell). Step 4 (the most important) The layout's correctness validation: Conceptually: d1 = abs(y_last_max - cachedSizeThatFits) d2 = 0.1 * min(y_last_max, cachedSizeThatFits) If: d1 < d2 -> the current CA::Transaction is committed. Otherwise: cachedSizeThatFits = sizeThatFits(...) and the process restarts from Step 1. If logging is enabled via: com.apple.SwiftUI.LazyStackLogging the behavior can be observed in the attached screenshot. Why this may fail The algorithm appears to assume that the estimated cell size does not change dramatically while fast scrolling. However, if the beginning of the list contains very tall cells and the remainder contains much smaller cells, the estimate may become significantly inaccurate. In that situation, comparing cachedSizeThatFits against the position of the last visible cell does not seem sufficient to guarantee a stable result, which may explain the temporary blank screen. Cell Truncation While scrolling upward, the following state can occur (see attached screenshot): SwiftUICore::resolveIndexAndPosition produces: anchorIndex = 2 anchorPosition = -562 The content is translated by 486.47, which corresponds approximately to: estimatedCellSize * 2 As a result, the effective anchor position becomes: -76.32 and the first 0...1 cells are truncated. Possible Solution Blank screen's problem My assumption is that the current layout validation criteria may not be sufficient in all cases. An alternative approach could be to always fill the visible region sequentially: top → bottom while scrolling downward bottom → top while scrolling upward With such a strategy, the layout result could potentially be committed immediately once the visible area is fully covered by realized cells. Cell's Truncation For the truncation issue, it seems that the current translation-based approach may be the source of the problem. One possible alternative would be to use a contentInset-based adjustment combined with clipping against the first visible cell's minY. In that model, negative inset values would not necessarily be problematic, and the visible content could remain correctly aligned. What do you think about this?
Topic: UI Frameworks SubTopic: SwiftUI
Replies
0
Boosts
0
Views
395
Activity
1d