Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 0 additions & 112 deletions Sources/DashUIKit/Components/Geometry/LocationReader.swift

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,24 @@ 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<Content: View>: View {

private let axes: Axis.Set
private let showsIndicators: Bool
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,
Expand All @@ -50,18 +52,43 @@ public struct ScrollViewWithOnScrollChanged<Content: View>: 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
Expand Down Expand Up @@ -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")
}
}

Expand Down
86 changes: 86 additions & 0 deletions Tests/DashUIKitTests/ScrollContentOriginTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
Loading
Loading