├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Package.resolved
├── LICENSE
├── Package.swift
├── Sources
└── SwiftUIInfiniteList
│ └── SwiftUIInfiniteList.swift
├── .gitignore
└── README.md
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "SwiftUIPullToRefresh",
6 | "repositoryURL": "https://github.com/globulus/swiftui-pull-to-refresh",
7 | "state": {
8 | "branch": null,
9 | "revision": "6ce6cab3f7c1b6c946ba18c84a1f35ce82005029",
10 | "version": "1.0.1"
11 | }
12 | }
13 | ]
14 | },
15 | "version": 1
16 | }
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Gordan Glavaš
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.3
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: "SwiftUIInfiniteList",
8 | platforms: [
9 | .iOS(.v13)
10 | ],
11 | products: [
12 | // Products define the executables and libraries a package produces, and make them visible to other packages.
13 | .library(
14 | name: "SwiftUIInfiniteList",
15 | targets: ["SwiftUIInfiniteList"]),
16 | ],
17 | dependencies: [
18 | // Dependencies declare other packages that this package depends on.
19 | // .package(url: /* package url */, from: "1.0.0"),
20 | .package(name: "SwiftUIPullToRefresh", url: "https://github.com/globulus/swiftui-pull-to-refresh", from: "1.0.1")
21 | ],
22 | targets: [
23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
24 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
25 | .target(
26 | name: "SwiftUIInfiniteList",
27 | dependencies: ["SwiftUIPullToRefresh"]),
28 | .testTarget(
29 | name: "SwiftUIInfiniteListTests",
30 | dependencies: ["SwiftUIInfiniteList"]),
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/Sources/SwiftUIInfiniteList/SwiftUIInfiniteList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import SwiftUIPullToRefresh
3 |
4 | public struct InfiniteList: View
5 | where Data: RandomAccessCollection, Data.Element: Hashable, Content: View, LoadingView: View {
6 | @Binding var data: Data
7 | @Binding var isLoading: Bool
8 | let loadingView: LoadingView
9 | let loadMore: () -> Void
10 | let onRefresh: OnRefresh?
11 | let content: (Data.Element) -> Content
12 |
13 | public init(data: Binding,
14 | isLoading: Binding,
15 | loadingView: LoadingView,
16 | loadMore: @escaping () -> Void,
17 | onRefresh: OnRefresh? = nil,
18 | @ViewBuilder content: @escaping (Data.Element) -> Content) {
19 | _data = data
20 | _isLoading = isLoading
21 | self.loadingView = loadingView
22 | self.loadMore = loadMore
23 | self.onRefresh = onRefresh
24 | self.content = content
25 | }
26 |
27 | public var body: some View {
28 | if onRefresh != nil {
29 | RefreshableScrollView(onRefresh: onRefresh!) {
30 | scrollableContent
31 | .onAppear(perform: loadMore)
32 | }
33 | } else {
34 | List {
35 | listItems
36 | }.onAppear(perform: loadMore)
37 | }
38 | }
39 |
40 | private var scrollableContent: some View {
41 | Group {
42 | if #available(iOS 14.0, *) {
43 | LazyVStack(spacing: 10) {
44 | listItems
45 | }
46 | } else {
47 | VStack(spacing: 10) {
48 | listItems
49 | }
50 | }
51 | }
52 | }
53 |
54 | private var listItems: some View {
55 | Group {
56 | ForEach(data, id: \.self) { item in
57 | content(item)
58 | .onAppear {
59 | if item == data.last {
60 | loadMore()
61 | }
62 | }
63 | }
64 | if isLoading {
65 | loadingView
66 | .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
67 | }
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUIInfiniteList
2 |
3 | Infinite scrolling list in SwiftUI:
4 |
5 | * A single view, that can be used just like any other List.
6 | * Renders data from a collection via a ViewBuilder and triggers loading when the list is scrolled to the bottom.
7 | * Customizable loading view (spinner).
8 | * Built-in [pull-to-refresh functionality](https://github.com/globulus/swiftui-pull-to-refresh) - set `onRefresh` callback to enable.
9 |
10 | 
11 |
12 | ### Recipe
13 |
14 | Check out [this recipe](https://swiftuirecipes.com/blog/infinite-scroll-list-in-swiftui) for in-depth description of the component and its code. Check out [SwiftUIRecipes.com](https://swiftuirecipes.com) for more **SwiftUI recipes**!
15 |
16 | ### Sample usage
17 |
18 | ```swift
19 | struct InifiniteListTest: View {
20 | @ObservedObject var viewModel: ListViewModel
21 |
22 | var body: some View {
23 | InfiniteList(data: $viewModel.items,
24 | isLoading: $viewModel.isLoading,
25 | loadingView: ProgressView(),
26 | loadMore: viewModel.loadMore,
27 | onRefresh: viewModel.refresh(refreshComplete:)) { item in
28 | Text(item.text)
29 | }
30 | }
31 | }
32 |
33 | struct ListItem: Hashable {
34 | let text: String
35 | }
36 |
37 | class ListViewModel: ObservableObject {
38 | @Published var items = [ListItem]()
39 | @Published var isLoading = false
40 | private var page = 1
41 | private var subscriptions = Set()
42 |
43 | func loadMore() {
44 | guard !isLoading else { return }
45 |
46 | isLoading = true
47 | (1...15).publisher
48 | .map { index in ListItem(text: "Page: \(page) item: \(index)") }
49 | .collect()
50 | .delay(for: .seconds(2), scheduler: RunLoop.main)
51 | .sink { [self] completion in
52 | isLoading = false
53 | page += 1
54 | } receiveValue: { [self] value in
55 | items += value
56 | }
57 | .store(in: &subscriptions)
58 | }
59 |
60 | func refresh(refreshComplete: RefreshComplete) {
61 | subscriptions.forEach { $0.cancel() }
62 | items.removeAll()
63 | isLoading = false
64 | page = 1
65 | loadMore()
66 | refreshComplete()
67 | }
68 | }
69 | ```
70 |
71 | ### Installation
72 |
73 | This component is distrubuted as a **Swift package**.
74 |
--------------------------------------------------------------------------------