Add new gallery component.

This commit is contained in:
Marcin Czachurski 2023-05-25 17:33:04 +02:00
parent 446fbd9b9e
commit 085abcbea1
21 changed files with 500 additions and 212 deletions

View File

@ -8,7 +8,6 @@ import Foundation
import PixelfedKit
public class StatusModel: ObservableObject {
public let id: EntityId
public let content: Html
@ -107,6 +106,21 @@ public extension StatusModel {
}
}
extension StatusModel: Equatable {
public static func == (lhs: StatusModel, rhs: StatusModel) -> Bool {
lhs.id == rhs.id
}
}
extension StatusModel: Hashable {
public func hash(into hasher: inout Hasher) {
return hasher.combine(self.id)
}
}
extension StatusModel: Identifiable {
}
public extension [StatusModel] {
func getAllImagesUrls() -> [URL] {
var urls: [URL] = []

View File

@ -7,40 +7,53 @@
import Foundation
import SwiftUI
/// Service is storing orginal image sizes.
/// Very often images doesn't have size in metadataa (especially for services other then Pixelfed).
/// After download image from server we can check his size and remember in the cache.
///
/// When we want to prepare placeholder for specfic image and container witdh we have to use special method.
public class ImageSizeService {
public static let shared = ImageSizeService()
private init() { }
/// Cache with orginal image sizes.
private var memoryCacheData = MemoryCache<URL, CGSize>(entryLifetime: 3600)
public func get(for url: URL) -> CGSize? {
return self.memoryCacheData[url]
}
public func calculate(for url: URL, width: Int32, height: Int32) -> CGSize {
return calculate(for: url, width: Double(width), height: Double(height))
public func save(for url: URL, width: Int32, height: Int32) {
save(for: url, width: Double(width), height: Double(height))
}
public func calculate(for url: URL, width: Int, height: Int) -> CGSize {
return calculate(for: url, width: Double(width), height: Double(height))
public func save(for url: URL, width: Int, height: Int) {
save(for: url, width: Double(width), height: Double(height))
}
public func calculate(width: Double, height: Double) -> CGSize {
let divider = Double(width) / UIScreen.main.bounds.size.width
public func save(for url: URL, width: Double, height: Double) {
self.memoryCacheData.insert(CGSize(width: width, height: height), forKey: url)
}
}
extension ImageSizeService {
public func calculate(for url: URL, andContainerWidth containerWidth: Double) -> CGSize {
guard let size = self.get(for: url) else {
return CGSize(width: containerWidth, height: containerWidth)
}
return self.calculate(width: size.width, height: size.height, andContainerWidth: containerWidth)
}
public func calculate(width: Double, height: Double, andContainerWidth containerWidth: Double) -> CGSize {
let divider = Double(width) / containerWidth
let calculatedHeight = Double(height) / divider
let size = CGSize(
width: UIScreen.main.bounds.width,
height: (calculatedHeight > 0 && calculatedHeight < .infinity) ? calculatedHeight : UIScreen.main.bounds.width
width: containerWidth,
height: (calculatedHeight > 0 && calculatedHeight < .infinity) ? calculatedHeight : containerWidth
)
return size
}
public func calculate(for url: URL, width: Double, height: Double) -> CGSize {
let size = self.calculate(width: width, height: height)
self.memoryCacheData.insert(size, forKey: url)
return size
}
}

View File

@ -27,7 +27,6 @@
F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */; };
F825F0C929F7A562008BD204 /* UserProfilePrivateAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F825F0C829F7A562008BD204 /* UserProfilePrivateAccountView.swift */; };
F825F0CB29F7CFC4008BD204 /* FollowRequestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F825F0CA29F7CFC4008BD204 /* FollowRequestsView.swift */; };
F830C3CD2A07A4020005FEF8 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = F830C3CC2A07A4020005FEF8 /* WaterfallGrid */; };
F835082329BEF9C400DE3247 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F835082629BEF9C400DE3247 /* Localizable.strings */; };
F835082429BEF9C400DE3247 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F835082629BEF9C400DE3247 /* Localizable.strings */; };
F83CBEFB298298A1002972C8 /* ImageCarouselPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83CBEFA298298A1002972C8 /* ImageCarouselPicture.swift */; };
@ -90,6 +89,7 @@
F866F6A729604629002E8F88 /* SignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A629604629002E8F88 /* SignInView.swift */; };
F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A929605AFA002E8F88 /* SceneDelegate.swift */; };
F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6AD29606367002E8F88 /* ApplicationViewMode.swift */; };
F8675DD02A1FA40500A89959 /* WaterfallGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8675DCF2A1FA40500A89959 /* WaterfallGrid.swift */; };
F86A42FD299A8B8E00DF7645 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F86A42FC299A8B8E00DF7645 /* StoreKit.framework */; };
F86A42FF299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = F86A42FE299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit */; };
F86A4301299A97F500DF7645 /* ProductIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86A4300299A97F500DF7645 /* ProductIdentifiers.swift */; };
@ -191,7 +191,6 @@
F8B0885E29942E31002AB40A /* ThirdPartyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B0885D29942E31002AB40A /* ThirdPartyView.swift */; };
F8B0886029943498002AB40A /* OtherSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B0885F29943498002AB40A /* OtherSectionView.swift */; };
F8B08862299435C9002AB40A /* SupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B08861299435C9002AB40A /* SupportView.swift */; };
F8C287A32A06B4C90072213F /* ImageScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C287A22A06B4C90072213F /* ImageScale.swift */; };
F8D5444329D4066C002225D6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D5444229D4066C002225D6 /* AppDelegate.swift */; };
F8DF38E429DD68820047F1AA /* ViewOffsetKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */; };
F8DF38E629DDB98A0047F1AA /* SocialsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */; };
@ -303,6 +302,7 @@
F866F6A829604FFF002E8F88 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
F866F6A929605AFA002E8F88 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
F866F6AD29606367002E8F88 /* ApplicationViewMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationViewMode.swift; sourceTree = "<group>"; };
F8675DCF2A1FA40500A89959 /* WaterfallGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaterfallGrid.swift; sourceTree = "<group>"; };
F86A42FC299A8B8E00DF7645 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
F86A42FE299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = InAppPurchaseStoreKitConfiguration.storekit; sourceTree = "<group>"; };
F86A4300299A97F500DF7645 /* ProductIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductIdentifiers.swift; sourceTree = "<group>"; };
@ -392,7 +392,6 @@
F8B08861299435C9002AB40A /* SupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportView.swift; sourceTree = "<group>"; };
F8B3699A29D86EB600BE3808 /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = "<group>"; };
F8B3699B29D86EBD00BE3808 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = "<group>"; };
F8C287A22A06B4C90072213F /* ImageScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScale.swift; sourceTree = "<group>"; };
F8C937A929882CA90004D782 /* Vernissage-001.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage-001.xcdatamodel"; sourceTree = "<group>"; };
F8CAE64129B8F1AF001E0372 /* Vernissage-005.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage-005.xcdatamodel"; sourceTree = "<group>"; };
F8D5444229D4066C002225D6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@ -450,7 +449,6 @@
F8210DD92966BB7E001D9973 /* NukeUI in Frameworks */,
F89B5CC029D019B600549F2F /* HTMLString in Frameworks */,
F88BC52A29E046D700CE6141 /* WidgetsKit in Frameworks */,
F830C3CD2A07A4020005FEF8 /* WaterfallGrid in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -535,7 +533,6 @@
F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */,
F871F21C29EF0D7000A351EF /* NavigationMenuItemDetails.swift */,
F8624D3C29F2D3AC00204986 /* SelectedMenuItemDetails.swift */,
F8C287A22A06B4C90072213F /* ImageScale.swift */,
);
path = Models;
sourceTree = "<group>";
@ -592,6 +589,7 @@
F8AFF7C329B25EF40087D083 /* ImagesGrid.swift */,
F89D6C49297196FF001DA3D4 /* ImageViewer.swift */,
F870EE5129F1645C00A2D43B /* MainNavigationOptions.swift */,
F8675DCF2A1FA40500A89959 /* WaterfallGrid.swift */,
);
path = Widgets;
sourceTree = "<group>";
@ -990,7 +988,6 @@
F88BC52629E0431D00CE6141 /* ServicesKit */,
F88BC52929E046D700CE6141 /* WidgetsKit */,
F88BC52C29E04BB600CE6141 /* EnvironmentKit */,
F830C3CC2A07A4020005FEF8 /* WaterfallGrid */,
);
productName = Vernissage;
productReference = F88C2468295C37B80006098B /* Vernissage.app */;
@ -1034,7 +1031,6 @@
F88E4D4B297EA4290057491A /* XCRemoteSwiftPackageReference "EmojiText" */,
F89B5CBE29D019B600549F2F /* XCRemoteSwiftPackageReference "HTMLString" */,
F84625F929FE393B002D3AF4 /* XCRemoteSwiftPackageReference "QRCode" */,
F830C3CB2A07A4020005FEF8 /* XCRemoteSwiftPackageReference "WaterfallGrid" */,
);
productRefGroup = F88C2469295C37B80006098B /* Products */;
projectDirPath = "";
@ -1262,7 +1258,6 @@
F89B5CC229D01BF700549F2F /* InstanceView.swift in Sources */,
F825F0CB29F7CFC4008BD204 /* FollowRequestsView.swift in Sources */,
F825F0C929F7A562008BD204 /* UserProfilePrivateAccountView.swift in Sources */,
F8C287A32A06B4C90072213F /* ImageScale.swift in Sources */,
F89F57B029D1C11200001EE3 /* RelationshipModel.swift in Sources */,
F88AB05829B36B8200345EDE /* AccountsPhotoView.swift in Sources */,
F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */,
@ -1282,6 +1277,7 @@
F83CBEFB298298A1002972C8 /* ImageCarouselPicture.swift in Sources */,
F89A46DC296EAACE0062125F /* SettingsView.swift in Sources */,
F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */,
F8675DD02A1FA40500A89959 /* WaterfallGrid.swift in Sources */,
F85D4DFE29B78C8400345267 /* HashtagModel.swift in Sources */,
F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */,
);
@ -1679,14 +1675,6 @@
minimumVersion = 12.0.0;
};
};
F830C3CB2A07A4020005FEF8 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/paololeonardi/WaterfallGrid.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.0;
};
};
F84625F929FE393B002D3AF4 /* XCRemoteSwiftPackageReference "QRCode" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/dmrschmidt/QRCode";
@ -1729,11 +1717,6 @@
package = F8210DD32966BB7E001D9973 /* XCRemoteSwiftPackageReference "Nuke" */;
productName = NukeUI;
};
F830C3CC2A07A4020005FEF8 /* WaterfallGrid */ = {
isa = XCSwiftPackageProductDependency;
package = F830C3CB2A07A4020005FEF8 /* XCRemoteSwiftPackageReference "WaterfallGrid" */;
productName = WaterfallGrid;
};
F84625FA29FE393B002D3AF4 /* QRCode */ = {
isa = XCSwiftPackageProductDependency;
package = F84625F929FE393B002D3AF4 /* XCRemoteSwiftPackageReference "QRCode" */;

View File

@ -1,12 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
enum ImageScale {
case orginalFullWidth
case squareHalfWidth
}

View File

@ -19,6 +19,7 @@ public class HomeTimelineService {
private let defaultAmountOfDownloadedStatuses = 40
private let imagePrefetcher = ImagePrefetcher(destination: .diskCache)
@MainActor
public func loadOnBottom(for account: AccountModel) async throws -> Int {
// Load data from API and operate on CoreData on background context.
let backgroundContext = CoreDataHandler.shared.newBackgroundContext()
@ -43,6 +44,7 @@ public class HomeTimelineService {
return allStatusesFromApi.count
}
@MainActor
public func refreshTimeline(for account: AccountModel) async throws -> String? {
// Load data from API and operate on CoreData on background context.
let backgroundContext = CoreDataHandler.shared.newBackgroundContext()

View File

@ -21,6 +21,11 @@ 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) {
@ -55,32 +60,49 @@ struct HomeFeedView: View {
private func timeline() -> some View {
ZStack {
ScrollView {
LazyVStack {
ForEach(dbStatuses, id: \.self) { item in
if self.shouldUpToDateBeVisible(statusId: item.id) {
self.upToDatePlaceholder()
}
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)
}
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)
}
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)
}
}
}
}
}
}
.gallery { galleryProperties in
self.imageColumns = galleryProperties.imageColumns
self.containerWidth = galleryProperties.containerWidth
self.containerHeight = galleryProperties.containerHeight
}
self.newPhotosView()
.offset(y: self.offset)

