From ca7acc17daabaa60bdfaba5b23221c5b7978faee Mon Sep 17 00:00:00 2001 From: Fabian Thies Date: Sun, 27 Nov 2022 00:26:15 +0100 Subject: [PATCH] [ADD] iOS 13 compatible AsyncImage implementation with cache --- AltStore.xcodeproj/project.pbxproj | 33 +++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++++ AltStore/View Components/AppIconView.swift | 7 +-- AltStore/Views/App Detail/AppDetailView.swift | 47 +++++------------- .../App Detail/AppScreenshotsScrollView.swift | 49 +++++++++++++++++++ .../Views/Browse/BrowseAppPreviewView.swift | 18 +++---- AltStore/Views/News/NewsItemView.swift | 21 ++++---- AltStore/Views/Settings/SettingsView.swift | 19 +++++++ 8 files changed, 143 insertions(+), 60 deletions(-) create mode 100644 AltStore/Views/App Detail/AppScreenshotsScrollView.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 22f0fdd3..f0950b77 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ 191E607E290B2EA7001A3B7C /* jplist.c in Sources */ = {isa = PBXBuildFile; fileRef = 191E5FCF290A651D001A3B7C /* jplist.c */; }; 1920B04F2924AC8300744F60 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 1920B04E2924AC8300744F60 /* Settings.bundle */; }; 19B9B7452845E6DF0076EF69 /* SelectTeamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19B9B7442845E6DF0076EF69 /* SelectTeamViewController.swift */; }; + 1F0DD810293222DF007608A4 /* AsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 1F0DD80F293222DF007608A4 /* AsyncImage */; }; + 1F0DD81329322487007608A4 /* LazyScrollingVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DD81229322487007608A4 /* LazyScrollingVStack.swift */; }; + 1F0DD81C2932D2FF007608A4 /* AppScreenshotsScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DD81B2932D2FF007608A4 /* AppScreenshotsScrollView.swift */; }; 1F6E08DA292806E0005059C0 /* AppRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6E08D9292806E0005059C0 /* AppRowView.swift */; }; 1F6E08DC292807D3005059C0 /* AppIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6E08DB292807D3005059C0 /* AppIconView.swift */; }; 1F6E08E029280B12005059C0 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6E08DF29280B12005059C0 /* SafariView.swift */; }; @@ -537,6 +540,8 @@ 191E5FD1290A651D001A3B7C /* jsmn.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = jsmn.h; path = Dependencies/libplist/src/jsmn.h; sourceTree = SOURCE_ROOT; }; 1920B04E2924AC8300744F60 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 19B9B7442845E6DF0076EF69 /* SelectTeamViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectTeamViewController.swift; sourceTree = ""; }; + 1F0DD81229322487007608A4 /* LazyScrollingVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyScrollingVStack.swift; sourceTree = ""; }; + 1F0DD81B2932D2FF007608A4 /* AppScreenshotsScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotsScrollView.swift; sourceTree = ""; }; 1F6E08D9292806E0005059C0 /* AppRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRowView.swift; sourceTree = ""; }; 1F6E08DB292807D3005059C0 /* AppIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconView.swift; sourceTree = ""; }; 1F6E08DF29280B12005059C0 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; @@ -979,6 +984,7 @@ D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */, 4879A9622861049C00FC1BBD /* OpenSSL in Frameworks */, B3C395F4284F35DD00DA9E2F /* Nuke in Frameworks */, + 1F0DD810293222DF007608A4 /* AsyncImage in Frameworks */, BF1614F1250822F100767AEA /* Roxas.framework in Frameworks */, B3C395F7284F362400DA9E2F /* AppCenterAnalytics in Frameworks */, BF66EE852501AE50007EE018 /* AltStoreCore.framework in Frameworks */, @@ -1024,6 +1030,14 @@ path = "libimobiledevice-glue/src"; sourceTree = ""; }; + 1F0DD81129322472007608A4 /* Layout */ = { + isa = PBXGroup; + children = ( + 1F0DD81229322487007608A4 /* LazyScrollingVStack.swift */, + ); + path = Layout; + sourceTree = ""; + }; 1F6E08DD29280AF1005059C0 /* View Extensions */ = { isa = PBXGroup; children = ( @@ -1089,6 +1103,7 @@ isa = PBXGroup; children = ( 1FB84BA52928DE08006A5CF4 /* AppDetailView.swift */, + 1F0DD81B2932D2FF007608A4 /* AppScreenshotsScrollView.swift */, ); path = "App Detail"; sourceTree = ""; @@ -1129,6 +1144,7 @@ 1FB84BA72928E073006A5CF4 /* View Components */ = { isa = PBXGroup; children = ( + 1F0DD81129322472007608A4 /* Layout */, 1F6E08D9292806E0005059C0 /* AppRowView.swift */, 1F6E08DB292807D3005059C0 /* AppIconView.swift */, 1F6E08E529280F4B005059C0 /* RatingStars.swift */, @@ -2195,6 +2211,7 @@ 4879A9612861049C00FC1BBD /* OpenSSL */, 9922FFEB29B501C50020F868 /* Starscream */, 1FB96FB729297C11007E68D1 /* GridStack */, + 1F0DD80F293222DF007608A4 /* AsyncImage */, ); productName = AltStore; productReference = BFD2476A2284B9A500981D42 /* SideStore.app */; @@ -2268,6 +2285,7 @@ 99C4EF472978D52400CB538D /* XCRemoteSwiftPackageReference "SemanticVersion" */, 9922FFEA29B501C50020F868 /* XCRemoteSwiftPackageReference "Starscream" */, 1FB96FB629297C11007E68D1 /* XCRemoteSwiftPackageReference "GridStack" */, + 1F0DD80E293222DF007608A4 /* XCRemoteSwiftPackageReference "AsyncImage" */, ); productRefGroup = BFD2476B2284B9A500981D42 /* Products */; projectDirPath = ""; @@ -2686,6 +2704,7 @@ 1F943C712927F90400ABE095 /* MyAppsView.swift in Sources */, BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */, BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */, + 1F0DD81329322487007608A4 /* LazyScrollingVStack.swift in Sources */, BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */, BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */, BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */, @@ -2752,6 +2771,7 @@ BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */, BF2901312318F7A800D88A45 /* AppBannerView.swift in Sources */, BFF00D342501BDCF00746320 /* IntentHandler.swift in Sources */, + 1F0DD81C2932D2FF007608A4 /* AppScreenshotsScrollView.swift in Sources */, BFDBBD80246CB84F004ED2F3 /* RemoveAppBackupOperation.swift in Sources */, 1FB96FCF292BBBCA007E68D1 /* SiriShortcutSetupView.swift in Sources */, BFF0B6942321CB85007A79E1 /* AuthenticationViewController.swift in Sources */, @@ -3582,6 +3602,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 1F0DD80E293222DF007608A4 /* XCRemoteSwiftPackageReference "AsyncImage" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/zzzzeu/AsyncImage"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.0.1; + }; + }; 1FB96FB629297C11007E68D1 /* XCRemoteSwiftPackageReference "GridStack" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pietropizzi/GridStack"; @@ -3683,6 +3711,11 @@ package = 4879A9602861049C00FC1BBD /* XCRemoteSwiftPackageReference "OpenSSL" */; productName = OpenSSL; }; + 1F0DD80F293222DF007608A4 /* AsyncImage */ = { + isa = XCSwiftPackageProductDependency; + package = 1F0DD80E293222DF007608A4 /* XCRemoteSwiftPackageReference "AsyncImage" */; + productName = AsyncImage; + }; 1FB96FB729297C11007E68D1 /* GridStack */ = { isa = XCSwiftPackageProductDependency; package = 1FB96FB629297C11007E68D1 /* XCRemoteSwiftPackageReference "GridStack" */; diff --git a/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c4766f83..1f67dd61 100644 --- a/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,15 @@ "version" : "4.4.2" } }, + { + "identity" : "asyncimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zzzzeu/AsyncImage", + "state" : { + "revision" : "854d01f6bb9550f4aeee8959ab5b67d7d7775f02", + "version" : "0.0.1" + } + }, { "identity" : "gridstack", "kind" : "remoteSourceControl", diff --git a/AltStore/View Components/AppIconView.swift b/AltStore/View Components/AppIconView.swift index fc161166..202cccfb 100644 --- a/AltStore/View Components/AppIconView.swift +++ b/AltStore/View Components/AppIconView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import AsyncImage struct AppIconView: View { let iconUrl: URL? @@ -16,7 +17,7 @@ struct AppIconView: View { } var body: some View { - if let iconUrl, #available(iOS 15.0, *) { + if let iconUrl { AsyncImage(url: iconUrl) { image in image .resizable() @@ -25,10 +26,6 @@ struct AppIconView: View { } .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - } else { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .background(Color.secondary) - .frame(width: size, height: size) } } } diff --git a/AltStore/Views/App Detail/AppDetailView.swift b/AltStore/Views/App Detail/AppDetailView.swift index 5f71e0dd..da880217 100644 --- a/AltStore/Views/App Detail/AppDetailView.swift +++ b/AltStore/Views/App Detail/AppDetailView.swift @@ -8,6 +8,7 @@ import SwiftUI import GridStack +import AsyncImage import AltStoreCore struct AppDetailView: View { @@ -39,7 +40,7 @@ struct AppDetailView: View { } var body: some View { - ObservableScrollView(scrollOffset: self.$scrollOffset) { proxy in + ObservableScrollView(scrollOffset: $scrollOffset) { proxy in LazyVStack { headerView .frame(height: headerViewHeight) @@ -86,29 +87,35 @@ struct AppDetailView: View { } var contentView: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 24) { if let subtitle = storeApp.subtitle { Text(subtitle) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - .padding() + .padding(.horizontal) } if !storeApp.screenshotURLs.isEmpty { - screenshotsView + // Equatable: Only reload the view if the screenshots change. + // This prevents unnecessary redraws on scroll. + AppScreenshotsScrollView(urls: storeApp.screenshotURLs) + .equatable() } Text(storeApp.localizedDescription) .lineLimit(6) - .padding() + .padding(.horizontal) currentVersionView + .padding(.horizontal) permissionsView + .padding(.horizontal) // TODO: Add review view // Only let users rate the app if it is currently installed! } + .padding(.vertical) .background( RoundedRectangle(cornerRadius: contentCornerRadius) .foregroundColor(Color(UIColor.systemBackground)) @@ -116,28 +123,6 @@ struct AppDetailView: View { ) } - var screenshotsView: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(storeApp.screenshotURLs) { url in - if #available(iOS 15.0, *) { - AsyncImage(url: url) { image in - image.resizable() - } placeholder: { - Rectangle() - .foregroundColor(.secondary) - } - .aspectRatio(9/16, contentMode: .fit) - .cornerRadius(8) - } - } - } - .padding(.horizontal) - } - .frame(height: 400) - .shadow(radius: 12) - } - var currentVersionView: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .bottom) { @@ -169,7 +154,6 @@ struct AppDetailView: View { .foregroundColor(.secondary) } } - .padding() } var permissionsView: some View { @@ -199,12 +183,5 @@ struct AppDetailView: View { Spacer() } - .padding() } } - -//struct AppDetailView_Previews: PreviewProvider { -// static var previews: some View { -// AppDetailView() -// } -//} diff --git a/AltStore/Views/App Detail/AppScreenshotsScrollView.swift b/AltStore/Views/App Detail/AppScreenshotsScrollView.swift new file mode 100644 index 00000000..e0c8bce0 --- /dev/null +++ b/AltStore/Views/App Detail/AppScreenshotsScrollView.swift @@ -0,0 +1,49 @@ +// +// AppScreenshotsScrollView.swift +// SideStore +// +// Created by Fabian Thies on 27.11.22. +// Copyright © 2022 SideStore. All rights reserved. +// + +import SwiftUI +import AsyncImage + + +/// Horizontal ScrollView with an asynchronously loaded image for each screenshot URL +/// +/// The struct inherits the `Equatable` protocol and implements the respective comparisation function to prevent the view from being constantly re-rendered when a `@State` change in the parent view occurs. +/// This way, the `AppScreenshotsScrollView` will only be reloaded when the parameters change. +struct AppScreenshotsScrollView: View { + let urls: [URL] + var aspectRatio: CGFloat = 9/16 + var height: CGFloat = 400 + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(urls) { url in + AsyncImage(url: url) { image in + image + .resizable() + } placeholder: { + Rectangle() + .foregroundColor(.secondary) + } + .aspectRatio(aspectRatio, contentMode: .fit) + .cornerRadius(8) + } + } + .padding(.horizontal) + } + .frame(height: height) + .shadow(radius: 12) + } +} + +extension AppScreenshotsScrollView: Equatable { + /// Prevent re-rendering of the view if the parameters didn't change + static func == (lhs: AppScreenshotsScrollView, rhs: AppScreenshotsScrollView) -> Bool { + lhs.urls == rhs.urls && lhs.aspectRatio == rhs.aspectRatio && lhs.height == rhs.height + } +} diff --git a/AltStore/Views/Browse/BrowseAppPreviewView.swift b/AltStore/Views/Browse/BrowseAppPreviewView.swift index 1acbda62..da714c77 100644 --- a/AltStore/Views/Browse/BrowseAppPreviewView.swift +++ b/AltStore/Views/Browse/BrowseAppPreviewView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import AsyncImage import AltStoreCore struct BrowseAppPreviewView: View { @@ -23,16 +24,15 @@ struct BrowseAppPreviewView: View { if !storeApp.screenshotURLs.isEmpty { HStack { ForEach(storeApp.screenshotURLs.prefix(2)) { url in - if #available(iOS 15.0, *) { - AsyncImage(url: url) { image in - image - .resizable() - .aspectRatio(contentMode: .fit) - } placeholder: { - Color(UIColor.secondarySystemBackground) - } - .cornerRadius(8) + AsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + Color(UIColor.secondarySystemBackground) + .aspectRatio(9/16, contentMode: .fit) } + .cornerRadius(8) } } .frame(height: 300) diff --git a/AltStore/Views/News/NewsItemView.swift b/AltStore/Views/News/NewsItemView.swift index bad2d248..ab1ec353 100644 --- a/AltStore/Views/News/NewsItemView.swift +++ b/AltStore/Views/News/NewsItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import AsyncImage import AltStoreCore struct NewsItemView: View { @@ -53,17 +54,15 @@ struct NewsItemView: View { } .padding(24) - if let imageUrl = newsItem.imageURL, #available(iOS 15.0, *) { - AsyncImage( - url: imageUrl, - content: { image in - if let image = image.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - } - } - ) + if let imageUrl = newsItem.imageURL { + AsyncImage(url: imageUrl) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + Color.secondary + .frame(maxWidth: .infinity, maxHeight: 100) + } } } .frame( diff --git a/AltStore/Views/Settings/SettingsView.swift b/AltStore/Views/Settings/SettingsView.swift index b3e64c42..1782cc94 100644 --- a/AltStore/Views/Settings/SettingsView.swift +++ b/AltStore/Views/Settings/SettingsView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import AsyncImage import AltStoreCore import Intents @@ -86,6 +87,9 @@ struct SettingsView: View { Text("Switch to UIKit") } + SwiftUI.Button(action: resetImageCache) { + Text("Reset Image Cache") + } } header: { Text("Debug") } @@ -137,6 +141,19 @@ struct SettingsView: View { UIApplication.shared.keyWindow?.rootViewController = rootVC } + + func resetImageCache() { + do { + let url = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + try FileManager.default.removeItem(at: url.appendingPathComponent("com.zeu.cache", isDirectory: true)) + } catch let error { + fatalError("\(error)") + } + } } struct SettingsView_Previews: PreviewProvider { @@ -144,3 +161,5 @@ struct SettingsView_Previews: PreviewProvider { SettingsView() } } + +