diff --git a/Demo/Demo/DemoScreen.swift b/Demo/Demo/DemoScreen.swift index 4dda8df..1624a78 100644 --- a/Demo/Demo/DemoScreen.swift +++ b/Demo/Demo/DemoScreen.swift @@ -23,12 +23,15 @@ struct DemoScreen: 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) { @@ -49,6 +52,24 @@ struct DemoScreen: 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) diff --git a/Sources/ScrollKit/Helpers/ScrollManager.swift b/Sources/ScrollKit/Helpers/ScrollManager.swift new file mode 100644 index 0000000..ce1dee8 --- /dev/null +++ b/Sources/ScrollKit/Helpers/ScrollManager.swift @@ -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" + } +} diff --git a/Sources/ScrollKit/ScrollViewWithStickyHeader.swift b/Sources/ScrollKit/ScrollViewWithStickyHeader.swift index 449aa98..a205574 100644 --- a/Sources/ScrollKit/ScrollViewWithStickyHeader.swift +++ b/Sources/ScrollKit/ScrollViewWithStickyHeader.swift @@ -55,6 +55,7 @@ public struct ScrollViewWithStickyHeader: 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( @@ -65,6 +66,7 @@ public struct ScrollViewWithStickyHeader: View { headerStretch: Bool = true, contentCornerRadius: CGFloat = 0, showsIndicators: Bool = true, + scrollManager: ScrollManager? = nil, onScroll: ScrollAction? = nil, @ViewBuilder content: @escaping () -> Content ) { @@ -75,6 +77,7 @@ public struct ScrollViewWithStickyHeader: View { self.headerMinHeight = headerMinHeight ?? headerHeight self.headerStretch = headerStretch self.contentCornerRadius = contentCornerRadius + self.scrollManager = scrollManager self.onScroll = onScroll self.content = content } @@ -86,11 +89,12 @@ public struct ScrollViewWithStickyHeader: 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 @@ -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) } } }