mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-10 15:23:27 +01:00
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// BrowseCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
import Nuke
|
||||
|
||||
@objc final class BrowseCollectionViewCell: UICollectionViewCell {
|
||||
var imageURLs: [URL] = [] {
|
||||
didSet {
|
||||
dataSource.items = imageURLs as [NSURL]
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
|
||||
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷♂️.
|
||||
screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
screenshotsCollectionView.delegate = self
|
||||
screenshotsCollectionView.dataSource = dataSource
|
||||
screenshotsCollectionView.prefetchDataSource = dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseCollectionViewCell {
|
||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage> {
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
|
||||
dataSource.cellConfigurationHandler = { cell, _, _ in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { imageURL, _, completionHandler in
|
||||
RSTAsyncBlockOperation { operation in
|
||||
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error {
|
||||
os_log("Error loading image: %@", type: .error , error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
|
||||
// Assuming 9.0 / 16.0 ratio for now.
|
||||
let aspectRatio: CGFloat = 9.0 / 16.0
|
||||
|
||||
let itemHeight = collectionView.bounds.height
|
||||
let itemWidth = itemHeight * aspectRatio
|
||||
|
||||
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
|
||||
return size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//
|
||||
// BrowseViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
import Nuke
|
||||
|
||||
class BrowseViewController: UICollectionViewController {
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||
|
||||
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
||||
|
||||
private var loadingState: LoadingState = .loading {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedItemSizes = [String: CGSize]()
|
||||
|
||||
@IBOutlet private var sourcesBarButtonItem: UIBarButtonItem!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
#if BETA
|
||||
dataSource.searchController.searchableKeyPaths = [#keyPath(InstalledApp.name)]
|
||||
navigationItem.searchController = dataSource.searchController
|
||||
#endif
|
||||
|
||||
prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
collectionView.prefetchDataSource = dataSource
|
||||
|
||||
registerForPreviewing(with: self, sourceView: collectionView)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
fetchSource()
|
||||
updateDataSource()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
@IBAction private func unwindFromSourcesViewController(_: UIStoryboardSegue) {
|
||||
fetchSource()
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseViewController {
|
||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage> {
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.cellConfigurationHandler = { cell, app, _ in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
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.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
cell.bannerView.button.activityIndicatorView.style = .medium
|
||||
|
||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||
// Otherwise, cell reuse can mess up some cached values.
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
let tintColor = app.tintColor ?? .altPrimary
|
||||
cell.tintColor = tintColor
|
||||
|
||||
if app.installedApp == nil {
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
cell.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = app.latestVersion?.date, versionDate > Date() {
|
||||
cell.bannerView.button.countdownDate = app.versionDate
|
||||
} else {
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
} else {
|
||||
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = nil
|
||||
cell.bannerView.button.progress = nil
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
dataSource.prefetchHandler = { storeApp, _, completionHandler -> Foundation.Operation? in
|
||||
let iconURL = storeApp.iconURL
|
||||
|
||||
return RSTAsyncBlockOperation { operation in
|
||||
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
cell.bannerView.iconImageView.image = image
|
||||
|
||||
if let error = error {
|
||||
os_log("Error loading image: %@", type: .error , error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.placeholderView = placeholderView
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func updateDataSource() {
|
||||
dataSource.predicate = nil
|
||||
}
|
||||
|
||||
func fetchSource() {
|
||||
loadingState = .loading
|
||||
|
||||
AppManager.shared.fetchSources { result in
|
||||
do {
|
||||
do {
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.loadingState = .finished(.success(()))
|
||||
}
|
||||
} catch let error as AppManager.FetchSourcesError {
|
||||
try error.managedObjectContext?.save()
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
if self.dataSource.itemCount > 0 {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
|
||||
self.loadingState = .finished(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
switch loadingState {
|
||||
case .loading:
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||
|
||||
placeholderView.activityIndicatorView.startAnimating()
|
||||
|
||||
case let .finished(.failure(error)):
|
||||
placeholderView.textLabel.isHidden = false
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
||||
placeholderView.detailTextLabel.text = error.localizedDescription
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
|
||||
case .finished(.success):
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseViewController {
|
||||
@IBAction func performAppAction(_ sender: PillButton) {
|
||||
let point = collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
if let installedApp = app.installedApp {
|
||||
open(installedApp)
|
||||
} else {
|
||||
install(app, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
func install(_ app: StoreApp, at indexPath: IndexPath) {
|
||||
let previousProgress = AppManager.shared.installationProgress(for: app)
|
||||
guard previousProgress == nil else {
|
||||
previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.install(app, presentingViewController: self) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .failure(OperationError.cancelled): break // Ignore
|
||||
case let .failure(error):
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
|
||||
case .success: os_log("Installed app: %@", type: .info , app.bundleIdentifier)
|
||||
}
|
||||
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
}
|
||||
|
||||
collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp) {
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let item = dataSource.item(at: indexPath)
|
||||
|
||||
if let previousSize = cachedItemSizes[item.bundleIdentifier] {
|
||||
return previousSize
|
||||
}
|
||||
|
||||
let maxVisibleScreenshots = 2 as CGFloat
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0
|
||||
|
||||
let layout = prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
|
||||
|
||||
dataSource.cellConfigurationHandler(prototypeCell, item, indexPath)
|
||||
|
||||
let widthConstraint = prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||
widthConstraint.isActive = true
|
||||
defer { widthConstraint.isActive = false }
|
||||
|
||||
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
|
||||
prototypeCell.frame.size.width = widthConstraint.constant
|
||||
prototypeCell.layoutIfNeeded()
|
||||
|
||||
let collectionViewWidth = prototypeCell.screenshotsCollectionView.bounds.width
|
||||
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
|
||||
let screenshotHeight = screenshotWidth * aspectRatio
|
||||
|
||||
let heightConstraint = prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
|
||||
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
|
||||
heightConstraint.isActive = true
|
||||
defer { heightConstraint.isActive = false }
|
||||
|
||||
let itemSize = prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
cachedItemSizes[item.bundleIdentifier] = itemSize
|
||||
return itemSize
|
||||
}
|
||||
|
||||
override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
navigationController?.pushViewController(appViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseViewController: UIViewControllerPreviewingDelegate {
|
||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
|
||||
guard
|
||||
let indexPath = collectionView.indexPathForItem(at: location),
|
||||
let cell = collectionView.cellForItem(at: indexPath)
|
||||
else { return nil }
|
||||
|
||||
previewingContext.sourceRect = cell.frame
|
||||
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
return appViewController
|
||||
}
|
||||
|
||||
func previewingContext(_: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
|
||||
navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// ScreenshotCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(ScreenshotCollectionViewCell)
|
||||
class ScreenshotCollectionViewCell: UICollectionViewCell {
|
||||
let imageView = UIImageView(image: nil)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
imageView.layer.masksToBounds = true
|
||||
addSubview(imageView, pinningEdgesWith: .zero)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
imageView.layer.cornerRadius = 4
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user