Add paginated panel loading

This commit is contained in:
Georg Bauer
2026-07-31 15:02:21 +02:00
parent 228879da15
commit 33f9eb809c
11 changed files with 1104 additions and 290 deletions

View File

@@ -8,6 +8,7 @@ final class IssuesViewController: RefreshingTableViewController {
private var rows: [IssueRow] = []
private var filterOptions: IssueFilterOptions?
private var filterTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
private lazy var filterButton = UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
@@ -45,17 +46,38 @@ final class IssuesViewController: RefreshingTableViewController {
}
private func loadIssues(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadIssues(page: 1, refreshing: refreshing)
}
override func loadMoreContent() {
loadIssues(page: currentPage + 1, refreshing: false)
}
private func loadIssues(page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
rows = try await context.core.issues(owner: owner, repository: repository)
let result = try await context.core.issues(
owner: owner,
repository: repository,
page: page
)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
updateEmptyState()
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if page == 1 { endLoading() }
}
}
@@ -335,6 +357,7 @@ final class MilestonesViewController: RefreshingTableViewController {
private let owner: String
private let repository: String
private var rows: [MilestoneRow] = []
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String) {
self.context = context
@@ -356,11 +379,29 @@ final class MilestonesViewController: RefreshingTableViewController {
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
rows = try await context.core.milestones(owner: owner, repository: repository)
let result = try await context.core.milestones(
owner: owner,
repository: repository,
page: page
)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
@@ -369,9 +410,12 @@ final class MilestonesViewController: RefreshingTableViewController {
)
: nil
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if page == 1 { endLoading() }
}
}
@@ -412,6 +456,7 @@ final class MilestoneViewController: RefreshingTableViewController {
private let repository: String
private let id: Int64
private var page: MilestonePage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, id: Int64) {
self.context = context
@@ -435,21 +480,45 @@ final class MilestoneViewController: RefreshingTableViewController {
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
page = try await context.core.milestone(
let result = try await context.core.milestone(
owner: owner,
repository: repository,
id: id
id: id,
page: requestedPage
)
if requestedPage == 1 {
page = result
} else {
page?.issues.append(contentsOf: result.issues)
page?.pulls.append(contentsOf: result.pulls)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
finishPagination(hasMore: result.hasMore)
title = page?.milestone.title
tableView.reloadData()
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if requestedPage == 1 { endLoading() }
}
}
@@ -584,6 +653,7 @@ final class PullsViewController: RefreshingTableViewController {
private var rows: [PullRow] = []
private var filterOptions: PullFilterOptions?
private var filterTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
init(context: AppContext) {
self.context = context
@@ -626,11 +696,25 @@ final class PullsViewController: RefreshingTableViewController {
}
private func loadPulls(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadPulls(page: 1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPulls(page: currentPage + 1, refreshing: false)
}
private func loadPulls(page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
rows = try await context.core.pulls()
let result = try await context.core.pulls(page: page)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
let status = context.core.settings().pullStatus
tableView.backgroundView = rows.isEmpty
@@ -642,9 +726,12 @@ final class PullsViewController: RefreshingTableViewController {
)
: nil
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if page == 1 { endLoading() }
}
}
@@ -783,6 +870,7 @@ final class CommitsViewController: RefreshingTableViewController {
private var contents: [RepositoryContentRow] = []
private var branch: String?
private var mode = Mode.history
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String) {
self.context = context
@@ -808,7 +896,19 @@ final class CommitsViewController: RefreshingTableViewController {
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
guard mode == .history else { return }
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
@@ -817,8 +917,11 @@ final class CommitsViewController: RefreshingTableViewController {
page = try await context.core.commits(
owner: owner,
repository: repository,
branch: branch
branch: branch,
pages: requestedPage
)
currentPage = requestedPage
finishPagination(hasMore: page?.hasMore ?? false)
updateBranchMenu()
case .files:
contents = try await context.core.repositoryContents(
@@ -830,9 +933,12 @@ final class CommitsViewController: RefreshingTableViewController {
tableView.reloadData()
updateEmptyView()
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if requestedPage == 1 { endLoading() }
}
}

View File

@@ -5,12 +5,18 @@ import SwiftUI
import UIKit
@MainActor
class MarkdownPageViewController: UIViewController {
class MarkdownPageViewController: UIViewController, UIScrollViewDelegate {
let context: AppContext
let scrollView = UIScrollView()
let stack = UIStackView()
private let spinner = UIActivityIndicatorView(style: .medium)
var loadingTask: Task<Void, Never>?
private lazy var moreButton = UIButton(
configuration: .plain(),
primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
)
private var hasMoreContent = false
private var loadingMore = false
init(context: AppContext) {
self.context = context
@@ -25,6 +31,7 @@ class MarkdownPageViewController: UIViewController {
view.backgroundColor = .systemGroupedBackground
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = true
scrollView.delegate = self
scrollView.refreshControl = UIRefreshControl()
scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
view.addSubview(scrollView)
@@ -49,6 +56,8 @@ class MarkdownPageViewController: UIViewController {
func loadContent(refreshing: Bool) {}
func loadMoreContent() {}
func beginLoading(refreshing: Bool) {
if !refreshing { beginNavigationLoading(spinner) }
}
@@ -63,6 +72,54 @@ class MarkdownPageViewController: UIViewController {
views.forEach(stack.addArrangedSubview)
}
func resetPagination() {
hasMoreContent = false
loadingMore = false
stack.removeArrangedSubview(moreButton)
moreButton.removeFromSuperview()
}
func finishPagination(hasMore: Bool) {
hasMoreContent = hasMore
loadingMore = false
guard hasMore else { return }
var configuration = moreButton.configuration
configuration?.title = "Pull up or tap to load more"
configuration?.showsActivityIndicator = false
moreButton.configuration = configuration
moreButton.accessibilityLabel = "Load more results"
if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) }
if moreButton.constraints.isEmpty {
moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
}
}
func failPagination() {
loadingMore = false
finishPagination(hasMore: hasMoreContent)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
let bottom = max(
-scrollView.adjustedContentInset.top,
scrollView.contentSize.height
+ scrollView.adjustedContentInset.bottom
- scrollView.bounds.height
)
if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
}
private func requestMoreContent() {
guard hasMoreContent, !loadingMore else { return }
loadingMore = true
var configuration = moreButton.configuration
configuration?.title = "Loading more…"
configuration?.showsActivityIndicator = true
moreButton.configuration = configuration
loadMoreContent()
}
@objc private func refreshRequested() {
loadContent(refreshing: true)
}
@@ -73,6 +130,8 @@ final class IssueViewController: MarkdownPageViewController {
private let owner: String
private let repository: String
private let number: Int64
private var page: IssuePage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, number: Int64) {
self.owner = owner
@@ -110,27 +169,52 @@ final class IssueViewController: MarkdownPageViewController {
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let page = try await context.core.issue(
let result = try await context.core.issue(
owner: owner,
repository: repository,
number: number
number: number,
page: requestedPage
)
replaceContent(detailViews(
title: page.title,
issueState: page.state,
meta: page.meta,
body: page.body,
comments: page.comments,
milestone: page.milestone
))
if requestedPage == 1 {
page = result
} else {
page?.comments.append(contentsOf: result.comments)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
if let page {
replaceContent(detailViews(
title: page.title,
issueState: page.state,
meta: page.meta,
body: page.body,
comments: page.comments,
milestone: page.milestone
))
finishPagination(hasMore: page.hasMore)
}
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if requestedPage == 1 { endLoading() }
}
}
}
@@ -140,6 +224,8 @@ final class PullViewController: MarkdownPageViewController {
private let owner: String
private let repository: String
private let number: Int64
private var page: PullPage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, number: Int64) {
self.owner = owner
@@ -158,24 +244,45 @@ final class PullViewController: MarkdownPageViewController {
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let page = try await context.core.pull(
let result = try await context.core.pull(
owner: owner,
repository: repository,
number: number
number: number,
page: requestedPage
)
var views = detailViews(
title: page.title,
meta: page.meta,
body: page.body,
comments: []
)
if !page.files.isEmpty {
views.append(sectionLabel(page.filesRef))
views.append(contentsOf: page.files.map { file in
if requestedPage == 1 {
page = result
} else {
page?.files.append(contentsOf: result.files)
page?.comments.append(contentsOf: result.comments)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
if let page {
var views = detailViews(
title: page.title,
meta: page.meta,
body: page.body,
comments: []
)
if !page.files.isEmpty {
views.append(sectionLabel(page.filesRef))
views.append(contentsOf: page.files.map { file in
detailButton(title: file.path, detail: file.status) { [weak self] in
guard let self else { return }
self.navigationController?.pushViewController(
@@ -191,14 +298,19 @@ final class PullViewController: MarkdownPageViewController {
animated: true
)
}
})
})
}
views.append(contentsOf: commentViews(page.comments))
replaceContent(views)
finishPagination(hasMore: page.hasMore)
}
views.append(contentsOf: commentViews(page.comments))
replaceContent(views)
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if requestedPage == 1 { endLoading() }
}
}
}

View File

@@ -188,6 +188,7 @@ final class RepositoriesViewController: RefreshingTableViewController {
private let context: AppContext
private let mode: RepositoryMode
private var rows: [RepositoryRow] = []
private var currentPage: UInt32 = 0
init(context: AppContext, mode: RepositoryMode) {
self.context = context
@@ -215,11 +216,25 @@ final class RepositoriesViewController: RefreshingTableViewController {
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
rows = try await context.core.repositories()
let result = try await context.core.repositories(page: page)
rows = result.rows
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
@@ -228,9 +243,12 @@ final class RepositoriesViewController: RefreshingTableViewController {
)
: nil
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if page == 1 { endLoading() }
}
}
@@ -308,6 +326,7 @@ final class HomeViewController: RefreshingTableViewController {
private let context: AppContext
private var page: HomePage?
private var filter = ActivityFilter.all
private var currentPage: UInt32 = 0
private var activities: [ActivityRow] {
guard let page else { return [] }
@@ -358,24 +377,48 @@ final class HomeViewController: RefreshingTableViewController {
refreshControl?.endRefreshing()
return
}
beginLoading(refreshing: refreshing)
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
page = try await context.core.home()
let result = try await context.core.home(page: requestedPage)
if requestedPage == 1 {
page = result
} else {
page?.activities.append(contentsOf: result.activities)
page?.issueActivities.append(contentsOf: result.issueActivities)
page?.pullActivities.append(contentsOf: result.pullActivities)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
finishPagination(hasMore: result.hasMore)
title = page?.serverName
tableView.tableHeaderView = page.map { page in
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in
guard let self, let filter = ActivityFilter(rawValue: index) else { return }
self.filter = filter
self.updateActivities()
}
}
} }
updateActivities()
} catch {
if !Task.isCancelled { show(error: error) }
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
endLoading()
if requestedPage == 1 { endLoading() }
}
}

View File

@@ -29,6 +29,12 @@ extension UIViewController {
class RefreshingTableViewController: UITableViewController {
private let spinner = UIActivityIndicatorView(style: .medium)
var loadingTask: Task<Void, Never>?
private lazy var moreButton = UIButton(
configuration: .plain(),
primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
)
private var hasMoreContent = false
private var loadingMore = false
init() {
super.init(style: .plain)
@@ -46,6 +52,8 @@ class RefreshingTableViewController: UITableViewController {
func loadContent(refreshing: Bool) {}
func loadMoreContent() {}
func beginLoading(refreshing: Bool) {
if !refreshing { beginNavigationLoading(spinner) }
}
@@ -55,6 +63,58 @@ class RefreshingTableViewController: UITableViewController {
refreshControl?.endRefreshing()
}
func resetPagination() {
hasMoreContent = false
loadingMore = false
tableView.tableFooterView = nil
}
func finishPagination(hasMore: Bool) {
hasMoreContent = hasMore
loadingMore = false
var configuration = moreButton.configuration
configuration?.title = "Pull up or tap to load more"
configuration?.showsActivityIndicator = false
moreButton.configuration = configuration
moreButton.accessibilityLabel = "Load more results"
tableView.tableFooterView = hasMore ? paginationFooter() : nil
}
func failPagination() {
loadingMore = false
finishPagination(hasMore: hasMoreContent)
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
let bottom = max(
-scrollView.adjustedContentInset.top,
scrollView.contentSize.height
+ scrollView.adjustedContentInset.bottom
- scrollView.bounds.height
)
if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
}
private func requestMoreContent() {
guard hasMoreContent, !loadingMore else { return }
loadingMore = true
var configuration = moreButton.configuration
configuration?.title = "Loading more…"
configuration?.showsActivityIndicator = true
moreButton.configuration = configuration
moreButton.accessibilityLabel = "Loading more results"
loadMoreContent()
}
private func paginationFooter() -> UIView {
let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56))
moreButton.frame = footer.bounds
moreButton.autoresizingMask = [.flexibleWidth, .flexibleHeight]
footer.addSubview(moreButton)
return footer
}
@objc private func refreshRequested() {
loadContent(refreshing: true)
}