From e3b726ec9aefa5babccce040f5c988167942e515 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 23 Oct 2026 22:48:32 +0300 Subject: [PATCH] refactor(scroll-view): read the offset from onScrollGeometryChange `ScrollViewWithOnScrollChanged` measured its offset with a zero-size `LocationReader` placed as the first child of the content, inside a per-instance named coordinate space. `onScrollGeometryChange` is available at the iOS 18 / macOS 15 floor and reports the offset itself. - y is the same as before at rest, while scrolled and when pulled past the top, under safe-area insets, `safeAreaPadding`, `ignoresSafeArea`, a collapsing large title and a refresh in progress. - x now is the real offset; it used to be the probe's position, the centre of the content. Right-to-left layouts are handled. - With `contentMargins`, y reads 0 at rest: the origin is measured inside every content inset. - A zero-size first child stays, so content keeps the stack spacing hosts lay out against. - The last value is sent again when the content reappears, as the probe's `onAppear` did. `LocationReader` and `readingLocation` were internal and had no other caller, so they go too. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../Components/Geometry/LocationReader.swift | 112 ------------------ .../ScrollViewWithOnScrollChanged.swift | 82 +++++++++++-- .../ScrollContentOriginTests.swift | 86 ++++++++++++++ docs/README.md | 4 +- docs/utilities.md | 35 +++--- 6 files changed, 176 insertions(+), 145 deletions(-) delete mode 100644 Sources/DashUIKit/Components/Geometry/LocationReader.swift create mode 100644 Tests/DashUIKitTests/ScrollContentOriginTests.swift diff --git a/README.md b/README.md index 11b6f30..583e0db 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ a component already exists before building your own. | `XmarkIcon` | Code-drawn close (✕) icon | [Feedback](docs/feedback.md) | | `CheckmarkIcon` | Code-drawn selection (✓) mark | [Feedback](docs/feedback.md) | | `ChevronIcon` | Code-drawn chevron, in any of the four directions | [Feedback](docs/feedback.md) | -| Geometry helpers | `readingFrame`, `readingLocation`, `ScrollViewWithOnScrollChanged`, `scaleToFitWidth` | [Utilities](docs/utilities.md) | +| Geometry helpers | `readingFrame`, `ScrollViewWithOnScrollChanged`, `scaleToFitWidth` | [Utilities](docs/utilities.md) | | Foundation | `Color.dash.*`, `Font.dash.*`, `.dashFont`, `DashIconSource` | [Foundation](docs/foundation.md) | ## Project layout diff --git a/Sources/DashUIKit/Components/Geometry/LocationReader.swift b/Sources/DashUIKit/Components/Geometry/LocationReader.swift deleted file mode 100644 index f46a374..0000000 --- a/Sources/DashUIKit/Components/Geometry/LocationReader.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// Created by Roman Chornyi -// Copyright © 2026 Dash Core Group. All rights reserved. -// -// Licensed under the MIT License (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://opensource.org/licenses/MIT -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -// MARK: - LocationReader - -/// Publishes the center point of whatever view it backs, resolved in `coordinateSpace`. -/// -/// The point-sized sibling of ``FrameReader``: it collapses to a zero-sized probe and only -/// emits the midpoint, which is all most scroll/position effects need. Reach for it through -/// ``SwiftUI/View/readingLocation(coordinateSpace:onChange:)``. -struct LocationReader: View { - - private let coordinateSpace: CoordinateSpace - private let report: (CGPoint) -> Void - - init(coordinateSpace: CoordinateSpace, onChange: @escaping (CGPoint) -> Void) { - self.coordinateSpace = coordinateSpace - self.report = onChange - } - - var body: some View { - GeometryReader { proxy in - // Pinned to a 0×0 box, so the frame's midpoint collapses onto the probe's origin. - let center = proxy.frame(in: coordinateSpace).center - - Color.clear - .onAppear { report(center) } - .onChange(of: center) { _, newCenter in report(newCenter) } - } - .frame(width: 0, height: 0, alignment: .center) - } -} - -// MARK: - View sugar - -extension View { - - /// Reports this view's center point — in `coordinateSpace` — on appear and on every change. - /// Installed as a zero-sized, transparent background so it never affects layout. - func readingLocation( - coordinateSpace: CoordinateSpace = .global, - onChange: @escaping (_ location: CGPoint) -> Void - ) -> some View { - background(LocationReader(coordinateSpace: coordinateSpace, onChange: onChange)) - } -} - -// MARK: - Helpers - -private extension CGRect { - /// Geometric center of the rect. - var center: CGPoint { CGPoint(x: midX, y: midY) } -} - -// MARK: - Preview - -#if DEBUG - -struct LocationReader_Previews: PreviewProvider { - - private struct Demo: View { - @State private var headerCenterY: CGFloat = 0 - - var body: some View { - ScrollView(.vertical) { - VStack(spacing: 16) { - Text("Header") - .frame(maxWidth: .infinity) - .frame(height: 160) - .background(Color.green) - .cornerRadius(10) - .readingLocation(coordinateSpace: .named("demo")) { point in - headerCenterY = point.y - } - - ForEach(0..<24) { index in - Color.green.opacity(0.4) - .frame(maxWidth: .infinity) - .frame(height: 160) - .cornerRadius(10) - .overlay(Text("\(index)")) - } - } - .padding() - } - .coordinateSpace(name: "demo") - .overlay(alignment: .top) { Text(String(format: "header midY: %.1f", headerCenterY)).padding() } - } - } - - static var previews: some View { - Demo() - } -} - -#endif diff --git a/Sources/DashUIKit/Components/Geometry/ScrollViewWithOnScrollChanged.swift b/Sources/DashUIKit/Components/Geometry/ScrollViewWithOnScrollChanged.swift index ec4042f..a0a84d5 100644 --- a/Sources/DashUIKit/Components/Geometry/ScrollViewWithOnScrollChanged.swift +++ b/Sources/DashUIKit/Components/Geometry/ScrollViewWithOnScrollChanged.swift @@ -19,13 +19,13 @@ import SwiftUI // MARK: - ScrollViewWithOnScrollChanged -/// A drop-in `ScrollView` that reports its content offset as the user scrolls — useful for -/// collapsing headers, scroll-driven shadows, parallax, etc. It predates the library's iOS 18 -/// floor; new code can use the system `onScrollGeometryChange` instead. +/// A drop-in `ScrollView` that reports where its content's origin sits as the user scrolls — +/// useful for collapsing headers, scroll-driven shadows, parallax, etc. /// -/// The trick: a zero-sized ``LocationReader`` rides at the very top of the content inside a -/// private named coordinate space. As the content slides, the probe's position within that -/// space is exactly the scroll offset, which is forwarded through `onScrollChanged`. +/// The reported point is the content origin relative to the visible start edges, inside every +/// content inset (safe area, `safeAreaPadding`, `contentMargins`): `.zero` at rest, negative once +/// the content has scrolled towards its end, and positive while it is pulled past the start. +/// It is read from `onScrollGeometryChange`, and sent again when the content reappears. public struct ScrollViewWithOnScrollChanged: View { private let axes: Axis.Set @@ -33,8 +33,10 @@ public struct ScrollViewWithOnScrollChanged: View { private let content: Content private let report: (CGPoint) -> Void - /// A per-instance coordinate-space name, so stacked scroll views never read each other's probe. - @State private var spaceName = UUID().uuidString + @Environment(\.layoutDirection) private var layoutDirection + /// Remembers the last origin so it can be sent again when the content + /// reappears. A reference, so scroll frames do not write view state. + @State private var replay = Replay() public init( _ axes: Axis.Set = .vertical, @@ -50,18 +52,43 @@ public struct ScrollViewWithOnScrollChanged: View { public var body: some View { ScrollView(axes, showsIndicators: showsIndicators) { - offsetProbe + // Keeps the content one default stack spacing below the top, where + // hosts lay out against it. Deleting it moves their content up by + // that spacing. + Color.clear.frame(width: 0, height: 0) content } - .coordinateSpace(name: spaceName) + .onScrollGeometryChange(for: CGPoint.self) { geometry in + scrollContentOrigin(of: geometry, layoutDirection: layoutDirection) + } action: { _, origin in + replay.last = origin + report(origin) + } + .onAppear { + // The first appearance is covered by the geometry change itself. + if replay.hasAppeared, let last = replay.last { report(last) } + replay.hasAppeared = true + } } - /// Top-of-content marker whose location within `spaceName` mirrors the scroll offset. - private var offsetProbe: some View { - LocationReader(coordinateSpace: .named(spaceName), onChange: report) + private final class Replay { + var last: CGPoint? + var hasAppeared = false } } +/// Where the content's origin sits relative to the visible start edges, inside the insets. +/// +/// Right-to-left layouts differ in how `ScrollGeometry` reports the horizontal axis: at rest +/// `contentOffset.x` equals the trailing inset, the leading inset does not move it, and the +/// offset grows as the content scrolls towards its end. +func scrollContentOrigin(of geometry: ScrollGeometry, layoutDirection: LayoutDirection) -> CGPoint { + let x = layoutDirection == .rightToLeft + ? -(geometry.contentOffset.x - geometry.contentInsets.trailing) + : -(geometry.contentOffset.x + geometry.contentInsets.leading) + return CGPoint(x: x, y: -(geometry.contentOffset.y + geometry.contentInsets.top)) +} + // MARK: - Preview #if DEBUG @@ -90,8 +117,37 @@ struct ScrollViewWithOnScrollChanged_Previews: PreviewProvider { } } + private struct HorizontalDemo: View { + @State private var offsetX: CGFloat = 0 + + var body: some View { + ScrollViewWithOnScrollChanged(.horizontal) { + HStack(spacing: 16) { + ForEach(0..<24) { index in + Color.blue.opacity(0.4) + .frame(width: 120, height: 120) + .overlay { Text("Col \(index)") } + } + } + .padding() + } onScrollChanged: { origin in + offsetX = origin.x + } + .overlay(alignment: .bottom) { Text(String(format: "offset.x: %.1f", offsetX)).padding() } + } + } + static var previews: some View { Demo() + .previewDisplayName("Vertical") + Demo() + .contentMargins(.top, 40) + .previewDisplayName("Vertical, top margin") + HorizontalDemo() + .previewDisplayName("Horizontal") + HorizontalDemo() + .environment(\.layoutDirection, .rightToLeft) + .previewDisplayName("Horizontal, right-to-left") } } diff --git a/Tests/DashUIKitTests/ScrollContentOriginTests.swift b/Tests/DashUIKitTests/ScrollContentOriginTests.swift new file mode 100644 index 0000000..0d31aa3 --- /dev/null +++ b/Tests/DashUIKitTests/ScrollContentOriginTests.swift @@ -0,0 +1,86 @@ +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import XCTest +@testable import DashUIKit + +/// Geometry values below are what `onScrollGeometryChange` delivered on an iOS simulator for +/// the named situations; the expected origin is `.zero` at rest, negative towards the content +/// end and positive past the start. +final class ScrollContentOriginTests: XCTestCase { + + private func geometry(offset: CGPoint, insets: EdgeInsets = EdgeInsets()) -> ScrollGeometry { + ScrollGeometry( + contentOffset: offset, + contentSize: CGSize(width: 2400, height: 2400), + contentInsets: insets, + containerSize: CGSize(width: 360, height: 600) + ) + } + + private func origin(_ offset: CGPoint, _ insets: EdgeInsets = EdgeInsets(), _ direction: LayoutDirection = .leftToRight) -> CGPoint { + scrollContentOrigin(of: geometry(offset: offset, insets: insets), layoutDirection: direction) + } + + // MARK: - Vertical + + func testTopInsetReadsZeroAtRest() { + XCTAssertEqual(origin(CGPoint(x: 0, y: -112), EdgeInsets(top: 112, leading: 0, bottom: 0, trailing: 0)).y, 0) + } + + func testScrollingDownIsNegativeAndPullingPastTheTopIsPositive() { + let insets = EdgeInsets(top: 112, leading: 0, bottom: 0, trailing: 0) + XCTAssertEqual(origin(CGPoint(x: 0, y: 8), insets).y, -120) + XCTAssertEqual(origin(CGPoint(x: 0, y: -152), insets).y, 40) + } + + // MARK: - Horizontal, left to right + + func testLeadingInsetReadsZeroAtRest() { + XCTAssertEqual(origin(CGPoint(x: -30, y: 0), EdgeInsets(top: 0, leading: 30, bottom: 0, trailing: 0)).x, 0) + } + + func testTrailingInsetDoesNotMoveTheRestPosition() { + let insets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 40) + XCTAssertEqual(origin(.zero, insets).x, 0) + XCTAssertEqual(origin(CGPoint(x: 40, y: 0), insets).x, -40) + XCTAssertEqual(origin(CGPoint(x: -30, y: 0), insets).x, 30) + } + + // MARK: - Horizontal, right to left + + func testRightToLeftStartInsetReadsZeroAtRest() { + let insets = EdgeInsets(top: 0, leading: 40, bottom: 0, trailing: 0) + XCTAssertEqual(origin(.zero, insets, .rightToLeft).x, 0) + XCTAssertEqual(origin(CGPoint(x: 40, y: 0), insets, .rightToLeft).x, -40) + XCTAssertEqual(origin(CGPoint(x: -30, y: 0), insets, .rightToLeft).x, 30) + } + + func testRightToLeftEndInsetIsSubtractedFromTheOffset() { + let insets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 30) + XCTAssertEqual(origin(CGPoint(x: 30, y: 0), insets, .rightToLeft).x, 0) + XCTAssertEqual(origin(CGPoint(x: 70, y: 0), insets, .rightToLeft).x, -40) + XCTAssertEqual(origin(CGPoint(x: -90, y: 0), insets, .rightToLeft).x, 120) + } + + func testRightToLeftMarginsOnBothSides() { + let insets = EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20) + XCTAssertEqual(origin(CGPoint(x: 20, y: 0), insets, .rightToLeft).x, 0) + XCTAssertEqual(origin(CGPoint(x: 60, y: 0), insets, .rightToLeft).x, -40) + XCTAssertEqual(origin(CGPoint(x: -10, y: 0), insets, .rightToLeft).x, 30) + } +} diff --git a/docs/README.md b/docs/README.md index 626a7e1..4f6d0ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,7 @@ and callbacks; they render and report intent. They require `import DashUIKit` an - **[Feedback](feedback.md)** — `Toast`, `SystemMessageView`, `LoadingIllustration` / `LoadingSpinner`, `SuccessIllustration`, `ErrorIllustration`, `XmarkIcon`, `CheckmarkIcon`, `ChevronIcon`, `InfoRoundIcon`. -- **[Utilities](utilities.md)** — geometry readers (`readingFrame`, `readingLocation`), +- **[Utilities](utilities.md)** — geometry reader (`readingFrame`), `ScrollViewWithOnScrollChanged`, `scaleToFitWidth`. ## Alphabetical index @@ -64,7 +64,7 @@ and callbacks; they render and report intent. They require `import DashUIKit` an | `NavigationBarCircleMenu` | [Navigation & containers](navigation-and-containers.md#custom-circle-actions) | | `NumericKeyboardView` | [Buttons & inputs](buttons-and-inputs.md#numerickeyboardview) | | `RadioButtonRow` | [Lists & rows](lists-and-rows.md#radiobuttonrow) | -| `readingFrame` / `readingLocation` | [Utilities](utilities.md#frame--location-readers) | +| `readingFrame` | [Utilities](utilities.md#frame-reader) | | `ReceiveEstimateView` | [Amount & currency](amount-and-currency.md#receiveestimateview) | | `ScrollViewWithOnScrollChanged` | [Utilities](utilities.md#scrollviewwithonscrollchanged) | | `scaleToFitWidth` | [Utilities](utilities.md#scaletofitwidth) | diff --git a/docs/utilities.md b/docs/utilities.md index f4ae859..bee2f75 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -5,29 +5,20 @@ mechanics for measuring and fitting views. --- -## Frame & location readers +## Frame reader -Files `Components/Geometry/FrameReader.swift`, `LocationReader.swift` +File `Components/Geometry/FrameReader.swift` -Read a view's frame or center point in a coordinate space via a background `GeometryReader`, -without disturbing layout. Callbacks fire on appear and on change. +Reads a view's frame in a coordinate space via a background `GeometryReader`, without +disturbing layout. The callback fires on appear and on change. ```swift -// Public: read the full frame. MyView() .readingFrame(coordinateSpace: .global) { frame in // frame: CGRect } - -// Read just the center point (internal helper used by ScrollViewWithOnScrollChanged). -MyView() - .readingLocation { center in - // center: CGPoint - } ``` -`readingFrame` is public; `LocationReader` / `readingLocation` are internal building blocks. - --- ## ScrollViewWithOnScrollChanged @@ -35,18 +26,28 @@ MyView() File `Components/Geometry/ScrollViewWithOnScrollChanged.swift` A `ScrollView` that reports its content's scroll origin as it moves — handy for -scroll-driven effects (collapsing headers, shadows, parallax). It works by placing a -`LocationReader` at the top of the content in a named coordinate space, which predates -`onScrollGeometryChange`; new code can use the system API directly. +scroll-driven effects (collapsing headers, shadows, parallax). Built on +`onScrollGeometryChange`. ```swift ScrollViewWithOnScrollChanged(.vertical, showsIndicators: false) { VStack { /* content */ } } onScrollChanged: { origin in - // origin: CGPoint — content offset within the scroll view + // origin: CGPoint — .zero at rest, negative as the content scrolls towards its end, + // positive while it is pulled past the start (rubber-banding) } ``` +- The origin is measured inside every content inset — safe area, `safeAreaPadding` and + `contentMargins` alike — so it reads `.zero` at rest whatever insets the host adds. +- `x` follows the layout direction: in right-to-left layouts, scrolling towards the end is + negative too. +- Only this scroll view's own offset is reported; a scroll view nested in the content does not + feed it. +- The last value is sent again when the content appears again with its state kept (switching + back to its tab, for example). +- The content starts one default stack spacing below the top. + --- ## scaleToFitWidth