diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index 96e3d603..ff392628 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -2,13 +2,6 @@ import ArtifactoryKit import GraphcodeKit import SwiftUI -/// The measured height of the board's posts, reported up so the scroll box can size -/// itself to them instead of to whatever space the rail happens to have spare. -private struct ArtifactoryContentHeight: PreferenceKey { - static let defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } -} - /// What the rail needs to know about the board without building a view to find out. enum ArtifactoryPresentation { /// Whether this graph's board has anything to show. Absent while empty, for the @@ -61,8 +54,6 @@ struct ArtifactorySection: View { /// Whether the mirrored records are unfolded. Local and unpersisted, unlike the /// section's own fold: opening the receipts is a thing you do once to answer a /// question, not a way you prefer to read the board. - /// How tall the posts actually are, so the scroll box can ask for exactly that. - @State private var contentHeight: CGFloat = 0 /// Past this the board scrolls rather than pushing the sections above it off the rail. /// Every note is on the board and reachable — this is only how much of it is on screen /// before the wheel takes over. About ten posts at the rail's default width, which is @@ -104,30 +95,21 @@ struct ArtifactorySection: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .background( - GeometryReader { proxy in - Color.clear.preference(key: ArtifactoryContentHeight.self, value: proxy.size.height) - } - ) } - .onPreferenceChange(ArtifactoryContentHeight.self) { contentHeight = $0 } .scrollBounceBehavior(.basedOnSize) .defaultScrollAnchor(.bottom) - // Hug the posts, and only then scroll. A `ScrollView` is greedy: given - // `maxHeight: .infinity` it took the rail's whole slack, and `defaultScrollAnchor` - // pinned the posts to the bottom of that box while the header stayed at its top — - // so a board with two notes on it drew a header, a stretch of nothing, and then - // the notes. Measuring the content and asking for exactly that height (up to a - // cap) leaves no slack for the anchor to spread. - // - // `nil` until measured, never zero. This started as `min(contentHeight, cap)` - // with `contentHeight` at 0, and a zero-height scroll view never lays out its - // content — so the preference that would have grown it never fired, and the - // expanded section drew a header, the button, and nothing between. Unsized for - // the first pass, the content is laid out, the measurement lands, and the box - // snaps to it. The failure mode is now a brief greedy box, not an empty one. - .frame(height: contentHeight > 0 ? min(contentHeight, Self.maxScrollHeight) : nil) - .frame(maxHeight: contentHeight > 0 ? nil : Self.maxScrollHeight) + // Hug the posts, and only then scroll. A `ScrollView` is greedy — offered the + // rail's slack it takes it, and `defaultScrollAnchor(.bottom)` then pins the + // posts to the foot of that box with a gap between them and the header. Two + // attempts measured the content through a preference and sized the box to it; + // both mis-sized (a zero start that never laid out, then a stale reading that + // left the gap). This is the idiom that needs no measuring: `fixedSize` + // (vertical) asks the scroll view for its *ideal* height, which is its + // content's, and `frame(maxHeight:)` under it clamps that. The box is exactly + // as tall as the posts until the cap, and scrolls after — no state, nothing to + // go stale, nothing to fire late. + .frame(maxHeight: Self.maxScrollHeight) + .fixedSize(horizontal: false, vertical: true) // Outside the scroll view on purpose. Inside it the composer was one more row // in a list that can be taller than the rail — it could open scrolled out of // sight, and it moved under the pointer as posts arrived. Pinned here it is