View File

@ -77,7 +77,7 @@ struct PaginableStatusesView: View {
ScrollView {
LazyVStack(alignment: .center) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item)
ImageRowAsync(statusViewModel: item, containerWidth: Binding.constant(UIScreen.main.bounds.width))
}
if allItemsLoaded == false {

View File

@ -240,7 +240,9 @@ struct StatusView: View {
}
if let imageHeight = self.imageHeight, let imageWidth = self.imageWidth, imageHeight > 0 && imageWidth > 0 {
let calculatedSize = ImageSizeService.shared.calculate(width: Double(imageWidth), height: Double(imageHeight))
let calculatedSize = ImageSizeService.shared.calculate(width: Double(imageWidth),
height: Double(imageHeight),
andContainerWidth: UIScreen.main.bounds.size.width)
return calculatedSize.height
}

View File

@ -11,7 +11,6 @@ import ClientKit
import ServicesKit
import EnvironmentKit
import WidgetsKit
import WaterfallGrid
struct StatusesView: View {
public enum ListType: Hashable {
@ -51,6 +50,11 @@ struct StatusesView: View {
@State private var state: ViewState = .loading
@State private var lastStatusId: String?
// 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 = 40
private let imagePrefetcher = ImagePrefetcher(destination: .diskCache)
@ -89,35 +93,43 @@ struct StatusesView: View {
@ViewBuilder
private func list() -> some View {
ScrollView {
WaterfallGrid(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item,
withAvatar: true,
imageScale: self.applicationState.showGridOnUserProfile ? .squareHalfWidth : .orginalFullWidth)
.padding(.top, -2)
if self.imageColumns > 1 {
WaterfallGrid(statusViewModel: $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)
}
}
Spacer()
}
}
}
}
.gridStyle(columns: 3, spacing: 4)
// .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
// LazyVStack(alignment: .center) {
// ForEach(self.statusViewModels, id: \.id) { item in
// ImageRowAsync(statusViewModel: item)
// }
//
// 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()
// }
// }
// }
}
.gallery { galleryProperties in
self.imageColumns = galleryProperties.imageColumns
self.containerWidth = galleryProperties.containerWidth
self.containerHeight = galleryProperties.containerHeight
}
.refreshable {
do {

View File

@ -72,7 +72,7 @@ struct TrendStatusesView: View {
} else {
LazyVStack(alignment: .center) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item)
ImageRowAsync(statusViewModel: item, containerWidth: Binding.constant(UIScreen.main.bounds.width))
}
}
.refreshable {

View File

@ -11,7 +11,6 @@ import ClientKit
import ServicesKit
import EnvironmentKit
import WidgetsKit
import WaterfallGrid
struct UserProfileStatusesView: View {
@EnvironmentObject private var applicationState: ApplicationState
@ -19,6 +18,11 @@ struct UserProfileStatusesView: View {
@State public var accountId: String
// Gallery parameters.
@Binding private var imageColumns: Int
@Binding private var containerWidth: Double
@Binding private var containerHeight: Double
@State private var allItemsLoaded = false
@State private var firstLoadFinished = false
@State private var statusViewModels: [StatusModel] = []
@ -28,72 +32,76 @@ struct UserProfileStatusesView: View {
private let singleGrids = [GridItem(.flexible(), spacing: 10)]
private let dubleGrid = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 0)]
init(accountId: String, imageColumns: Binding<Int>, containerWidth: Binding<Double>, containerHeight: Binding<Double>) {
self.accountId = accountId
self._imageColumns = imageColumns
self._containerWidth = containerWidth
self._containerHeight = containerHeight
}
var body: some View {
if firstLoadFinished == true {
HStack {
Spacer()
Button {
withAnimation {
self.applicationState.showGridOnUserProfile = false
ApplicationSettingsHandler.shared.set(showGridOnUserProfile: false)
if self.imageColumns > 1 {
WaterfallGrid(statusViewModel: $statusViewModels, columns: $imageColumns, hideLoadMore: $allItemsLoaded) { item in
ImageRowAsync(statusViewModel: item, withAvatar: false, containerWidth: $containerWidth)
} onLoadMore: {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
}
} label: {
Image(systemName: "rectangle.grid.1x2.fill")
.foregroundColor(self.applicationState.showGridOnUserProfile ? .lightGrayColor : .accentColor)
.padding(.trailing, 8)
.padding(.bottom, 8)
}
Button {
withAnimation {
self.applicationState.showGridOnUserProfile = true
ApplicationSettingsHandler.shared.set(showGridOnUserProfile: true)
}
} label: {
Image(systemName: "rectangle.grid.2x2.fill")
.foregroundColor(self.applicationState.showGridOnUserProfile ? .accentColor : .lightGrayColor)
.padding(.trailing, 16)
.padding(.bottom, 8)
}
}
WaterfallGrid(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item,
withAvatar: false,
imageScale: self.applicationState.showGridOnUserProfile ? .squareHalfWidth : .orginalFullWidth)
// .if(self.applicationState.showGridOnUserProfile) {
// $0.frame(width: UIScreen.main.bounds.width / 2, height: UIScreen.main.bounds.width / 2)
// }
}
.gridStyle(columns: 3, spacing: 2)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
/*
LazyVGrid(columns: self.applicationState.showGridOnUserProfile ? dubleGrid : singleGrids, spacing: 5) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item,
withAvatar: false,
imageScale: self.applicationState.showGridOnUserProfile ? .squareHalfWidth : .orginalFullWidth)
.if(self.applicationState.showGridOnUserProfile) {
$0.frame(width: UIScreen.main.bounds.width / 2, height: UIScreen.main.bounds.width / 2)
} else {
HStack {
Spacer()
Button {
withAnimation {
self.applicationState.showGridOnUserProfile = false
ApplicationSettingsHandler.shared.set(showGridOnUserProfile: false)
}
} label: {
Image(systemName: "rectangle.grid.1x2.fill")
.foregroundColor(self.applicationState.showGridOnUserProfile ? .lightGrayColor : .accentColor)
.padding(.trailing, 8)
.padding(.bottom, 8)
}
Button {
withAnimation {
self.applicationState.showGridOnUserProfile = true
ApplicationSettingsHandler.shared.set(showGridOnUserProfile: true)
}
} label: {
Image(systemName: "rectangle.grid.2x2.fill")
.foregroundColor(self.applicationState.showGridOnUserProfile ? .accentColor : .lightGrayColor)
.padding(.trailing, 16)
.padding(.bottom, 8)
}
}
if allItemsLoaded == false && firstLoadFinished == true {
HStack {
Spacer()
LoadingIndicator()
.task {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: true)
LazyVGrid(columns: self.applicationState.showGridOnUserProfile ? dubleGrid : singleGrids, spacing: 5) {
ForEach(self.statusViewModels, id: \.id) { item in
ImageRowAsync(statusViewModel: item,
withAvatar: false,
containerWidth: Binding.constant(self.applicationState.showGridOnUserProfile ? self.containerWidth / 2 : self.containerWidth),
clipToRectangle: $applicationState.showGridOnUserProfile)
}
if allItemsLoaded == false && firstLoadFinished == true {
HStack {
Spacer()
LoadingIndicator()
.task {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: true)
}
}
}
Spacer()
Spacer()
}
}
}
}
*/
} else {
LoadingIndicator()
.onFirstAppear {

View File

@ -27,6 +27,11 @@ struct UserProfileView: View {
@State private var state: ViewState = .loading
@State private var viewId = UUID().uuidString
// 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 {
self.mainBody()
.navigationTitle(self.accountDisplayName ?? self.accountUserName)
@ -68,11 +73,16 @@ struct UserProfileView: View {
.id(self.viewId)
if self.applicationState.account?.id == account.id || self.relationship.haveAccessToPhotos(account: account) {
UserProfileStatusesView(accountId: account.id)
UserProfileStatusesView(accountId: account.id, imageColumns: $imageColumns, containerWidth: $containerWidth, containerHeight: $containerHeight)
} else {
UserProfilePrivateAccountView()
}
}
.gallery { galleryProperties in
self.imageColumns = galleryProperties.imageColumns
self.containerWidth = galleryProperties.containerWidth
self.containerHeight = galleryProperties.containerHeight
}
.onAppear {
if let updatedProfile = self.applicationState.updatedProfile {
self.account = nil

View File

@ -25,12 +25,15 @@ struct ImageRow: View {
// Calculate size of frame (first from cache, then from real image, then from metadata).
if let firstAttachment, let size = ImageSizeService.shared.get(for: firstAttachment.url) {
self.imageWidth = size.width
self.imageHeight = size.height
let calculatedSize = ImageSizeService.shared.calculate(width: size.width, height: size.height, andContainerWidth: UIScreen.main.bounds.size.width)
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
} else if let firstAttachment, firstAttachment.metaImageWidth > 0 && firstAttachment.metaImageHeight > 0 {
let size = ImageSizeService.shared.calculate(for: firstAttachment.url,
width: firstAttachment.metaImageWidth,
height: firstAttachment.metaImageHeight)
ImageSizeService.shared.save(for: firstAttachment.url,
width: firstAttachment.metaImageWidth,
height: firstAttachment.metaImageHeight)
let size = ImageSizeService.shared.calculate(for: firstAttachment.url, andContainerWidth: UIScreen.main.bounds.size.width)
self.imageWidth = size.width
self.imageHeight = size.height
} else {
@ -73,7 +76,9 @@ struct ImageRow: View {
}
.onChange(of: selected, perform: { attachmentId in
if let attachment = attachmentsData.first(where: { item in item.id == attachmentId }) {
let size = ImageSizeService.shared.calculate(width: Double(attachment.metaImageWidth), height: Double(attachment.metaImageHeight))
let size = ImageSizeService.shared.calculate(width: Double(attachment.metaImageWidth),
height: Double(attachment.metaImageHeight),
andContainerWidth: UIScreen.main.bounds.size.width)
if size.width != self.imageWidth || size.height != self.imageHeight {
withAnimation(.linear(duration: 0.4)) {

View File

@ -9,38 +9,50 @@ import PixelfedKit
import ClientKit
import ServicesKit
import WidgetsKit
import EnvironmentKit
struct ImageRowAsync: View {
private let statusViewModel: StatusModel
private let firstAttachment: AttachmentModel?
private let showAvatar: Bool
private let imageScale: ImageScale
@Binding private var containerWidth: Double
@Binding private var clipToRectangle: Bool
@State private var selected: String
@State private var imageHeight: Double
@State private var imageWidth: Double
init(statusViewModel: StatusModel, withAvatar showAvatar: Bool = true, imageScale: ImageScale = .orginalFullWidth) {
init(statusViewModel: StatusModel,
withAvatar showAvatar: Bool = true,
containerWidth: Binding<Double>,
clipToRectangle: Binding<Bool> = Binding.constant(false)) {
self.showAvatar = showAvatar
self.imageScale = imageScale
self.statusViewModel = statusViewModel
self.firstAttachment = statusViewModel.mediaAttachments.first
self.selected = String.empty()
self._containerWidth = containerWidth
self._clipToRectangle = clipToRectangle
// Calculate size of frame (first from cache, then from metadata).
if let firstAttachment, let size = ImageSizeService.shared.get(for: firstAttachment.url) {
self.imageWidth = size.width
self.imageHeight = size.height
let calculatedSize = ImageSizeService.shared.calculate(width: size.width, height: size.height, andContainerWidth: containerWidth.wrappedValue)
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
} else if let firstAttachment,
let imgHeight = (firstAttachment.meta as? ImageMetadata)?.original?.height,
let imgWidth = (firstAttachment.meta as? ImageMetadata)?.original?.width {
let size = ImageSizeService.shared.calculate(for: firstAttachment.url, width: imgWidth, height: imgHeight)
self.imageWidth = size.width
self.imageHeight = size.height
ImageSizeService.shared.save(for: firstAttachment.url, width: imgWidth, height: imgHeight)
let calculatedSize = ImageSizeService.shared.calculate(for: firstAttachment.url, andContainerWidth: containerWidth.wrappedValue)
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
} else {
self.imageWidth = UIScreen.main.bounds.width
self.imageHeight = UIScreen.main.bounds.width
self.imageWidth = containerWidth.wrappedValue
self.imageHeight = containerWidth.wrappedValue
}
}
@ -49,18 +61,28 @@ struct ImageRowAsync: View {
ImageRowItemAsync(statusViewModel: self.statusViewModel,
attachment: firstAttachment,
withAvatar: self.showAvatar,
imageScale: self.imageScale) { (imageWidth, imageHeight) in
containerWidth: $containerWidth,
clipToRectangle: $clipToRectangle,
showSpoilerText: Binding.constant(self.containerWidth > 300)) { (imageWidth, imageHeight) in
// When we download image and calculate real size we have to change view size.
if imageWidth != self.imageWidth || imageHeight != self.imageHeight {
let calculatedSize = ImageSizeService.shared.calculate(width: imageWidth, height: imageHeight, andContainerWidth: self.containerWidth)
if calculatedSize.width != self.imageWidth || calculatedSize.height != self.imageHeight {
withAnimation(.linear(duration: 0.4)) {
self.imageWidth = imageWidth
self.imageHeight = imageHeight
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
}
}
}
.if(self.imageScale == .squareHalfWidth) {
$0.frame(width: self.imageWidth / 3, height: self.imageHeight / 3)
.frame(width: self.clipToRectangle ? self.containerWidth : self.imageWidth,
height: self.clipToRectangle ? self.containerWidth : self.imageHeight)
.onChange(of: self.containerWidth) { newContainerWidth in
let calculatedSize = ImageSizeService.shared.calculate(width: self.imageWidth,
height: self.imageHeight,
andContainerWidth: newContainerWidth)
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
}
} else {
TabView(selection: $selected) {
@ -68,14 +90,18 @@ struct ImageRowAsync: View {
ImageRowItemAsync(statusViewModel: self.statusViewModel,
attachment: attachment,
withAvatar: self.showAvatar,
imageScale: self.imageScale) { (imageWidth, imageHeight) in
containerWidth: $containerWidth,
clipToRectangle: $clipToRectangle,
showSpoilerText: Binding.constant(self.containerWidth > 300)) { (imageWidth, imageHeight) in
// When we download image and calculate real size we have to change view size (only when image is now visible).
let calculatedSize = ImageSizeService.shared.calculate(width: imageWidth, height: imageHeight, andContainerWidth: self.containerWidth)
if attachment.id == self.selected {
if imageWidth != self.imageWidth || imageHeight != self.imageHeight {
if calculatedSize.width != self.imageWidth || calculatedSize.height != self.imageHeight {
withAnimation(.linear(duration: 0.4)) {
self.imageWidth = imageWidth
self.imageHeight = imageHeight
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
}
}
}
@ -83,24 +109,34 @@ struct ImageRowAsync: View {
.tag(attachment.id)
}
}
.onChange(of: self.containerWidth) { newContainerWidth in
let calculatedSize = ImageSizeService.shared.calculate(width: self.imageWidth,
height: self.imageHeight,
andContainerWidth: newContainerWidth)
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
}
.onFirstAppear {
self.selected = self.statusViewModel.mediaAttachments.first?.id ?? String.empty()
}
.onChange(of: selected, perform: { attachmentId in
if let attachment = self.statusViewModel.mediaAttachments.first(where: { item in item.id == attachmentId }) {
if let size = ImageSizeService.shared.get(for: attachment.url) {
if size.width != self.imageWidth || size.height != self.imageHeight {
let calculatedSize = ImageSizeService.shared.calculate(width: size.width,
height: size.height,
andContainerWidth: self.containerWidth)
if calculatedSize.width != self.imageWidth || calculatedSize.height != self.imageHeight {
withAnimation(.linear(duration: 0.4)) {
self.imageWidth = size.width
self.imageHeight = size.height
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
}
}
}
}
})
.if(self.imageScale == .squareHalfWidth) {
$0.frame(width: self.imageWidth / 3, height: self.imageHeight / 3)
}
.frame(width: self.clipToRectangle ? self.containerWidth : self.imageWidth,
height: self.clipToRectangle ? self.containerWidth : self.imageHeight)
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(CustomPageTabViewStyleView(pages: self.statusViewModel.mediaAttachments, currentId: $selected))
}

View File

@ -210,14 +210,18 @@ struct ImageRowItem: View {
}
private func setVariables(imageData: Data, downloadedImage: UIImage) {
let size = ImageSizeService.shared.calculate(for: attachmentData.url,
width: downloadedImage.size.width,
height: downloadedImage.size.height)
ImageSizeService.shared.save(for: attachmentData.url,
width: downloadedImage.size.width,
height: downloadedImage.size.height)
let size = ImageSizeService.shared.calculate(for: attachmentData.url, andContainerWidth: UIScreen.main.bounds.size.width)
self.onImageDownloaded(size.width, size.height)
self.uiImage = downloadedImage
HomeTimelineService.shared.update(attachment: attachmentData, withData: imageData, imageWidth: size.width, imageHeight: size.height)
self.uiImage = downloadedImage
HomeTimelineService.shared.update(attachment: attachmentData,
withData: imageData,
imageWidth: downloadedImage.size.width,
imageHeight: downloadedImage.size.height)
self.error = nil
self.cancelled = false
}

View File

@ -22,7 +22,10 @@ struct ImageRowItemAsync: View {
private var attachment: AttachmentModel
private let showAvatar: Bool
private let imageFromCache: Bool
private let imageScale: ImageScale
@Binding private var containerWidth: Double
@Binding private var showSpoilerText: Bool
@Binding private var clipToRectangle: Bool
@State private var showThumbImage = false
@State private var opacity = 1.0
@ -33,14 +36,19 @@ struct ImageRowItemAsync: View {
init(statusViewModel: StatusModel,
attachment: AttachmentModel,
withAvatar showAvatar: Bool = true,
imageScale: ImageScale = .orginalFullWidth,
containerWidth: Binding<Double>,
clipToRectangle: Binding<Bool> = Binding.constant(false),
showSpoilerText: Binding<Bool> = Binding.constant(true),
onImageDownloaded: @escaping (_: Double, _: Double) -> Void) {
self.showAvatar = showAvatar
self.imageScale = imageScale
self.statusViewModel = statusViewModel
self.attachment = attachment
self.onImageDownloaded = onImageDownloaded
self._containerWidth = containerWidth
self._showSpoilerText = showSpoilerText
self._clipToRectangle = clipToRectangle
self.imageFromCache = ImagePipeline.shared.cache.containsCachedImage(for: ImageRequest(url: attachment.url))
}
@ -49,7 +57,7 @@ struct ImageRowItemAsync: View {
if let image = state.image {
if self.statusViewModel.sensitive && !self.applicationState.showSensitive {
ZStack {
ContentWarning(spoilerText: self.imageScale == .orginalFullWidth ? self.statusViewModel.spoilerText : nil) {
ContentWarning(spoilerText: self.showSpoilerText ? self.statusViewModel.spoilerText : nil) {
self.imageContainerView(image: image)
.imageContextMenu(statusModel: self.statusViewModel,
attachmentModel: self.attachment,
@ -155,11 +163,10 @@ struct ImageRowItemAsync: View {
private func imageView(image: Image) -> some View {
image
.resizable()
//.aspectRatio(contentMode: .fill)
.aspectRatio(contentMode: .fit)
// .if(self.imageScale == .squareHalfWidth) {
// $0.frame(width: UIScreen.main.bounds.width / 4, height: UIScreen.main.bounds.width / 4).clipped()
// }
.aspectRatio(contentMode: self.clipToRectangle ? .fill : .fit)
.if(self.clipToRectangle) {
$0.frame(width: self.containerWidth, height: self.containerWidth).clipped()
}
.onTapGesture(count: 2) {
Task {
// Update favourite in Pixelfed server.
@ -195,10 +202,11 @@ struct ImageRowItemAsync: View {
}
private func recalculateSizeOfDownloadedImage(uiImage: UIImage) {
let size = ImageSizeService.shared.calculate(for: attachment.url,
width: uiImage.size.width,
height: uiImage.size.height)
ImageSizeService.shared.save(for: attachment.url,
width: uiImage.size.width,
height: uiImage.size.height)
let size = ImageSizeService.shared.calculate(for: attachment.url, andContainerWidth: UIScreen.main.bounds.size.width)
self.onImageDownloaded(size.width, size.height)
}
}

View File

@ -224,7 +224,9 @@ struct ImageViewer: View {
private func calculateStartingOffset() -> CGSize {
// Image size on the screen.
let calculatedSize = ImageSizeService.shared.calculate(width: self.imageWidth, height: self.imageHeight)
let calculatedSize = ImageSizeService.shared.calculate(width: self.imageWidth,
height: self.imageHeight,
andContainerWidth: UIScreen.main.bounds.size.width)
let imageOnScreenHeight = calculatedSize.height
// Calculate full space for image.

View File

@ -52,7 +52,8 @@ struct ImagesCarousel: View {
self.heightWasPrecalculated = true
} else if let highestImage, imgHeight > 0 && imgWidth > 0 {
let size = ImageSizeService.shared.calculate(for: highestImage.url, width: imgWidth, height: imgHeight)
ImageSizeService.shared.save(for: highestImage.url, width: imgWidth, height: imgHeight)
let size = ImageSizeService.shared.calculate(for: highestImage.url, andContainerWidth: UIScreen.main.bounds.size.width)
self.imageWidth = size.width
self.imageHeight = size.height
@ -107,6 +108,8 @@ struct ImagesCarousel: View {
for item in attachments {
if let data = item.data, let image = UIImage(data: data) {
ImageSizeService.shared.save(for: attachment.url, width: image.size.width, height: image.size.height)
if image.size.height > imageHeight {
imageHeight = image.size.height
imageWidth = image.size.width
@ -121,7 +124,7 @@ struct ImagesCarousel: View {
}
}
let size = ImageSizeService.shared.calculate(for: attachment.url, width: imageWidth, height: imageHeight)
let size = ImageSizeService.shared.calculate(for: attachment.url, andContainerWidth: UIScreen.main.bounds.size.width)
self.imageWidth = size.width
self.imageHeight = size.height
}

View File

@ -0,0 +1,83 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import SwiftUI
import WidgetsKit
import ClientKit
struct WaterfallGrid<Content>: View where Content: View {
@Binding private var statusViewModels: [StatusModel]
@Binding private var columns: Int
@Binding private var hideLoadMore: Bool
@State private var data: [[StatusModel]] = []
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
LazyVStack(spacing: 8) {
ForEach(array, id: \.id) { item in
self.content(item)
}
if self.shouldShowSpinner(array: array) {
LoadingIndicator()
.task {
await self.onLoadMore()
}
}
}
}
}
.onFirstAppear {
self.recalculateArrays()
}
.onChange(of: self.statusViewModels) { _ in
self.recalculateArrays()
}
.onChange(of: self.columns) { _ in
self.recalculateArrays()
}
}
private func recalculateArrays() {
var internalArray: [[StatusModel]] = []
for _ in 0 ..< self.columns {
internalArray.append([])
}
for (index, item) in self.statusViewModels.enumerated() {
let arrayIndex = index % self.columns
internalArray[arrayIndex].append(item)
}
self.data = internalArray
}
private func shouldShowSpinner(array: [StatusModel]) -> Bool {
if self.hideLoadMore {
return false
}
return self.data[1].first == array.first
}
}

View File

@ -14,3 +14,11 @@ public extension View {
}
}
}
public extension ViewModifier {
func asyncAfter(_ time: Double, operation: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + time) {
operation()
}
}
}

View File

@ -0,0 +1,85 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
import SwiftUI
struct DeviceRotationViewModifier: ViewModifier {
let action: (UIDeviceOrientation) -> Void
func body(content: Content) -> some View {
content
.onAppear()
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
action(UIDevice.current.orientation)
}
}
}
public extension View {
func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View {
self.modifier(DeviceRotationViewModifier(action: action))
}
}
public struct GalleryProperties {
public let imageColumns: Int
public let containerWidth: Double
public let containerHeight: Double
}
struct DeviceImageGallery: ViewModifier {
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
let action: (GalleryProperties) -> Void
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.onRotate { _ in
asyncAfter(0.1) {
let galleryProperties = self.getGalleryProperties(geometry: geometry, horizontalSize: self.horizontalSizeClass ?? .compact)
self.action(galleryProperties)
}
}
.onChange(of: self.horizontalSizeClass) { horizontalSize in
asyncAfter(0.1) {
let galleryProperties = self.getGalleryProperties(geometry: geometry, horizontalSize: horizontalSize ?? .compact)
self.action(galleryProperties)
}
}
.onAppear {
asyncAfter(0.1) {
let galleryProperties = self.getGalleryProperties(geometry: geometry, horizontalSize: self.horizontalSizeClass ?? .compact)
self.action(galleryProperties)
}
}
}
}
private func getGalleryProperties(geometry: GeometryProxy, horizontalSize: UserInterfaceSizeClass) -> GalleryProperties {
if horizontalSize == .compact {
// View like on iPhone.
return GalleryProperties(imageColumns: 1,
containerWidth: geometry.size.width,
containerHeight: geometry.size.height)
} else {
// View like on iPad.
let imageColumns = geometry.size.width > geometry.size.height ? 3 : 2
print("\(geometry.size.width ):\(geometry.size.height)")
return GalleryProperties(imageColumns: imageColumns,
containerWidth: geometry.size.width / Double(imageColumns),
containerHeight: geometry.size.height / Double(imageColumns))
}
}
}
public extension View {
func gallery(perform action: @escaping (GalleryProperties) -> Void) -> some View {
self.modifier(DeviceImageGallery(action: action))
}
}