Skip to content

Instantly share code, notes, and snippets.

@pexavc
Created July 26, 2023 03:10
Show Gist options
  • Save pexavc/00ce646f3220925ab738357546c00339 to your computer and use it in GitHub Desktop.
Save pexavc/00ce646f3220925ab738357546c00339 to your computer and use it in GitHub Desktop.
Simply Paging Manager/UI for an infinite scrolling feed.
import Foundation
import Combine
import SwiftUI
protocol Pageable: Equatable, Identifiable, Hashable {
var id: String { get }
}
class Pager<Model: Pageable>: ObservableObject {
@Published var itemIDs: [String]
var itemMap: [String: Model] = [:]
var items: [Model] {
itemIDs.compactMap { itemMap[$0] }
}
@Published var fetchMoreTimedOut: Bool = false
@Published var isFetching: Bool = false
@Published var hasMore: Bool = true
var pageSize: Int = 10
var isEmpty: Bool {
items.isEmpty
}
private(set) var lastItem: Model? = nil
var onRefreshHandler: GraniteScrollView.CompletionHandler?
var pageIndex: Int = 1
private var timerCancellable: Cancellable?
private var task: Task<Void, Error>? = nil
private var handler: ((Int?) async -> [Model])?
var enableAuxiliaryLoaders: Bool = false
var emptyText: LocalizedStringKey
init(emptyText: LocalizedStringKey) {
self.emptyText = emptyText
itemIDs = []
self.handler = nil
}
@discardableResult
func hook(_ commit: @escaping ((Int?) async -> [Model])) -> Self {
self.handler = commit
return self
}
func refresh(_ handler: GraniteScrollView.CompletionHandler?) {
self.onRefreshHandler = handler
self.fetch(force: true)
}
func fetch(force: Bool = false) {
guard hasMore || force else { return }
if force {
pageIndex = 1
hasMore = true
}
guard self.isFetching == false else {
if force {
clean()
}
return
}
self.isFetching = true
self.timerCancellable = Timer.publish(every: 5,
on: .main,
in: .common)
.autoconnect()
.sink(receiveValue: { [weak self] (output) in
self?.fetchMoreTimedOut = true
self?.timerCancellable?.cancel()
self?.timerCancellable = nil
})
self.task?.cancel()
self.task = Task.detached { [weak self] in
let models: [Model] = (await self?.handler?(self?.pageIndex)) ?? []
DispatchQueue.main.async { [weak self] in
let lastModel = models.last
if !force,
let lastItem = self?.lastItem,
models.contains(lastItem) {
self?.hasMore = false
}
if models.isEmpty {
self?.hasMore = false
}
self?.lastItem = lastModel
if self?.hasMore == true {
self?.pageIndex += 1
}
if force {
self?.itemIDs = models.map { $0.id }
} else if self?.hasMore == true {
let items = models.filter { self?.itemIDs.contains($0.id) == false }
self?.itemIDs.append(contentsOf: items.map { $0.id })
for model in items {
self?.itemMap[model.id] = model
}
}
self?.clean()
if self?.enableAuxiliaryLoaders == false {
self?.enableAuxiliaryLoaders = true
}
}
}
}
func tryAgain() {
clean()
fetch()
}
func clean() {
self.timerCancellable?.cancel()
self.timerCancellable = nil
self.isFetching = false
self.fetchMoreTimedOut = false
self.onRefreshHandler?()
self.onRefreshHandler = nil
}
}
struct PagerScrollView<Model: Pageable, Header: View, AddContent: View, Content: View>: View {
@EnvironmentObject private var pager: Pager<Model>
let header: () -> Header
let addContent: () -> AddContent
let content: (Model) -> Content
let hideDivider: Bool
let alternateAddPosition: Bool
init(_ model: Model.Type,
hideDivider: Bool = false,
alternateAddPosition: Bool = false,
@ViewBuilder header: @escaping (() -> Header) = { EmptyView() },
@ViewBuilder inlineBody: @escaping (() -> AddContent) = { EmptyView() },
@ViewBuilder content: @escaping (Model) -> Content) {
self.header = header
self.addContent = inlineBody
self.content = content
self.hideDivider = hideDivider
self.alternateAddPosition = alternateAddPosition
}
var body: some View {
Group {
if pager.isEmpty {
if alternateAddPosition {
addContent()
}
header()
if !alternateAddPosition {
addContent()
}
PagerLoadingView<Model>(label: pager.emptyText)
.environmentObject(pager)
} else {
GraniteScrollView(onRefresh: pager.refresh(_:)) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
if alternateAddPosition {
addContent()
}
Section(header: header(),
footer: PagerFooterLoadingView<Model>().environmentObject(pager)) {
if !alternateAddPosition {
addContent()
}
ForEach(pager.items) { item in
content(item)
if !hideDivider,
item.id != pager.lastItem?.id {
Divider()
}
}
}
Spacer()
}
}
}
}
}
}
struct PagerFooterLoadingView<Model: Pageable>: View {
@EnvironmentObject private var pager: Pager<Model>
var hasMore: Bool {
pager.hasMore && pager.items.count >= pager.pageSize
}
var body: some View {
VStack {
if pager.fetchMoreTimedOut {
Image(systemName: "arrow.counterclockwise")
.font(.headline.bold())
.onTapGesture {
GraniteHaptic.light.invoke()
pager.tryAgain()
}
} else if pager.isFetching {
#if os(iOS)
ProgressView()
#else
ProgressView()
.scaleEffect(0.6)
#endif
} else {
EmptyView()
}
}
.frame(maxWidth: .infinity, minHeight: hasMore ? 40 : 0, maxHeight: (hasMore ? 40 : 0))
.onAppear {
if pager.enableAuxiliaryLoaders && hasMore {
pager.fetch()
}
}
}
}
struct PagerLoadingView<Model: Pageable>: View {
@EnvironmentObject private var pager: Pager<Model>
var label: LocalizedStringKey
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
if pager.isFetching {
#if os(iOS)
ProgressView()
#else
ProgressView()
.scaleEffect(0.6)
#endif
} else {
Text(label)
.font(.headline.bold())
}
Spacer()
}
Spacer()
}
}
}
@pexavc
Copy link
Author

pexavc commented Jul 26, 2023

Example Use:

@StateObject var comments: Pager<CommentView> = .init(emptyText: "No comments")

PagerScrollView(CommentView.self) {
    contentHeader
        .background(Color.background)
} inlineBody: {
    content
} content: { comment in
    CommentCardView(model: comment, postView: model)
        .attach({ model in
            self.showDrawer = true
            self.commentModel = model
        }, at: \.showDrawer)
}.environmentObject(comments)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment