├── .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..