mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-12 00:03:27 +01:00
More space efficient, and avoids UI bug where inline search bar could appear messed up after pushing view controller onto navigation stack.
688 lines
26 KiB
Swift
688 lines
26 KiB
Swift
//
|
|
// BrowseViewController.swift
|
|
// AltStore
|
|
//
|
|
// Created by Riley Testut on 7/15/19.
|
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Combine
|
|
|
|
import minimuxer
|
|
import AltStoreCore
|
|
import Roxas
|
|
|
|
import Nuke
|
|
|
|
class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
|
{
|
|
// Nil == Show apps from all sources.
|
|
let source: Source?
|
|
|
|
private(set) var category: StoreCategory? {
|
|
didSet {
|
|
self.updateDataSource()
|
|
self.update()
|
|
}
|
|
}
|
|
|
|
var searchPredicate: NSPredicate? {
|
|
didSet {
|
|
self.updateDataSource()
|
|
}
|
|
}
|
|
|
|
private lazy var dataSource = self.makeDataSource()
|
|
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
|
|
|
private let prototypeCell = AppCardCollectionViewCell(frame: .zero)
|
|
private var sortButton: UIBarButtonItem?
|
|
|
|
private var preferredAppSorting: AppSorting = UserDefaults.shared.preferredAppSorting
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
private var titleStackView: UIStackView!
|
|
private var titleSourceIconView: AppIconImageView!
|
|
private var titleCategoryIconView: UIImageView!
|
|
private var titleLabel: UILabel!
|
|
|
|
init?(source: Source?, coder: NSCoder)
|
|
{
|
|
self.source = source
|
|
self.category = nil
|
|
|
|
super.init(coder: coder)
|
|
}
|
|
|
|
init?(category: StoreCategory?, coder: NSCoder)
|
|
{
|
|
self.source = nil
|
|
self.category = category
|
|
|
|
super.init(coder: coder)
|
|
}
|
|
|
|
required init?(coder: NSCoder)
|
|
{
|
|
self.source = nil
|
|
self.category = nil
|
|
|
|
super.init(coder: coder)
|
|
}
|
|
|
|
private var cachedItemSizes = [String: CGSize]()
|
|
|
|
@IBOutlet private var sourcesBarButtonItem: UIBarButtonItem!
|
|
|
|
override func viewDidLoad()
|
|
{
|
|
super.viewDidLoad()
|
|
|
|
self.collectionView.backgroundColor = .altBackground
|
|
self.collectionView.alwaysBounceVertical = true
|
|
|
|
self.dataSource.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
|
#keyPath(StoreApp.subtitle),
|
|
#keyPath(StoreApp.developerName),
|
|
#keyPath(StoreApp.bundleIdentifier)]
|
|
self.navigationItem.searchController = self.dataSource.searchController
|
|
|
|
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
|
|
self.collectionView.dataSource = self.dataSource
|
|
self.collectionView.prefetchDataSource = self.dataSource
|
|
|
|
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
|
|
collectionViewLayout.minimumLineSpacing = 30
|
|
|
|
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
|
|
|
|
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction { [weak self] _ in
|
|
self?.updateSources()
|
|
})
|
|
self.collectionView.refreshControl = refreshControl
|
|
|
|
if self.category != nil, #available(iOS 16, *)
|
|
{
|
|
let categoriesMenu = UIMenu(children: [
|
|
UIDeferredMenuElement.uncached { [weak self] completion in
|
|
let actions = self?.makeCategoryActions() ?? []
|
|
completion(actions)
|
|
}
|
|
])
|
|
|
|
self.navigationItem.titleMenuProvider = { _ in categoriesMenu }
|
|
}
|
|
|
|
self.titleSourceIconView = AppIconImageView(style: .circular)
|
|
|
|
self.titleCategoryIconView = UIImageView(frame: .zero)
|
|
self.titleCategoryIconView.contentMode = .scaleAspectFit
|
|
|
|
self.titleLabel = UILabel()
|
|
self.titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
|
|
|
|
self.titleStackView = UIStackView(arrangedSubviews: [self.titleSourceIconView, self.titleCategoryIconView, self.titleLabel])
|
|
self.titleStackView.spacing = 4
|
|
|
|
self.navigationItem.largeTitleDisplayMode = .never
|
|
|
|
if #available(iOS 16, *)
|
|
{
|
|
self.navigationItem.preferredSearchBarPlacement = .automatic
|
|
}
|
|
|
|
if #available(iOS 15, *)
|
|
{
|
|
self.prepareAppSorting()
|
|
}
|
|
|
|
self.preparePipeline()
|
|
|
|
NSLayoutConstraint.activate([
|
|
// Source icon = equal width and height
|
|
self.titleSourceIconView.heightAnchor.constraint(equalToConstant: 26),
|
|
self.titleSourceIconView.widthAnchor.constraint(equalTo: self.titleSourceIconView.heightAnchor),
|
|
|
|
// Category icon = constant height, variable widths
|
|
self.titleCategoryIconView.heightAnchor.constraint(equalToConstant: 26)
|
|
])
|
|
|
|
self.updateDataSource()
|
|
self.update()
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool)
|
|
{
|
|
super.viewWillAppear(animated)
|
|
|
|
self.update()
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool)
|
|
{
|
|
super.viewDidDisappear(animated)
|
|
|
|
self.navigationController?.navigationBar.tintColor = nil
|
|
}
|
|
}
|
|
|
|
private extension BrowseViewController
|
|
{
|
|
func preparePipeline()
|
|
{
|
|
AppManager.shared.$updateSourcesResult
|
|
.receive(on: RunLoop.main) // Delay to next run loop so we receive _current_ value (not previous value).
|
|
.sink { [weak self] result in
|
|
self?.update()
|
|
}
|
|
.store(in: &self.cancellables)
|
|
}
|
|
|
|
func makeFetchRequest() -> NSFetchRequest<StoreApp>
|
|
{
|
|
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
|
fetchRequest.returnsObjectsAsFaults = false
|
|
|
|
let predicate = StoreApp.visibleAppsPredicate
|
|
|
|
if let source = self.source
|
|
{
|
|
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source)
|
|
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [filterPredicate, predicate])
|
|
}
|
|
else if let category = self.category
|
|
{
|
|
let categoryPredicate = switch category {
|
|
case .other: StoreApp.otherCategoryPredicate
|
|
default: NSPredicate(format: "%K == %@", #keyPath(StoreApp._category), category.rawValue)
|
|
}
|
|
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [categoryPredicate, predicate])
|
|
}
|
|
else
|
|
{
|
|
fetchRequest.predicate = predicate
|
|
}
|
|
|
|
var sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
|
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
|
|
|
switch self.preferredAppSorting
|
|
{
|
|
case .default:
|
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: self.preferredAppSorting.isAscending)
|
|
sortDescriptors.insert(descriptor, at: 0)
|
|
|
|
case .name:
|
|
// Already sorting by name, no need to prepend additional sort descriptor.
|
|
break
|
|
|
|
case .developer:
|
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.developerName, ascending: self.preferredAppSorting.isAscending)
|
|
sortDescriptors.insert(descriptor, at: 0)
|
|
|
|
case .lastUpdated:
|
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: self.preferredAppSorting.isAscending)
|
|
sortDescriptors.insert(descriptor, at: 0)
|
|
}
|
|
|
|
fetchRequest.sortDescriptors = sortDescriptors
|
|
|
|
return fetchRequest
|
|
}
|
|
|
|
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
|
{
|
|
let fetchRequest = self.makeFetchRequest()
|
|
|
|
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
|
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
|
|
dataSource.placeholderView = self.placeholderView
|
|
dataSource.cellConfigurationHandler = { [weak self] (cell, app, indexPath) in
|
|
guard let self else { return }
|
|
|
|
let cell = cell as! AppCardCollectionViewCell
|
|
cell.layoutMargins.left = self.view.layoutMargins.left
|
|
cell.layoutMargins.right = self.view.layoutMargins.right
|
|
|
|
let showSourceIcon = (self.source == nil) // Hide source icon if redundant
|
|
cell.configure(for: app, showSourceIcon: showSourceIcon)
|
|
|
|
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
|
|
cell.bannerView.button.activityIndicatorView.color = .white
|
|
|
|
let tintColor = app.tintColor ?? .altPrimary
|
|
cell.tintColor = tintColor
|
|
}
|
|
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
|
let iconURL = storeApp.iconURL
|
|
|
|
return RSTAsyncBlockOperation() { (operation) in
|
|
ImagePipeline.shared.loadImage(with: iconURL, 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 = { [weak dataSource] (cell, image, indexPath, error) in
|
|
let cell = cell as! AppCardCollectionViewCell
|
|
cell.bannerView.iconImageView.isIndicatingActivity = false
|
|
cell.bannerView.iconImageView.image = image
|
|
|
|
if let error = error, let dataSource
|
|
{
|
|
let app = dataSource.item(at: indexPath)
|
|
Logger.main.debug("Failed to load app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
return dataSource
|
|
}
|
|
|
|
func updateDataSource()
|
|
{
|
|
self.dataSource.predicate = nil
|
|
let fetchRequest = self.makeFetchRequest()
|
|
|
|
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
|
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
|
|
self.dataSource.fetchedResultsController = fetchedResultsController
|
|
|
|
self.dataSource.predicate = self.searchPredicate
|
|
}
|
|
|
|
func updateSources()
|
|
{
|
|
AppManager.shared.updateAllSources { result in
|
|
self.collectionView.refreshControl?.endRefreshing()
|
|
|
|
guard case .failure(let error) = result else { return }
|
|
|
|
if self.dataSource.itemCount > 0
|
|
{
|
|
let toastView = ToastView(error: error)
|
|
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
|
toastView.show(in: self)
|
|
}
|
|
}
|
|
}
|
|
|
|
func update()
|
|
{
|
|
if self.searchPredicate != nil
|
|
{
|
|
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
|
self.placeholderView.textLabel.isHidden = false
|
|
|
|
self.placeholderView.detailTextLabel.text = NSLocalizedString("Please make sure your spelling is correct, or try searching for another app.", comment: "")
|
|
self.placeholderView.detailTextLabel.isHidden = false
|
|
|
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
}
|
|
else
|
|
{
|
|
switch AppManager.shared.updateSourcesResult
|
|
{
|
|
case nil:
|
|
self.placeholderView.textLabel.isHidden = true
|
|
self.placeholderView.detailTextLabel.isHidden = false
|
|
|
|
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
|
|
|
self.placeholderView.activityIndicatorView.startAnimating()
|
|
|
|
case .failure(let error):
|
|
self.placeholderView.textLabel.isHidden = false
|
|
self.placeholderView.detailTextLabel.isHidden = false
|
|
|
|
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
|
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
|
|
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
|
|
case .success:
|
|
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
|
self.placeholderView.textLabel.isHidden = false
|
|
self.placeholderView.detailTextLabel.isHidden = true
|
|
|
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
}
|
|
}
|
|
|
|
let tintColor: UIColor
|
|
|
|
if let source = self.source
|
|
{
|
|
tintColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
|
|
|
self.title = source.name
|
|
|
|
self.titleSourceIconView.backgroundColor = tintColor
|
|
self.titleSourceIconView.isHidden = false
|
|
|
|
self.titleCategoryIconView.isHidden = true
|
|
|
|
if let iconURL = source.effectiveIconURL
|
|
{
|
|
Nuke.loadImage(with: iconURL, into: self.titleSourceIconView) { result in
|
|
switch result
|
|
{
|
|
case .failure(let error): Logger.main.error("Failed to fetch source icon at \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
case .success: self.titleSourceIconView.backgroundColor = .white
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if let category = self.category
|
|
{
|
|
tintColor = category.tintColor
|
|
|
|
self.title = category.localizedName
|
|
|
|
let image = UIImage(systemName: category.filledSymbolName)?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
|
self.titleCategoryIconView.image = image
|
|
self.titleCategoryIconView.isHidden = false
|
|
|
|
self.titleSourceIconView.isHidden = true
|
|
}
|
|
else
|
|
{
|
|
tintColor = .altPrimary
|
|
|
|
self.title = NSLocalizedString("Browse", comment: "")
|
|
|
|
self.titleSourceIconView.isHidden = true
|
|
self.titleCategoryIconView.isHidden = true
|
|
}
|
|
|
|
self.titleLabel.text = self.title
|
|
self.titleStackView.sizeToFit()
|
|
self.navigationItem.titleView = self.titleStackView
|
|
|
|
self.view.tintColor = tintColor
|
|
|
|
let appearance = NavigationBarAppearance()
|
|
appearance.configureWithTintColor(tintColor)
|
|
appearance.configureWithDefaultBackground()
|
|
|
|
let edgeAppearance = appearance.copy()
|
|
edgeAppearance.configureWithTransparentBackground()
|
|
|
|
self.navigationItem.standardAppearance = appearance
|
|
self.navigationItem.scrollEdgeAppearance = edgeAppearance
|
|
|
|
// Necessary to tint UISearchController's inline bar button.
|
|
self.navigationController?.navigationBar.tintColor = tintColor
|
|
|
|
if let sortButton
|
|
{
|
|
sortButton.image = sortButton.image?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
|
}
|
|
}
|
|
|
|
func makeCategoryActions() -> [UIAction]
|
|
{
|
|
let handler = { [weak self] (category: StoreCategory) in
|
|
self?.category = category
|
|
}
|
|
|
|
let fetchRequest = NSFetchRequest(entityName: StoreApp.entity().name!) as NSFetchRequest<NSDictionary>
|
|
fetchRequest.resultType = .dictionaryResultType
|
|
fetchRequest.returnsDistinctResults = true
|
|
fetchRequest.propertiesToFetch = [#keyPath(StoreApp._category)]
|
|
fetchRequest.predicate = StoreApp.visibleAppsPredicate
|
|
|
|
do
|
|
{
|
|
let dictionaries = try DatabaseManager.shared.viewContext.fetch(fetchRequest)
|
|
|
|
// Keep nil values
|
|
let categories = dictionaries.map { $0[#keyPath(StoreApp._category)] as? String? ?? nil }.map { rawCategory -> StoreCategory in
|
|
guard let rawCategory else { return .other }
|
|
return StoreCategory(rawValue: rawCategory) ?? .other
|
|
}
|
|
|
|
var sortedCategories = Set(categories).sorted(by: { $0.localizedName.localizedStandardCompare($1.localizedName) == .orderedAscending })
|
|
if let otherIndex = sortedCategories.firstIndex(of: .other)
|
|
{
|
|
// Ensure "Other" is always last
|
|
sortedCategories.move(fromOffsets: [otherIndex], toOffset: sortedCategories.count)
|
|
}
|
|
|
|
let actions = sortedCategories.map { category in
|
|
let state: UIAction.State = (category == self.category) ? .on : .off
|
|
let image = UIImage(systemName: category.symbolName)?.withTintColor(category.tintColor, renderingMode: .alwaysOriginal)
|
|
return UIAction(title: category.localizedName, image: image, state: state) { _ in
|
|
handler(category)
|
|
}
|
|
}
|
|
|
|
return actions
|
|
}
|
|
catch
|
|
{
|
|
Logger.main.error("Failed to fetch categories. \(error.localizedDescription, privacy: .public)")
|
|
|
|
return []
|
|
}
|
|
}
|
|
|
|
@available(iOS 15, *)
|
|
func prepareAppSorting()
|
|
{
|
|
if self.preferredAppSorting == .default && self.source == nil
|
|
{
|
|
// Only allow `default` sorting if source is non-nil.
|
|
// Otherwise, fall back to `lastUpdated` sorting.
|
|
self.preferredAppSorting = .lastUpdated
|
|
|
|
// Don't update UserDefaults unless explicitly changed by user.
|
|
// UserDefaults.shared.preferredAppSorting = .lastUpdated
|
|
}
|
|
|
|
let children = UIDeferredMenuElement.uncached { [weak self] completion in
|
|
guard let self else { return completion([]) }
|
|
|
|
var sortingOptions = AppSorting.allCases
|
|
if self.source == nil
|
|
{
|
|
// Only allow `default` sorting when source is non-nil.
|
|
sortingOptions = sortingOptions.filter { $0 != .default }
|
|
}
|
|
|
|
let actions = sortingOptions.map { sorting in
|
|
let state: UIMenuElement.State = (sorting == self.preferredAppSorting) ? .on : .off
|
|
let action = UIAction(title: sorting.localizedName, image: nil, state: state) { action in
|
|
self.preferredAppSorting = sorting
|
|
UserDefaults.shared.preferredAppSorting = sorting // Update separately to save change.
|
|
|
|
self.updateDataSource()
|
|
}
|
|
|
|
return action
|
|
}
|
|
|
|
completion(actions)
|
|
}
|
|
|
|
let sortMenu = UIMenu(title: NSLocalizedString("Sort by…", comment: ""), options: [.singleSelection], children: [children])
|
|
let sortIcon = UIImage(systemName: "arrow.up.arrow.down")
|
|
|
|
let sortButton = UIBarButtonItem(title: NSLocalizedString("Sort by…", comment: ""), image: sortIcon, primaryAction: nil, menu: sortMenu)
|
|
self.sortButton = sortButton
|
|
|
|
self.navigationItem.rightBarButtonItems = [sortButton]
|
|
}
|
|
}
|
|
|
|
private extension BrowseViewController
|
|
{
|
|
@IBAction func performAppAction(_ sender: PillButton)
|
|
{
|
|
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
|
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
|
|
|
let app = self.dataSource.item(at: indexPath)
|
|
|
|
if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
|
|
{
|
|
self.open(installedApp)
|
|
}
|
|
else
|
|
{
|
|
self.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
|
|
}
|
|
|
|
if !minimuxer.ready() {
|
|
let toastView = ToastView(error: MinimuxerError.NoConnection)
|
|
toastView.show(in: self)
|
|
return
|
|
}
|
|
|
|
if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
|
{
|
|
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
|
}
|
|
else
|
|
{
|
|
AppManager.shared.install(app, presentingViewController: self, completionHandler: finish(_:))
|
|
}
|
|
|
|
UIView.performWithoutAnimation {
|
|
self.collectionView.reloadItems(at: [indexPath])
|
|
}
|
|
|
|
func finish(_ result: Result<InstalledApp, Error>)
|
|
{
|
|
DispatchQueue.main.async {
|
|
switch result
|
|
{
|
|
case .failure(OperationError.cancelled): break // Ignore
|
|
case .failure(let error):
|
|
let toastView = ToastView(error: error, opensLog: true)
|
|
toastView.show(in: self)
|
|
|
|
case .success: print("Installed app:", app.bundleIdentifier)
|
|
}
|
|
|
|
UIView.performWithoutAnimation {
|
|
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app)
|
|
{
|
|
self.collectionView.reloadItems(at: [indexPath])
|
|
}
|
|
else
|
|
{
|
|
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func open(_ installedApp: InstalledApp)
|
|
{
|
|
UIApplication.shared.open(installedApp.openAppURL)
|
|
}
|
|
}
|
|
|
|
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[itemID]
|
|
{
|
|
return previousSize
|
|
}
|
|
|
|
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 - insets)
|
|
widthConstraint.isActive = true
|
|
defer { widthConstraint.isActive = false }
|
|
|
|
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
|
|
self.prototypeCell.frame.size.width = widthConstraint.constant
|
|
self.prototypeCell.layoutIfNeeded()
|
|
|
|
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
self.cachedItemSizes[itemID] = itemSize
|
|
return itemSize
|
|
}
|
|
|
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
|
{
|
|
let app = self.dataSource.item(at: indexPath)
|
|
let appViewController = AppViewController.makeAppViewController(app: app)
|
|
|
|
// Fall back to presentingViewController.navigationController in case we're being used for search results.
|
|
let navigationController = self.navigationController ?? self.presentingViewController?.navigationController
|
|
navigationController?.pushViewController(appViewController, animated: true)
|
|
}
|
|
}
|
|
|
|
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
|
{
|
|
@available(iOS, deprecated: 13.0)
|
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
|
{
|
|
guard
|
|
let indexPath = self.collectionView.indexPathForItem(at: location),
|
|
let cell = self.collectionView.cellForItem(at: indexPath)
|
|
else { return nil }
|
|
|
|
previewingContext.sourceRect = cell.frame
|
|
|
|
let app = self.dataSource.item(at: indexPath)
|
|
|
|
let appViewController = AppViewController.makeAppViewController(app: app)
|
|
return appViewController
|
|
}
|
|
|
|
@available(iOS, deprecated: 13.0)
|
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
|
{
|
|
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
|
|
}
|