├── .gitignore ├── LICENSE ├── Package.swift ├── README.md └── Sources └── PinnedScrollView ├── Backport.swift ├── CurrentValue.swift ├── Extension.swift ├── PinnedCoordinator.swift ├── PinnedModifier.swift └── PinnedScrollView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | .swiftpm 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lumi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PinnedScrollView", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | .library( 11 | name: "PinnedScrollView", 12 | targets: ["PinnedScrollView"] 13 | ), 14 | ], 15 | targets: [ 16 | .target(name: "PinnedScrollView") 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## PinnedScrollView 2 | 3 | PinnedScrollView is a tiny SwiftUI library that helps you pin any view in a scrollview like a header, without using `LazyVStack` and `Section`. 4 | 5 | 6 | 7 | ## Usage 8 | 9 | ```swift 10 | var body: some View { 11 | ScrollView { 12 | VStack(spacing: 0) { 13 | ForEach(0..<20) { _ in 14 | Text("Header") 15 | .pinned() // 1 16 | 17 | Text("Some content text") 18 | } 19 | } 20 | } 21 | .pinnedScrollView() // 2 22 | } 23 | ``` 24 | 25 | 1. Add `.pinned()` modifier to the view you want to pin within the scrollview 26 | 2. Add `.pinnedScrollView()` modifier to the corresponding scrollview 27 | 28 | That's it! 29 | 30 | #### Optional observation 31 | 32 | You can also provide a optional closure `onReachedTop: (Bool) -> ()` to the `pinned` modifier if you want to get a notification when the pinned state changed. 33 | 34 | ```swift 35 | @State private var isHeaderReachedTop = false 36 | 37 | // ... 38 | Text("Header" + (isHeaderReachedTop ? " Reached" : "")) 39 | .pinned { isReachedTop in 40 | isHeaderReachedTop = isReachedTop 41 | } 42 | ``` 43 | 44 | ## Limitation 45 | 46 | PinnedScrollView requires iOS/iPadOS 14.0. 47 | 48 | PinnedScrollView only supports vertical `ScrollView` and doesn't support horizontal `ScrollView` or the `List` view. 49 | 50 | ## Installation 51 | 52 | Use [Swift Package Manager](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app) to add PinnedScrollView to your project. 53 | 54 | `https://github.com/Lumisilk/PinnedScrollView.git` 55 | 56 | ## License 57 | 58 | This package is licensed under the MIT open-source license. 59 | 60 | ## Inspiration 61 | 62 | The implementation of PinnedScrollView was inspired by objc.io's course on [Sticky Headers for Scroll Views](https://talk.objc.io/episodes/S01E333-sticky-headers-for-scroll-views). 63 | Unlike the original implementation from objc.io, which uses `PreferenceKey` to coordinate the frames of headers and can lead to performance bottlenecks, PinnedScrollView use `onChange` to efficiently record the frames of headers. 64 | 65 | Additionally, PinnedScrollView use Combine's `debounce` and publisher to minimize the re-evaluation count of the headers' body, aiming to optimize performance. 66 | 67 | Special thanks to [@auramagi](https://github.com/auramagi) for the algorithm suggestions. 68 | 69 | -------------------------------------------------------------------------------- /Sources/PinnedScrollView/Backport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Backport.swift 3 | // 4 | // 5 | // Created by Lumisilk on 2023/11/14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Backport { 11 | let selfContent: Content 12 | 13 | @ViewBuilder 14 | func background(alignment: Alignment = .center, @ViewBuilder content: () -> some View) -> some View { 15 | if #available(iOS 15, *) { 16 | selfContent.background(alignment: alignment, content: content) 17 | } else { 18 | selfContent.background(content(), alignment: alignment) 19 | } 20 | } 21 | 22 | @ViewBuilder 23 | func overlay(alignment: Alignment = .center, @ViewBuilder content: () -> some View) -> some View { 24 | if #available(iOS 15, *) { 25 | selfContent.overlay(alignment: alignment, content: content) 26 | } else { 27 | selfContent.overlay(content(), alignment: alignment) 28 | } 29 | } 30 | } 31 | 32 | extension View { 33 | var backport: Backport { 34 | Backport(selfContent: self) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/PinnedScrollView/CurrentValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentValue.swift 3 | // 4 | // 5 | // Created by Lumisilk on 2023/11/08. 6 | // 7 | 8 | import Combine 9 | 10 | @propertyWrapper 11 | struct CurrentValue { 12 | 13 | let subject: CurrentValueSubject 14 | 15 | init(wrappedValue initialValue: Value) { 16 | subject = .init(initialValue) 17 | } 18 | 19 | var wrappedValue: Value { 20 | get { subject.value } 21 | set { subject.send(newValue) } 22 | } 23 | 24 | var projectedValue: some Publisher { 25 | subject 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/PinnedScrollView/Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension.swift 3 | // 4 | // 5 | // Created by Lumisilk on 2023/11/14. 6 | // 7 | 8 | import UIKit 9 | 10 | extension RandomAccessCollection { 11 | /// For array [1, 2, 3, 4], this method returns [(nil, 1), (1, 2), (2, 3), (3, 4)]. 12 | func adjacentPairsFromNil() -> some Sequence<(previous: Element?, current: Element)> { 13 | sequence(state: (previous: nil as Element?, iterator: makeIterator())) { state in 14 | guard let next = state.iterator.next() else { return nil } 15 | defer { state.previous = next } 16 | return (state.previous, next) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/PinnedScrollView/PinnedCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PinnedCoordinator.swift 3 | // 4 | // 5 | // Created by Lumisilk on 2023/11/14. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | final class PinnedCoordinator: ObservableObject { 12 | 13 | let scrollViewName = UUID() 14 | 15 | @CurrentValue var headerFrames: [Namespace.ID: CGRect] = [:] 16 | @CurrentValue var headerOffsets: [Namespace.ID: CGFloat] = [:] 17 | 18 | private var cancellable: AnyCancellable? 19 | 20 | init() { 21 | cancellable = $headerFrames 22 | .debounce(for: DispatchQueue.main.minimumTolerance, scheduler: DispatchQueue.main) 23 | .sink { [unowned self] newFrames in 24 | self.update(headerFrames: newFrames) 25 | } 26 | } 27 | 28 | private func update(headerFrames: [Namespace.ID: CGRect]) { 29 | var newHeaderOffsets: [Namespace.ID: CGFloat] = [:] 30 | defer { headerOffsets = newHeaderOffsets } 31 | 32 | let headerFramePairs = headerFrames 33 | .sorted { $0.value.minY < $1.value.minY } 34 | .adjacentPairsFromNil() 35 | 36 | for (previous, current) in headerFramePairs { 37 | if let previous, 38 | case let touchingDistance = current.value.minY - previous.value.height, 39 | touchingDistance < 0 { 40 | newHeaderOffsets[previous.key] = -previous.value.minY + touchingDistance 41 | } 42 | 43 | if current.value.minY < 0 { 44 | newHeaderOffsets[current.key] = -current.value.minY 45 | } else { 46 | break 47 | } 48 | } 49 | } 50 | } 51 | 52 | private struct PinnedCoordinatorKey: EnvironmentKey { 53 | static let defaultValue: PinnedCoordinator? = nil 54 | } 55 | 56 | extension EnvironmentValues { 57 | var pinnedCoordinator: PinnedCoordinator? { 58 | get { self[PinnedCoordinatorKey.self] } 59 | set { self[PinnedCoordinatorKey.self] = newValue } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/PinnedScrollView/PinnedModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PinnedModifier.swift 3 | // 4 | // 5 | // Created by Lumisilk on 2023/11/14. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | private struct PinnedModifier: ViewModifier { 12 | @Environment(\.pinnedCoordinator) private var coordinator 13 | 14 | @Namespace private var id 15 | @State private var offset: CGFloat = 0 16 | 17 | var onReachedTop: ((Bool) -> ())? 18 | 19 | func body(content: Content) -> some View { 20 | content 21 | .offset(y: offset) 22 | .onReceive(offsetPublisher) { offset = $0 } 23 | .backport.overlay { 24 | if let coordinator { 25 | GeometryReader { proxy in 26 | let frame = proxy.frame(in: .named(coordinator.scrollViewName)) 27 | let isReachedTop = frame.minY <= 0 28 | Color.clear 29 | .onAppear { 30 | coordinator.headerFrames[id] = frame 31 | } 32 | .onChange(of: frame) { frame in 33 | coordinator.headerFrames[id] = frame 34 | } 35 | .onAppear { 36 | onReachedTop?(isReachedTop) 37 | } 38 | .onChange(of: isReachedTop) { isReachedTop in 39 | onReachedTop?(isReachedTop) 40 | } 41 | } 42 | .hidden() 43 | } 44 | } 45 | .zIndex(1) 46 | } 47 | 48 | var offsetPublisher: some Publisher { 49 | if let coordinator { 50 | return coordinator 51 | .$headerOffsets 52 | .map { $0[id] ?? 0 } 53 | .removeDuplicates() 54 | .eraseToAnyPublisher() 55 | } else { 56 | return Empty().eraseToAnyPublisher() 57 | } 58 | } 59 | } 60 | 61 | public extension View { 62 | /// The .pinned() modifier is used to pin a view within a ScrollView. 63 | /// 64 | /// When applied, it keeps the view fixed at the top of the ScrollView while the rest of the content scrolls. 65 | /// Note: You must also apply the `.pinnedScrollView()` modifier to the corresponding ScrollView for this modifier to have any effect. 66 | /// 67 | /// - Parameter onReachedTop: An optional closure that is executed when the pinned view reaches or leaves the top of the ScrollView. Note that this value remains `true` if the pinned view scrolls upwards out of the visible range. 68 | func pinned(onReachedTop: ((Bool) -> ())? = nil) -> some View { 69 | modifier(PinnedModifier(onReachedTop: onReachedTop)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/PinnedScrollView/PinnedScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PinnedScrollView.swift 3 | // 4 | // 5 | // Created by Lumisilk on 2023/11/14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct PinnedScrollViewModifier: ViewModifier { 11 | @StateObject private var coordinator = PinnedCoordinator() 12 | 13 | func body(content: Content) -> some View { 14 | content 15 | .coordinateSpace(name: coordinator.scrollViewName) 16 | .environment(\.pinnedCoordinator, coordinator) 17 | } 18 | } 19 | 20 | public extension View { 21 | /// The `.pinnedScrollView()` modifier is applied to a ScrollView to enable the pinning functionality. 22 | /// 23 | /// This modifier should be used on the ScrollView that contains views with the `.pinned()` modifier. 24 | func pinnedScrollView() -> some View { 25 | modifier(PinnedScrollViewModifier()) 26 | } 27 | } 28 | 29 | struct PinnedScrollViewModifier_Previews: PreviewProvider { 30 | 31 | struct SimpleExample: View { 32 | var body: some View { 33 | ScrollView { 34 | VStack(spacing: 0) { 35 | ForEach(0..<20) { _ in 36 | Text("Header") 37 | .font(.title) 38 | .frame(maxWidth: .infinity) 39 | .backport.background { 40 | Color.green 41 | } 42 | .pinned() 43 | 44 | Text( 45 | "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book." 46 | ) 47 | .padding(8) 48 | } 49 | } 50 | } 51 | .pinnedScrollView() 52 | } 53 | } 54 | 55 | struct BindingExample: View { 56 | @State var isHeaderReachedTop: [Bool] = .init(repeating: false, count: 20) 57 | 58 | var body: some View { 59 | ScrollView { 60 | VStack(spacing: 0) { 61 | ForEach(Array(0..