From 7977267107e14b99186f83d98a11cddcac3433d5 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 19 Oct 2023 16:11:57 -0500 Subject: [PATCH] Replaces BrowseCollectionViewCell with AppCardCollectionViewCell * Handles dynamic screenshot sizes * Allows swiping through screenshots * Supports iPhone + iPad screenshots --- AltStore.xcodeproj/project.pbxproj | 11 +- .../AppScreenshotCollectionViewCell.swift | 29 +- AltStore/Base.lproj/Main.storyboard | 2 +- AltStore/Browse/BrowseViewController.swift | 50 ++-- .../AppCardCollectionViewCell.swift | 276 ++++++++++++++++++ AltStoreCore/Model/AppScreenshot.swift | 7 + 6 files changed, 331 insertions(+), 44 deletions(-) create mode 100644 AltStore/Components/AppCardCollectionViewCell.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 1cff0060..fe889ab4 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -245,7 +245,6 @@ BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */; }; BF989185250AAD1D002ACF50 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2479E2284FBD000981D42 /* UIColor+AltStore.swift */; }; BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */; }; - BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */; }; BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */; }; BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4A22DD137F008935CF /* NavigationBar.swift */; }; BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4C22DD16DE008935CF /* PillButton.swift */; }; @@ -293,7 +292,6 @@ BFD52C2122A1A9EC000B7ED1 /* node_list.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52C1E22A1A9EC000B7ED1 /* node_list.c */; }; BFD52C2222A1A9EC000B7ED1 /* cnary.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52C1F22A1A9EC000B7ED1 /* cnary.c */; }; BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD6B03222DFF20800B86064 /* MyAppsComponents.swift */; }; - BFDB5B2622EFBBEA00F74113 /* BrowseCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */; }; BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */; }; BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */; }; BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */; }; @@ -420,6 +418,7 @@ D5B6F6A92AD75D01007EED5A /* ProcessInfo+Previews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */; }; D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */; }; D5BA9E9B2A9FE1E8007C0661 /* JITManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5BA9E9A2A9FE1E8007C0661 /* JITManager.swift */; }; + D5C0E7672AD9C75900530CA4 /* AppCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */; }; D5C8ACDB2A956B2B00669F92 /* Process+STPrivilegedTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C8ACDA2A956B2B00669F92 /* Process+STPrivilegedTask.swift */; }; D5CA0C4B280E141900469595 /* ManagedPatron.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4A280E141900469595 /* ManagedPatron.swift */; }; D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */; }; @@ -935,7 +934,6 @@ BF989190250AAE86002ACF50 /* ViewAppIntentHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewAppIntentHandler.swift; sourceTree = ""; }; BF989191250AAE86002ACF50 /* ViewApp.intentdefinition */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.intentdefinition; path = ViewApp.intentdefinition; sourceTree = ""; }; BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewController.swift; sourceTree = ""; }; - BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCollectionViewCell.swift; sourceTree = ""; }; BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotCollectionViewCell.swift; sourceTree = ""; }; BF9ABA4A22DD137F008935CF /* NavigationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = ""; }; BF9ABA4C22DD16DE008935CF /* PillButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillButton.swift; sourceTree = ""; }; @@ -979,7 +977,6 @@ BFD52C1F22A1A9EC000B7ED1 /* cnary.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = cnary.c; path = Dependencies/libplist/libcnary/cnary.c; sourceTree = SOURCE_ROOT; }; BFD6B03222DFF20800B86064 /* MyAppsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppsComponents.swift; sourceTree = ""; }; BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+RelativeDate.swift"; sourceTree = ""; }; - BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowseCollectionViewCell.xib; sourceTree = ""; }; BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignAppOperation.swift; sourceTree = ""; }; BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationError.swift; sourceTree = ""; }; @@ -1085,6 +1082,7 @@ D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Previews.swift"; sourceTree = ""; }; D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = ""; }; D5BA9E9A2A9FE1E8007C0661 /* JITManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JITManager.swift; sourceTree = ""; }; + D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCardCollectionViewCell.swift; sourceTree = ""; }; D5C8ACDA2A956B2B00669F92 /* Process+STPrivilegedTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Process+STPrivilegedTask.swift"; sourceTree = ""; }; D5CA0C4A280E141900469595 /* ManagedPatron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedPatron.swift; sourceTree = ""; }; D5CA0C4C280E242500469595 /* AltStore 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 10.xcdatamodel"; sourceTree = ""; }; @@ -1815,8 +1813,6 @@ isa = PBXGroup; children = ( BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */, - BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */, - BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */, BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */, ); path = Browse; @@ -1989,6 +1985,7 @@ BF6C8FAD2429597900125131 /* AppBannerCollectionViewCell.swift */, BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */, D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */, + D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */, ); path = Components; sourceTree = ""; @@ -3234,7 +3231,6 @@ BD4513AB2C6FA98C0052BCC0 /* AppExtensionView.swift in Sources */, BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */, BFA8172B23C5633D001B5953 /* FetchAnisetteDataOperation.swift in Sources */, - BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */, D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */, BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */, BF41B808233433C100C593A3 /* LoadingState.swift in Sources */, @@ -3279,6 +3275,7 @@ BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */, BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */, BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */, + D5C0E7672AD9C75900530CA4 /* AppCardCollectionViewCell.swift in Sources */, D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */, D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, diff --git a/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift b/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift index 803ee034..efdabdfc 100644 --- a/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift +++ b/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift @@ -10,6 +10,20 @@ import UIKit import AltStoreCore +extension AppScreenshotCollectionViewCell +{ + private class ImageView: UIImageView + { + override func layoutSubviews() + { + super.layoutSubviews() + + // Explicitly layout cell to ensure rounded corners are accurate. + self.superview?.superview?.setNeedsLayout() + } + } +} + class AppScreenshotCollectionViewCell: UICollectionViewCell { let imageView: UIImageView @@ -31,21 +45,21 @@ class AppScreenshotCollectionViewCell: UICollectionViewCell override init(frame: CGRect) { - self.imageView = UIImageView(frame: .zero) + self.imageView = ImageView(frame: .zero) self.imageView.clipsToBounds = true self.imageView.layer.cornerCurve = .continuous self.imageView.layer.borderColor = UIColor.tertiaryLabel.cgColor - + super.init(frame: frame) self.imageView.translatesAutoresizingMaskIntoConstraints = false self.contentView.addSubview(self.imageView) let widthConstraint = self.imageView.widthAnchor.constraint(equalTo: self.contentView.widthAnchor) - widthConstraint.priority = UILayoutPriority(999) + widthConstraint.priority = .defaultHigh let heightConstraint = self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor) - heightConstraint.priority = UILayoutPriority(999) + heightConstraint.priority = .defaultHigh NSLayoutConstraint.activate([ widthConstraint, @@ -64,7 +78,7 @@ class AppScreenshotCollectionViewCell: UICollectionViewCell fatalError("init(coder:) has not been implemented") } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -75,11 +89,6 @@ class AppScreenshotCollectionViewCell: UICollectionViewCell { super.layoutSubviews() - guard self.imageView.bounds.width != 0 else { - self.setNeedsLayout() - return - } - if self.isRounded { let cornerRadius = self.imageView.bounds.width / 9.0 // Based on iPhone 15 diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index e21c508e..d3898e31 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -50,7 +50,7 @@ - + diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 4b9b61c2..748ab0cb 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -22,7 +22,7 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing private lazy var dataSource = self.makeDataSource() private lazy var placeholderView = RSTPlaceholderView(frame: .zero) - private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)! + private let prototypeCell = AppCardCollectionViewCell(frame: .zero) private var loadingState: LoadingState = .loading { didSet { @@ -59,7 +59,7 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false - self.collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) + self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) self.collectionView.dataSource = self.dataSource self.collectionView.prefetchDataSource = self.dataSource @@ -121,14 +121,11 @@ private extension BrowseViewController let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchRequest: fetchRequest, managedObjectContext: context) dataSource.cellConfigurationHandler = { (cell, app, indexPath) in - let cell = cell as! BrowseCollectionViewCell + let cell = cell as! AppCardCollectionViewCell cell.layoutMargins.left = self.view.layoutMargins.left cell.layoutMargins.right = self.view.layoutMargins.right - cell.subtitleLabel.text = app.subtitle - cell.imageURLs = Array(app.screenshotURLs.prefix(2)) - - cell.bannerView.configure(for: app) + cell.configure(for: app) cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true @@ -188,7 +185,7 @@ private extension BrowseViewController } } dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in - let cell = cell as! BrowseCollectionViewCell + let cell = cell as! AppCardCollectionViewCell cell.bannerView.iconImageView.isIndicatingActivity = false cell.bannerView.iconImageView.image = image @@ -365,21 +362,18 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let item = self.dataSource.item(at: indexPath) + let itemID = item.globallyUniqueID ?? item.bundleIdentifier - if let previousSize = self.cachedItemSizes[item.bundleIdentifier] + if let previousSize = self.cachedItemSizes[itemID] { return previousSize } - - let maxVisibleScreenshots = 2 as CGFloat - let aspectRatio: CGFloat = 16.0 / 9.0 - let layout = self.prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout - let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right - self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath) + + let insets = (self.view.layoutMargins.left + self.view.layoutMargins.right) - let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width) + let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width - insets) widthConstraint.isActive = true defer { widthConstraint.isActive = false } @@ -387,17 +381,8 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout self.prototypeCell.frame.size.width = widthConstraint.constant self.prototypeCell.layoutIfNeeded() - let collectionViewWidth = self.prototypeCell.screenshotsCollectionView.bounds.width - let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down) - let screenshotHeight = screenshotWidth * aspectRatio - - let heightConstraint = self.prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight) - heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error. - heightConstraint.isActive = true - defer { heightConstraint.isActive = false } - let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - self.cachedItemSizes[item.bundleIdentifier] = itemSize + self.cachedItemSizes[itemID] = itemSize return itemSize } @@ -434,3 +419,16 @@ extension BrowseViewController: UIViewControllerPreviewingDelegate self.navigationController?.pushViewController(viewControllerToCommit, animated: true) } } + +@available(iOS 17, *) +#Preview(traits: .portrait) { + DatabaseManager.shared.startForPreview() + + let storyboard = UIStoryboard(name: "Main", bundle: .main) + let browseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in + BrowseViewController(source: nil, coder: coder) + } + + let navigationController = UINavigationController(rootViewController: browseViewController) + return navigationController +} diff --git a/AltStore/Components/AppCardCollectionViewCell.swift b/AltStore/Components/AppCardCollectionViewCell.swift new file mode 100644 index 00000000..7177233f --- /dev/null +++ b/AltStore/Components/AppCardCollectionViewCell.swift @@ -0,0 +1,276 @@ +// +// AppCardCollectionViewCell.swift +// AltStore +// +// Created by Riley Testut on 10/13/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore +import Roxas + +import Nuke + +private let minimumItemSpacing = 8.0 + +class AppCardCollectionViewCell: UICollectionViewCell +{ + let bannerView: AppBannerView + + private let screenshotsCollectionView: UICollectionView + private let stackView: UIStackView + + private lazy var dataSource = self.makeDataSource() + + private var screenshots: [AppScreenshot] = [] { + didSet { + self.dataSource.items = self.screenshots + } + } + + override init(frame: CGRect) + { + self.bannerView = AppBannerView(frame: .zero) + + self.screenshotsCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + self.screenshotsCollectionView.backgroundColor = nil + self.screenshotsCollectionView.alwaysBounceVertical = false + self.screenshotsCollectionView.alwaysBounceHorizontal = true + self.screenshotsCollectionView.showsHorizontalScrollIndicator = false + self.screenshotsCollectionView.showsVerticalScrollIndicator = false + + self.stackView = UIStackView(arrangedSubviews: [self.bannerView, self.screenshotsCollectionView]) + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.stackView.spacing = 0 + self.stackView.axis = .vertical + self.stackView.alignment = .fill + self.stackView.distribution = .equalSpacing + + super.init(frame: frame) + + self.contentView.clipsToBounds = true + self.contentView.layer.cornerCurve = .continuous + + self.contentView.addSubview(self.bannerView.backgroundEffectView, pinningEdgesWith: .zero) + self.contentView.addSubview(self.stackView, pinningEdgesWith: .zero) + + self.screenshotsCollectionView.collectionViewLayout = self.makeLayout() + self.screenshotsCollectionView.dataSource = self.dataSource + self.screenshotsCollectionView.prefetchDataSource = self.dataSource + + // Adding screenshotsCollectionView's gesture recognizers to self.contentView breaks paging, + // so instead we intercept taps and pass them onto delegate. + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AppCardCollectionViewCell.handleTapGesture(_:))) + tapGestureRecognizer.cancelsTouchesInView = false + tapGestureRecognizer.delaysTouchesBegan = false + tapGestureRecognizer.delaysTouchesEnded = false + self.screenshotsCollectionView.addGestureRecognizer(tapGestureRecognizer) + + self.screenshotsCollectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) + + let inset = 14.0 //TODO: Assign from bannerView's layoutMargins + self.stackView.isLayoutMarginsRelativeArrangement = true + self.stackView.layoutMargins.bottom = inset + + self.contentView.preservesSuperviewLayoutMargins = true + self.screenshotsCollectionView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset) + + // Aspect ratio constraint to fit exactly 3 modern portrait iPhone screenshots side-by-side (with spacing). + let multiplier = (AppScreenshot.defaultAspectRatio.width * 3) / AppScreenshot.defaultAspectRatio.height + let spacing = (inset * 2) + (minimumItemSpacing * 2) + let aspectRatioConstraint = self.screenshotsCollectionView.widthAnchor.constraint(equalTo: self.screenshotsCollectionView.heightAnchor, multiplier: multiplier, constant: spacing) + + NSLayoutConstraint.activate([ + aspectRatioConstraint, + self.bannerView.heightAnchor.constraint(equalToConstant: 88) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() + { + super.layoutSubviews() + + self.contentView.layer.cornerRadius = self.bannerView.layer.cornerRadius + } +} + +private extension AppCardCollectionViewCell +{ + func makeLayout() -> UICollectionViewCompositionalLayout + { + let layoutConfig = UICollectionViewCompositionalLayoutConfiguration() + layoutConfig.contentInsetsReference = .layoutMargins + + let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + guard let self else { return nil } + + var contentWidth = 0.0 + var numberOfVisibleScreenshots = 0 + + for screenshot in self.screenshots + { + var aspectRatio = screenshot.aspectRatio + if aspectRatio.width > aspectRatio.height + { + switch screenshot.deviceType + { + case .iphone: + // Always rotate landscape iPhone screenshots + aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width) + + case .ipad: + // Never rotate iPad screenshots + break + + default: break + } + } + + let screenshotWidth = (layoutEnvironment.container.effectiveContentSize.height * (aspectRatio.width / aspectRatio.height)).rounded(.up) // Round to ensure we over-estimate contentWidth. + + let totalContentWidth = contentWidth + (screenshotWidth + minimumItemSpacing) + if totalContentWidth > layoutEnvironment.container.effectiveContentSize.width + { + // totalContentWidth is larger than visible width. + break + } + + contentWidth = totalContentWidth + numberOfVisibleScreenshots += 1 + } + + // Use .estimated(1) to ensure we don't over-estimate widths, which can cause incorrect layouts for the last group. + let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(1), heightDimension: .fractionalHeight(1.0)) + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: nil, bottom: nil) + + let groupItem = NSCollectionLayoutItem(layoutSize: itemSize) + let trailingGroup = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [groupItem]) + trailingGroup.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .flexible(0), bottom: nil) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, trailingGroup]) + group.interItemSpacing = .fixed(minimumItemSpacing) + + if numberOfVisibleScreenshots < self.screenshots.count + { + // There are more screenshots than what is displayed, so no need to manually center them. + } + else + { + // We're showing all screenshots initially, so make sure they're centered. + + let insetWidth = (layoutEnvironment.container.effectiveContentSize.width - contentWidth) / 2.0 + group.contentInsets.leading = (insetWidth - 1).rounded(.down) // Subtract 1 to avoid overflowing/clipping + } + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.orthogonalScrollingBehavior = .groupPagingCentered + layoutSection.interGroupSpacing = self.screenshotsCollectionView.directionalLayoutMargins.leading + self.screenshotsCollectionView.directionalLayoutMargins.trailing + return layoutSection + }, configuration: layoutConfig) + + return layout + } + + func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource + { + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: []) + dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in + let cell = cell as! AppScreenshotCollectionViewCell + cell.imageView.image = nil + cell.imageView.isIndicatingActivity = true + + var aspectRatio = screenshot.aspectRatio + if aspectRatio.width > aspectRatio.height + { + switch screenshot.deviceType + { + case .iphone: + // Always rotate landscape iPhone screenshots + aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width) + + case .ipad: + // Never rotate iPad screenshots + break + + default: break + } + } + + cell.aspectRatio = aspectRatio + } + dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in + let imageURL = screenshot.imageURL + return RSTAsyncBlockOperation() { (operation) in + let request = ImageRequest(url: imageURL) + ImagePipeline.shared.loadImage(with: request, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completionHandler(response.image, nil) + case .failure(let error): completionHandler(nil, error) + } + } + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! AppScreenshotCollectionViewCell + cell.imageView.isIndicatingActivity = false + cell.setImage(image) + + if let error = error + { + print("Error loading image:", error) + } + } + + return dataSource + } + + @objc func handleTapGesture(_ tapGesture: UITapGestureRecognizer) + { + var superview: UIView? = self.superview + var collectionView: UICollectionView? = nil + + while case let view? = superview + { + if let cv = view as? UICollectionView + { + collectionView = cv + break + } + + superview = view.superview + } + + if let collectionView, let indexPath = collectionView.indexPath(for: self) + { + collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath) + } + } +} + +extension AppCardCollectionViewCell +{ + func configure(for storeApp: StoreApp) + { + self.screenshots = storeApp.preferredScreenshots() + + self.bannerView.tintColor = storeApp.tintColor + self.bannerView.configure(for: storeApp) + + self.bannerView.subtitleLabel.numberOfLines = 1 + self.bannerView.subtitleLabel.lineBreakMode = .byTruncatingTail + self.bannerView.subtitleLabel.minimumScaleFactor = 0.8 + self.bannerView.subtitleLabel.text = storeApp.subtitle ?? storeApp.developerName + } +} diff --git a/AltStoreCore/Model/AppScreenshot.swift b/AltStoreCore/Model/AppScreenshot.swift index 8a143a34..86f63fb2 100644 --- a/AltStoreCore/Model/AppScreenshot.swift +++ b/AltStoreCore/Model/AppScreenshot.swift @@ -98,6 +98,13 @@ public class AppScreenshot: NSManagedObject, Fetchable, Decodable } } +public extension AppScreenshot +{ + var aspectRatio: CGSize { + return self.size ?? AppScreenshot.defaultAspectRatio + } +} + extension AppScreenshot { var screenshotID: String {