Timelines with waterfall

This commit is contained in:
Marcin Czachurski 2023-05-26 16:06:38 +02:00
parent 085abcbea1
commit d3aa3b7099
9 changed files with 168 additions and 95 deletions

View File

@ -9,6 +9,13 @@ import PixelfedKit
extension Client {
public class PublicTimeline: BaseClient {
public func getHomeTimeline(maxId: String? = nil,
sinceId: String? = nil,
minId: String? = nil,
limit: Int = 40) async throws -> [Status] {
return try await pixelfedClient.getHomeTimeline(maxId: maxId, sinceId: sinceId, minId: minId, limit: limit)
}
public func getStatuses(local: Bool? = nil,
remote: Bool? = nil,
maxId: String? = nil,

View File

@ -21,11 +21,6 @@ struct HomeFeedView: View {
@State private var opacity = 0.0
@State private var offset = -50.0
// Gallery parameters.
@State private var imageColumns = 3
@State private var containerWidth: Double = UIScreen.main.bounds.width
@State private var containerHeight: Double = UIScreen.main.bounds.height
@FetchRequest var dbStatuses: FetchedResults<StatusData>
init(accountId: String) {
@ -60,49 +55,32 @@ struct HomeFeedView: View {
private func timeline() -> some View {
ZStack {
ScrollView {
if self.imageColumns > 1 {
// WaterfallGrid(statusViewModel: $dbStatuses, columns: $imageColumns, hideLoadMore: $allItemsLoaded) { item in
// ImageRowAsync(statusViewModel: item, containerWidth: $containerWidth)
// } onLoadMore: {
// do {
// try await self.loadMoreStatuses()
// } catch {
// ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
// }
// }
} else {
LazyVStack {
ForEach(dbStatuses, id: \.self) { item in
if self.shouldUpToDateBeVisible(statusId: item.id) {
self.upToDatePlaceholder()
}
ImageRow(statusData: item)
LazyVStack {
ForEach(dbStatuses, id: \.self) { item in
if self.shouldUpToDateBeVisible(statusId: item.id) {
self.upToDatePlaceholder()
}
if allItemsLoaded == false {
LoadingIndicator()
.task {
do {
if let account = self.applicationState.account {
let newStatusesCount = try await HomeTimelineService.shared.loadOnBottom(for: account)
if newStatusesCount == 0 {
allItemsLoaded = true
}
ImageRow(statusData: item)
}
if allItemsLoaded == false {
LoadingIndicator()
.task {
do {
if let account = self.applicationState.account {
let newStatusesCount = try await HomeTimelineService.shared.loadOnBottom(for: account)
if newStatusesCount == 0 {
allItemsLoaded = true
}
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: !Task.isCancelled)
}
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: !Task.isCancelled)
}
}
}
}
}
}
.gallery { galleryProperties in
self.imageColumns = galleryProperties.imageColumns
self.containerWidth = galleryProperties.containerWidth
self.containerHeight = galleryProperties.containerHeight
}
self.newPhotosView()
.offset(y: self.offset)

View File

@ -116,8 +116,13 @@ struct MainView: View {
private func getMainView() -> some View {
switch self.viewMode {
case .home:
HomeFeedView(accountId: applicationState.account?.id ?? String.empty())
.id(applicationState.account?.id ?? String.empty())
if UIDevice.isIPhone {
HomeFeedView(accountId: applicationState.account?.id ?? String.empty())
.id(applicationState.account?.id ?? String.empty())
} else {
StatusesView(listType: .home)
.id(applicationState.account?.id ?? String.empty())
}
case .trendingPhotos:
TrendStatusesView(accountId: applicationState.account?.id ?? String.empty())
.id(applicationState.account?.id ?? String.empty())

View File

@ -38,6 +38,11 @@ struct PaginableStatusesView: View {
@State private var state: ViewState = .loading
@State private var page = 1
// Gallery parameters.
@State private var imageColumns = 3
@State private var containerWidth: Double = UIScreen.main.bounds.width
@State private var containerHeight: Double = UIScreen.main.bounds.height
private let defaultLimit = 10
private let imagePrefetcher = ImagePrefetcher(destination: .diskCache)
@ -75,27 +80,44 @@ struct PaginableStatusesView: View {
@ViewBuilder
private func list() -> some View {
ScrollView {
LazyVStack(alignment: .center) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item, containerWidth: Binding.constant(UIScreen.main.bounds.width))
if self.imageColumns > 1 {
WaterfallGrid($statusViewModels, columns: $imageColumns, hideLoadMore: $allItemsLoaded) { item in
ImageRowAsync(statusViewModel: item, containerWidth: $containerWidth)
} onLoadMore: {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
}
}
} else {
LazyVStack(alignment: .center) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item, containerWidth: $containerWidth)
}
if allItemsLoaded == false {
HStack {
Spacer()
LoadingIndicator()
.task {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
if allItemsLoaded == false {
HStack {
Spacer()
LoadingIndicator()
.task {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
}
}
}
Spacer()
Spacer()
}
}
}
}
}
.gallery { galleryProperties in
self.imageColumns = galleryProperties.imageColumns
self.containerWidth = galleryProperties.containerWidth
self.containerHeight = galleryProperties.containerHeight
}
}
private func loadData() async {

View File

@ -14,6 +14,7 @@ import WidgetsKit
struct StatusesView: View {
public enum ListType: Hashable {
case home
case local
case federated
case favourites
@ -22,6 +23,8 @@ struct StatusesView: View {
public var title: LocalizedStringKey {
switch self {
case .home:
return "mainview.tab.homeTimeline"
case .local:
return "statuses.navigationBar.localTimeline"
case .federated:
@ -94,10 +97,11 @@ struct StatusesView: View {
private func list() -> some View {
ScrollView {
if self.imageColumns > 1 {
WaterfallGrid(statusViewModel: $statusViewModels, columns: $imageColumns, hideLoadMore: $allItemsLoaded) { item in
WaterfallGrid($statusViewModels, columns: $imageColumns, hideLoadMore: $allItemsLoaded) { item in
ImageRowAsync(statusViewModel: item, containerWidth: $containerWidth)
} onLoadMore: {
do {
print("load more......")
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
@ -232,6 +236,12 @@ struct StatusesView: View {
private func loadFromApi(maxId: String? = nil, sinceId: String? = nil, minId: String? = nil) async throws -> [Status] {
switch self.listType {
case .home:
return try await self.client.publicTimeline?.getHomeTimeline(
maxId: maxId,
sinceId: sinceId,
minId: minId,
limit: self.defaultLimit) ?? []
case .local:
return try await self.client.publicTimeline?.getStatuses(
local: true,

View File

@ -21,6 +21,11 @@ struct TrendStatusesView: View {
@State private var statusViewModels: [StatusModel] = []
@State private var state: ViewState = .loading
// Gallery parameters.
@State private var imageColumns = 3
@State private var containerWidth: Double = UIScreen.main.bounds.width
@State private var containerHeight: Double = UIScreen.main.bounds.height
var body: some View {
ScrollView {
Picker(selection: $tabSelectedValue, label: Text("")) {
@ -45,6 +50,20 @@ struct TrendStatusesView: View {
self.mainBody()
}
.gallery { galleryProperties in
self.imageColumns = galleryProperties.imageColumns
self.containerWidth = galleryProperties.containerWidth
self.containerHeight = galleryProperties.containerHeight
}
.refreshable {
do {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
try await self.loadStatuses()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
} catch {
ErrorService.shared.handle(error, message: "trendingStatuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
}
}
.navigationTitle("trendingStatuses.navigationBar.title")
}
@ -70,18 +89,15 @@ struct TrendStatusesView: View {
if self.statusViewModels.isEmpty {
NoDataView(imageSystemName: "photo.on.rectangle.angled", text: "trendingStatuses.title.noPhotos")
} else {
LazyVStack(alignment: .center) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item, containerWidth: Binding.constant(UIScreen.main.bounds.width))
}
}
.refreshable {
do {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
try await self.loadStatuses()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
} catch {
ErrorService.shared.handle(error, message: "trendingStatuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
if self.imageColumns > 1 {
WaterfallGrid($statusViewModels, columns: $imageColumns, hideLoadMore: Binding.constant(true)) { item in
ImageRowAsync(statusViewModel: item, containerWidth: $containerWidth)
} onLoadMore: { }
} else {
LazyVStack(alignment: .center) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item, containerWidth: Binding.constant(UIScreen.main.bounds.width))
}
}
}
}

View File

@ -42,7 +42,7 @@ struct UserProfileStatusesView: View {
var body: some View {
if firstLoadFinished == true {
if self.imageColumns > 1 {
WaterfallGrid(statusViewModel: $statusViewModels, columns: $imageColumns, hideLoadMore: $allItemsLoaded) { item in
WaterfallGrid($statusViewModels, columns: $imageColumns, hideLoadMore: $allItemsLoaded) { item in
ImageRowAsync(statusViewModel: item, withAvatar: false, containerWidth: $containerWidth)
} onLoadMore: {
do {

View File

@ -6,33 +6,23 @@
import SwiftUI
import WidgetsKit
import ClientKit
struct WaterfallGrid<Content>: View where Content: View {
@Binding private var statusViewModels: [StatusModel]
struct WaterfallGrid<Data, ID, Content>: View where Data: RandomAccessCollection, Data: Equatable, Content: View,
ID: Hashable, Data.Element: Equatable, Data.Element: Identifiable, Data.Element: Hashable {
@Binding private var columns: Int
@Binding private var hideLoadMore: Bool
@State private var data: [[StatusModel]] = []
@Binding private var data: Data
private let dataId: KeyPath<Data.Element, ID>
private let content: (Data.Element) -> Content
@State private var columnsData: [[Data.Element]] = []
private let onLoadMore: () async -> Void
private let content: (StatusModel) -> Content
init(statusViewModel: Binding<[StatusModel]>,
columns: Binding<Int>,
hideLoadMore: Binding<Bool>,
content: @escaping (StatusModel) -> Content,
onLoadMore: @escaping () async -> Void) {
self._statusViewModels = statusViewModel
self._columns = columns
self._hideLoadMore = hideLoadMore
self.content = content
self.onLoadMore = onLoadMore
}
var body: some View {
HStack(alignment: .top, spacing: 20) {
ForEach(self.data, id: \.self) { array in
ForEach(self.columnsData, id: \.self) { array in
LazyVStack(spacing: 8) {
ForEach(array, id: \.id) { item in
self.content(item)
@ -50,7 +40,7 @@ struct WaterfallGrid<Content>: View where Content: View {
.onFirstAppear {
self.recalculateArrays()
}
.onChange(of: self.statusViewModels) { _ in
.onChange(of: self.data) { _ in
self.recalculateArrays()
}
.onChange(of: self.columns) { _ in
@ -59,25 +49,52 @@ struct WaterfallGrid<Content>: View where Content: View {
}
private func recalculateArrays() {
var internalArray: [[StatusModel]] = []
var internalArray: [[Data.Element]] = []
for _ in 0 ..< self.columns {
internalArray.append([])
}
for (index, item) in self.statusViewModels.enumerated() {
for (index, item) in self.data.enumerated() {
let arrayIndex = index % self.columns
internalArray[arrayIndex].append(item)
}
self.data = internalArray
self.columnsData = internalArray
}
private func shouldShowSpinner(array: [StatusModel]) -> Bool {
private func shouldShowSpinner(array: [Data.Element]) -> Bool {
if self.hideLoadMore {
return false
}
return self.data[1].first == array.first
return self.columnsData[1].first == array.first
}
}
extension WaterfallGrid {
init(_ data: Binding<Data>, id: KeyPath<Data.Element, ID>, columns: Binding<Int>,
hideLoadMore: Binding<Bool>, content: @escaping (Data.Element) -> Content, onLoadMore: @escaping () async -> Void) {
self._data = data
self.dataId = id
self.content = content
self._columns = columns
self._hideLoadMore = hideLoadMore
self.onLoadMore = onLoadMore
}
}
extension WaterfallGrid where ID == Data.Element.ID, Data.Element: Identifiable {
init(_ data: Binding<Data>, columns: Binding<Int>,
hideLoadMore: Binding<Bool>, content: @escaping (Data.Element) -> Content, onLoadMore: @escaping () async -> Void) {
self._data = data
self.dataId = \Data.Element.id
self.content = content
self._columns = columns
self._hideLoadMore = hideLoadMore
self.onLoadMore = onLoadMore
}
}

View File

@ -0,0 +1,18 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
import UIKit
public extension UIDevice {
static var isIPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
static var isIPhone: Bool {
UIDevice.current.userInterfaceIdiom == .phone
}
}