Skip to content

Commit 93c96b0

Browse files
authored
Add ScrollManager for programmatic control of ScrollViewWithStickyHeader
Merge pull request #27 from gabrielribeiro/feature/scroll-manager-solved-conflicts
2 parents 77d9ef7 + 805e33f commit 93c96b0

File tree

3 files changed

+115
-12
lines changed

3 files changed

+115
-12
lines changed

Demo/Demo/DemoScreen.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ struct DemoScreen<HeaderView: View>: View {
2323

2424
@State
2525
private var scrollOffset: CGPoint = .zero
26+
27+
private let scrollManager = ScrollManager()
2628

2729
var body: some View {
2830
ScrollViewWithStickyHeader(
2931
header: header,
3032
headerHeight: headerHeight,
3133
headerMinHeight: 75,
34+
scrollManager: scrollManager,
3235
onScroll: handleScrollOffset
3336
) {
3437
LazyVStack(spacing: 0) {
@@ -49,6 +52,24 @@ struct DemoScreen<HeaderView: View>: View {
4952
.previewHeaderContent()
5053
.opacity(1 - visibleHeaderRatio)
5154
}
55+
ToolbarItem(placement: .topBarTrailing) {
56+
Button {
57+
scrollManager.scrollToContent()
58+
} label: {
59+
Label("Scroll to content", systemImage: "hand.point.down")
60+
.labelStyle(.iconOnly)
61+
}
62+
.buttonStyle(.plain)
63+
}
64+
ToolbarItem(placement: .topBarTrailing) {
65+
Button {
66+
scrollManager.scrollToHeader()
67+
} label: {
68+
Label("Scroll to header", systemImage: "hand.point.up")
69+
.labelStyle(.iconOnly)
70+
}
71+
.buttonStyle(.plain)
72+
}
5273
}
5374
.toolbarBackground(.hidden)
5475
.statusBarHidden(scrollOffset.y > -3)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// ScrollManager.swift
3+
// ScrollKit
4+
//
5+
// Created by Gabriel Ribeiro on 2025-04-06.
6+
// Copyright © 2023-2024 Daniel Saidi. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
11+
/// A class that manages programmatic scrolling within a
12+
/// scroll view that uses sticky headers.
13+
///
14+
/// `ScrollManager` can be used to scroll to specific
15+
/// parts of a scroll view (e.g. the sticky header or
16+
/// the main content) using a `ScrollViewProxy`.
17+
///
18+
/// To use it, inject an instance into a compatible scroll
19+
/// view like `ScrollViewWithStickyHeader`, which will
20+
/// register its internal proxy with the manager on appear.
21+
///
22+
/// You can then call `scrollToHeader()` or
23+
/// `scrollToContent()` from your view model or UI logic
24+
/// to trigger animated scrolling actions.
25+
///
26+
/// - Important: `ScrollManager` uses `ScrollViewReader`
27+
/// under the hood, so the scrollable views must have
28+
/// valid `.id(...)` values matching the internal targets.
29+
public class ScrollManager {
30+
31+
/// Creates a new scroll manager instance.
32+
public init() { }
33+
34+
private var proxy: ScrollViewProxy?
35+
36+
/// Scroll to the sticky header in the scroll view.
37+
///
38+
/// - Parameter anchor: The anchor point to scroll to,
39+
/// defaulting to `.top`.
40+
public func scrollToHeader(anchor: UnitPoint = .top) {
41+
withAnimation {
42+
proxy?.scrollTo(ScrollTargets.header, anchor: anchor)
43+
}
44+
}
45+
46+
/// Scroll to the main content in the scroll view.
47+
///
48+
/// - Parameter anchor: The anchor point to scroll to,
49+
/// defaulting to `.top`.
50+
public func scrollToContent(anchor: UnitPoint = .top) {
51+
withAnimation {
52+
proxy?.scrollTo(ScrollTargets.content, anchor: anchor)
53+
}
54+
}
55+
56+
/// Set the internal scroll proxy.
57+
///
58+
/// This method is intended for internal use by views
59+
/// like `ScrollViewWithStickyHeader`.
60+
///
61+
/// - Parameter proxy: The `ScrollViewProxy` to store.
62+
internal func setProxy(_ proxy: ScrollViewProxy) {
63+
self.proxy = proxy
64+
}
65+
66+
/// Internal scroll target identifiers.
67+
enum ScrollTargets {
68+
static let header = "scrollkit-target-header"
69+
static let content = "scrollkit-target-content"
70+
}
71+
}

Sources/ScrollKit/ScrollViewWithStickyHeader.swift

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
5555
/// - headerStretch: Whether to stretch out the header when pulling down, by default `true`.
5656
/// - contentCornerRadius: The corner radius to apply to the scroll content.
5757
/// - showsIndicators: Whether or not to show scroll indicators, by default `true`.
58+
/// - scrollManager: A class that manages programmatic scrolling to header or content.
5859
/// - onScroll: An action that will be called whenever the scroll offset changes, by default `nil`.
5960
/// - content: The scroll view content builder.
6061
public init(
@@ -65,6 +66,7 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
6566
headerStretch: Bool = true,
6667
contentCornerRadius: CGFloat = 0,
6768
showsIndicators: Bool = true,
69+
scrollManager: ScrollManager? = nil,
6870
onScroll: ScrollAction? = nil,
6971
@ViewBuilder content: @escaping () -> Content
7072
) {
@@ -75,6 +77,7 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
7577
self.headerMinHeight = headerMinHeight
7678
self.headerStretch = headerStretch
7779
self.contentCornerRadius = contentCornerRadius
80+
self.scrollManager = scrollManager
7881
self.onScroll = onScroll
7982
self.content = content
8083
}
@@ -86,11 +89,12 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
8689
private let headerMinHeight: Double?
8790
private let headerStretch: Bool
8891
private let contentCornerRadius: CGFloat
92+
private let scrollManager: ScrollManager?
8993
private let onScroll: ScrollAction?
9094
private let content: () -> Content
91-
95+
9296
public typealias ScrollAction = (_ offset: CGPoint, _ visibleHeaderRatio: CGFloat) -> Void
93-
97+
9498
@State
9599
private var scrollOffset: CGPoint = .zero
96100

@@ -148,16 +152,23 @@ private extension ScrollViewWithStickyHeader {
148152
func scrollView(
149153
in geo: GeometryProxy
150154
) -> some View {
151-
ScrollViewWithOffsetTracking(
152-
axes,
153-
showsIndicators: showsIndicators,
154-
onScroll: handleScrollOffset
155-
) {
156-
VStack(spacing: 0) {
157-
scrollHeader
158-
.opacity(0)
159-
content()
160-
.frame(maxHeight: .infinity)
155+
ScrollViewReader { scrollProxy in
156+
ScrollViewWithOffsetTracking(
157+
axes,
158+
showsIndicators: showsIndicators,
159+
onScroll: handleScrollOffset
160+
) {
161+
VStack(spacing: 0) {
162+
scrollHeader
163+
.opacity(0)
164+
.id(ScrollManager.ScrollTargets.header)
165+
content()
166+
.frame(maxHeight: .infinity)
167+
.id(ScrollManager.ScrollTargets.content)
168+
}
169+
}
170+
.onAppear {
171+
scrollManager?.setProxy(scrollProxy)
161172
}
162173
}
163174
}

0 commit comments

Comments
 (0)