From d3aa3b7099f83881a245be167331c980ed47107d Mon Sep 17 00:00:00 2001 From: Marcin Czachurski Date: Fri, 26 May 2023 16:06:38 +0200 Subject: [PATCH] Timelines with waterfall --- .../Sources/ClientKit/Client+Timeline.swift | 7 ++ Vernissage/Views/HomeFeedView.swift | 60 ++++++----------- Vernissage/Views/MainView.swift | 9 ++- Vernissage/Views/PaginableStatusesView.swift | 50 ++++++++++---- Vernissage/Views/StatusesView.swift | 12 +++- Vernissage/Views/TrendStatusesView.swift | 40 ++++++++---- .../Subviews/UserProfileStatusesView.swift | 2 +- Vernissage/Widgets/WaterfallGrid.swift | 65 ++++++++++++------- .../Extensions/UIDevice+Device.swift | 18 +++++ 9 files changed, 168 insertions(+), 95 deletions(-) create mode 100644 WidgetsKit/Sources/WidgetsKit/Extensions/UIDevice+Device.swift diff --git a/ClientKit/Sources/ClientKit/Client+Timeline.swift b/ClientKit/Sources/ClientKit/Client+Timeline.swift index a2ac89f..41b1383 100644 --- a/ClientKit/Sources/ClientKit/Client+Timeline.swift +++ b/ClientKit/Sources/ClientKit/Client+Timeline.swift @@ -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, diff --git a/Vernissage/Views/HomeFeedView.swift b/Vernissage/Views/HomeFeedView.swift index 6d008c8..1a3e11a 100644 --- a/Vernissage/Views/HomeFeedView.swift +++ b/Vernissage/Views/HomeFeedView.swift @@ -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 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) diff --git a/Vernissage/Views/MainView.swift b/Vernissage/Views/MainView.swift index 6662783..1961456 100644 --- a/Vernissage/Views/MainView.swift +++ b/Vernissage/Views/MainView.swift @@ -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()) diff --git a/Vernissage/Views/PaginableStatusesView.swift b/Vernissage/Views/PaginableStatusesView.swift index 8e03589..6e6e29e 100644 --- a/Vernissage/Views/PaginableStatusesView.swift +++ b/Vernissage/Views/PaginableStatusesView.swift @@ -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 { diff --git a/Vernissage/Views/StatusesView.swift b/Vernissage/Views/StatusesView.swift index bf0c93f..25e5674 100644 --- a/Vernissage/Views/StatusesView.swift +++ b/Vernissage/Views/StatusesView.swift @@ -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, diff --git a/Vernissage/Views/TrendStatusesView.swift b/Vernissage/Views/TrendStatusesView.swift index 1f66449..a0ccc9d 100644 --- a/Vernissage/Views/TrendStatusesView.swift +++ b/Vernissage/Views/TrendStatusesView.swift @@ -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)) + } } } } diff --git a/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift b/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift index 2e8dafb..05286c8 100644 --- a/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift +++ b/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift @@ -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 { diff --git a/Vernissage/Widgets/WaterfallGrid.swift b/Vernissage/Widgets/WaterfallGrid.swift index c5c185a..9d622ca 100644 --- a/Vernissage/Widgets/WaterfallGrid.swift +++ b/Vernissage/Widgets/WaterfallGrid.swift @@ -6,33 +6,23 @@ import SwiftUI import WidgetsKit -import ClientKit -struct WaterfallGrid: View where Content: View { - @Binding private var statusViewModels: [StatusModel] +struct WaterfallGrid: 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 + 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, - hideLoadMore: Binding, - 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: 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: 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, id: KeyPath, columns: Binding, + hideLoadMore: Binding, 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, columns: Binding, + hideLoadMore: Binding, 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 } } diff --git a/WidgetsKit/Sources/WidgetsKit/Extensions/UIDevice+Device.swift b/WidgetsKit/Sources/WidgetsKit/Extensions/UIDevice+Device.swift new file mode 100644 index 0000000..1704cbf --- /dev/null +++ b/WidgetsKit/Sources/WidgetsKit/Extensions/UIDevice+Device.swift @@ -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 + } +}