Skip to content

Add ScrollManager for programmatic control of ScrollViewWithStickyHeader [solved conflict] #27

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
21 changes: 21 additions & 0 deletions Demo/Demo/DemoScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ struct DemoScreen<HeaderView: View>: View {

@State
private var scrollOffset: CGPoint = .zero

private let scrollManager = ScrollManager()

var body: some View {
ScrollViewWithStickyHeader(
header: header,
headerHeight: headerHeight,
headerMinHeight: 75,
scrollManager: scrollManager,
onScroll: handleScrollOffset
) {
LazyVStack(spacing: 0) {
Expand All @@ -49,6 +52,24 @@ struct DemoScreen<HeaderView: View>: View {
.previewHeaderContent()
.opacity(1 - visibleHeaderRatio)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
scrollManager.scrollToContent()
} label: {
Label("Scroll to content", systemImage: "hand.point.down")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
scrollManager.scrollToHeader()
} label: {
Label("Scroll to header", systemImage: "hand.point.up")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
}
}
.toolbarBackground(.hidden)
.statusBarHidden(scrollOffset.y > -3)
Expand Down
71 changes: 71 additions & 0 deletions Sources/ScrollKit/Helpers/ScrollManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// ScrollManager.swift
// ScrollKit
//
// Created by Gabriel Ribeiro on 2025-04-06.
// Copyright © 2023-2024 Daniel Saidi. All rights reserved.
//

import SwiftUI

/// A class that manages programmatic scrolling within a
/// scroll view that uses sticky headers.
///
/// `ScrollManager` can be used to scroll to specific
/// parts of a scroll view (e.g. the sticky header or
/// the main content) using a `ScrollViewProxy`.
///
/// To use it, inject an instance into a compatible scroll
/// view like `ScrollViewWithStickyHeader`, which will
/// register its internal proxy with the manager on appear.
///
/// You can then call `scrollToHeader()` or
/// `scrollToContent()` from your view model or UI logic
/// to trigger animated scrolling actions.
///
/// - Important: `ScrollManager` uses `ScrollViewReader`
/// under the hood, so the scrollable views must have
/// valid `.id(...)` values matching the internal targets.
public class ScrollManager {

/// Creates a new scroll manager instance.
public init() { }

private var proxy: ScrollViewProxy?

/// Scroll to the sticky header in the scroll view.
///
/// - Parameter anchor: The anchor point to scroll to,
/// defaulting to `.top`.
public func scrollToHeader(anchor: UnitPoint = .top) {
withAnimation {
proxy?.scrollTo(ScrollTargets.header, anchor: anchor)
}
}

/// Scroll to the main content in the scroll view.
///
/// - Parameter anchor: The anchor point to scroll to,
/// defaulting to `.top`.
public func scrollToContent(anchor: UnitPoint = .top) {
withAnimation {
proxy?.scrollTo(ScrollTargets.content, anchor: anchor)
}
}

/// Set the internal scroll proxy.
///
/// This method is intended for internal use by views
/// like `ScrollViewWithStickyHeader`.
///
/// - Parameter proxy: The `ScrollViewProxy` to store.
internal func setProxy(_ proxy: ScrollViewProxy) {
self.proxy = proxy
}

/// Internal scroll target identifiers.
enum ScrollTargets {
static let header = "scrollkit-target-header"
static let content = "scrollkit-target-content"
}
}
35 changes: 23 additions & 12 deletions Sources/ScrollKit/ScrollViewWithStickyHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
/// - headerStretch: Whether to stretch out the header when pulling down, by default `true`.
/// - contentCornerRadius: The corner radius to apply to the scroll content.
/// - showsIndicators: Whether or not to show scroll indicators, by default `true`.
/// - scrollManager: A class that manages programmatic scrolling to header or content.
/// - onScroll: An action that will be called whenever the scroll offset changes, by default `nil`.
/// - content: The scroll view content builder.
public init(
Expand All @@ -65,6 +66,7 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
headerStretch: Bool = true,
contentCornerRadius: CGFloat = 0,
showsIndicators: Bool = true,
scrollManager: ScrollManager? = nil,
onScroll: ScrollAction? = nil,
@ViewBuilder content: @escaping () -> Content
) {
Expand All @@ -75,6 +77,7 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
self.headerMinHeight = headerMinHeight ?? headerHeight
self.headerStretch = headerStretch
self.contentCornerRadius = contentCornerRadius
self.scrollManager = scrollManager
self.onScroll = onScroll
self.content = content
}
Expand All @@ -86,11 +89,12 @@ public struct ScrollViewWithStickyHeader<Header: View, Content: View>: View {
private let headerMinHeight: Double
private let headerStretch: Bool
private let contentCornerRadius: CGFloat
private let scrollManager: ScrollManager?
private let onScroll: ScrollAction?
private let content: () -> Content

public typealias ScrollAction = (_ offset: CGPoint, _ visibleHeaderRatio: CGFloat) -> Void

@State
private var scrollOffset: CGPoint = .zero

Expand Down Expand Up @@ -148,16 +152,23 @@ private extension ScrollViewWithStickyHeader {
func scrollView(
in geo: GeometryProxy
) -> some View {
ScrollViewWithOffsetTracking(
axes,
showsIndicators: showsIndicators,
onScroll: handleScrollOffset
) {
VStack(spacing: 0) {
scrollHeader
.opacity(0)
content()
.frame(maxHeight: .infinity)
ScrollViewReader { scrollProxy in
ScrollViewWithOffsetTracking(
axes,
showsIndicators: showsIndicators,
onScroll: handleScrollOffset
) {
VStack(spacing: 0) {
scrollHeader
.opacity(0)
.id(ScrollManager.ScrollTargets.header)
content()
.frame(maxHeight: .infinity)
.id(ScrollManager.ScrollTargets.content)
}
}
.onAppear {
scrollManager?.setProxy(scrollProxy)
}
}
}
Expand Down