mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-20 04:03:26 +01:00
Adds Drag & Drop support for activating/deactivating apps
This commit is contained in:
@@ -49,6 +49,10 @@ class InstalledApp: NSManagedObject, InstalledAppProtocol
|
|||||||
return self.storeApp == nil
|
return self.storeApp == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appIDCount: Int {
|
||||||
|
return 1 + self.appExtensions.count
|
||||||
|
}
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||||
{
|
{
|
||||||
super.init(entity: entity, insertInto: context)
|
super.init(entity: entity, insertInto: context)
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Roxas
|
||||||
|
|
||||||
class InstalledAppCollectionViewCell: UICollectionViewCell
|
class InstalledAppCollectionViewCell: UICollectionViewCell
|
||||||
{
|
{
|
||||||
|
private(set) var deactivateBadge: UIView?
|
||||||
|
|
||||||
@IBOutlet var bannerView: AppBannerView!
|
@IBOutlet var bannerView: AppBannerView!
|
||||||
|
|
||||||
override func awakeFromNib()
|
override func awakeFromNib()
|
||||||
@@ -18,6 +21,37 @@ class InstalledAppCollectionViewCell: UICollectionViewCell
|
|||||||
|
|
||||||
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
self.contentView.preservesSuperviewLayoutMargins = true
|
self.contentView.preservesSuperviewLayoutMargins = true
|
||||||
|
|
||||||
|
if #available(iOS 13.0, *)
|
||||||
|
{
|
||||||
|
let deactivateBadge = UIView()
|
||||||
|
deactivateBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
deactivateBadge.isHidden = true
|
||||||
|
self.addSubview(deactivateBadge)
|
||||||
|
|
||||||
|
// Solid background to make the X opaque white.
|
||||||
|
let backgroundView = UIView()
|
||||||
|
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backgroundView.backgroundColor = .white
|
||||||
|
deactivateBadge.addSubview(backgroundView)
|
||||||
|
|
||||||
|
let badgeView = UIImageView(image: UIImage(systemName: "xmark.circle.fill"))
|
||||||
|
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||||
|
badgeView.tintColor = .systemRed
|
||||||
|
deactivateBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
deactivateBadge.centerXAnchor.constraint(equalTo: self.bannerView.iconImageView.trailingAnchor),
|
||||||
|
deactivateBadge.centerYAnchor.constraint(equalTo: self.bannerView.iconImageView.topAnchor),
|
||||||
|
|
||||||
|
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||||
|
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||||
|
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||||
|
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||||
|
])
|
||||||
|
|
||||||
|
self.deactivateBadge = deactivateBadge
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class MyAppsViewController: UICollectionViewController
|
|||||||
private var isRefreshingAllApps = false
|
private var isRefreshingAllApps = false
|
||||||
private var refreshGroup: RefreshGroup?
|
private var refreshGroup: RefreshGroup?
|
||||||
private var sideloadingProgress: Progress?
|
private var sideloadingProgress: Progress?
|
||||||
|
private var dropDestinationIndexPath: IndexPath?
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
private var cachedUpdateSizes = [String: CGSize]()
|
private var cachedUpdateSizes = [String: CGSize]()
|
||||||
@@ -80,6 +81,9 @@ class MyAppsViewController: UICollectionViewController
|
|||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
self.collectionView.dataSource = self.dataSource
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
self.collectionView.prefetchDataSource = self.dataSource
|
||||||
|
self.collectionView.dragDelegate = self
|
||||||
|
self.collectionView.dropDelegate = self
|
||||||
|
self.collectionView.dragInteractionEnabled = true
|
||||||
|
|
||||||
self.prototypeUpdateCell = UpdateCollectionViewCell.instantiate(with: UpdateCollectionViewCell.nib!)
|
self.prototypeUpdateCell = UpdateCollectionViewCell.instantiate(with: UpdateCollectionViewCell.nib!)
|
||||||
self.prototypeUpdateCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
self.prototypeUpdateCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -279,6 +283,23 @@ private extension MyAppsViewController
|
|||||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||||
cell.tintColor = tintColor
|
cell.tintColor = tintColor
|
||||||
|
|
||||||
|
cell.deactivateBadge?.isHidden = false
|
||||||
|
|
||||||
|
if let dropIndexPath = self.dropDestinationIndexPath, dropIndexPath.section == Section.activeApps.rawValue && dropIndexPath.item == indexPath.item
|
||||||
|
{
|
||||||
|
cell.bannerView.alpha = 0.4
|
||||||
|
|
||||||
|
cell.deactivateBadge?.alpha = 1.0
|
||||||
|
cell.deactivateBadge?.transform = .identity
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cell.bannerView.alpha = 1.0
|
||||||
|
|
||||||
|
cell.deactivateBadge?.alpha = 0.0
|
||||||
|
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.33, y: 0.33)
|
||||||
|
}
|
||||||
|
|
||||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||||
cell.bannerView.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false)
|
cell.bannerView.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false)
|
||||||
|
|
||||||
@@ -371,6 +392,11 @@ private extension MyAppsViewController
|
|||||||
cell.bannerView.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false)
|
cell.bannerView.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false)
|
||||||
|
|
||||||
cell.bannerView.buttonLabel.isHidden = true
|
cell.bannerView.buttonLabel.isHidden = true
|
||||||
|
cell.bannerView.alpha = 1.0
|
||||||
|
|
||||||
|
cell.deactivateBadge?.isHidden = true
|
||||||
|
cell.deactivateBadge?.alpha = 0.0
|
||||||
|
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5)
|
||||||
|
|
||||||
cell.bannerView.button.isIndicatingActivity = false
|
cell.bannerView.button.isIndicatingActivity = false
|
||||||
cell.bannerView.button.tintColor = tintColor
|
cell.bannerView.button.tintColor = tintColor
|
||||||
@@ -824,28 +850,14 @@ private extension MyAppsViewController
|
|||||||
self.present(alertController, animated: true, completion: nil)
|
self.present(alertController, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentDeactivateAppAlert(completionHandler: @escaping (Bool) -> Void)
|
func updateCell(at indexPath: IndexPath)
|
||||||
{
|
{
|
||||||
let alertController = UIAlertController(title: NSLocalizedString("Cannot Activate More than 3 Apps", comment: ""), message: NSLocalizedString("Free developer accounts are limited to 3 active apps and app extensions. Please choose an app to deactivate.", comment: ""), preferredStyle: .alert)
|
guard let cell = collectionView.cellForItem(at: indexPath) as? InstalledAppCollectionViewCell else { return }
|
||||||
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { (action) in
|
|
||||||
completionHandler(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext)
|
let installedApp = self.dataSource.item(at: indexPath)
|
||||||
for app in activeApps where app.bundleIdentifier != StoreApp.altstoreAppID
|
self.dataSource.cellConfigurationHandler(cell, installedApp, indexPath)
|
||||||
{
|
|
||||||
alertController.addAction(UIAlertAction(title: app.name, style: .default) { (action) in
|
|
||||||
self.deactivate(app) { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure: completionHandler(false)
|
|
||||||
case .success: completionHandler(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
self.present(alertController, animated: true, completion: nil)
|
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,46 +886,47 @@ private extension MyAppsViewController
|
|||||||
|
|
||||||
func activate(_ installedApp: InstalledApp)
|
func activate(_ installedApp: InstalledApp)
|
||||||
{
|
{
|
||||||
if let sideloadedAppsLimit = UserDefaults.standard.activeAppsLimit
|
func activate()
|
||||||
{
|
{
|
||||||
let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext)
|
installedApp.isActive = true
|
||||||
let activeAppsCount = activeApps.reduce(0) { $0 + (1 + $1.appExtensions.count) } // As of iOS 13.3.1, app extensions count as "apps"
|
|
||||||
|
|
||||||
let availableActiveApps = max(sideloadedAppsLimit - activeAppsCount, 0)
|
AppManager.shared.activate(installedApp, presentingViewController: self) { (result) in
|
||||||
let requiredActiveAppSlots = 1 + installedApp.appExtensions.count
|
do
|
||||||
|
{
|
||||||
guard requiredActiveAppSlots <= availableActiveApps else {
|
let app = try result.get()
|
||||||
return self.presentDeactivateAppAlert { (shouldContinue) in
|
try? app.managedObjectContext?.save()
|
||||||
guard shouldContinue else { return }
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
print("Failed to activate app:", error)
|
||||||
|
|
||||||
installedApp.managedObjectContext?.perform {
|
DispatchQueue.main.async {
|
||||||
self.activate(installedApp)
|
installedApp.isActive = false
|
||||||
|
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.show(in: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !installedApp.isActive else { return }
|
if UserDefaults.standard.activeAppsLimit != nil
|
||||||
installedApp.isActive = true
|
{
|
||||||
|
self.deactivateApps(for: installedApp) { (shouldContinue) in
|
||||||
AppManager.shared.activate(installedApp, presentingViewController: self) { (result) in
|
if shouldContinue
|
||||||
do
|
{
|
||||||
{
|
activate()
|
||||||
let app = try result.get()
|
}
|
||||||
try? app.managedObjectContext?.save()
|
else
|
||||||
}
|
{
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to activate app:", error)
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
installedApp.isActive = false
|
installedApp.isActive = false
|
||||||
|
|
||||||
let toastView = ToastView(error: error)
|
|
||||||
toastView.show(in: self)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
activate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate(_ installedApp: InstalledApp, completionHandler: ((Result<InstalledApp, Error>) -> Void)? = nil)
|
func deactivate(_ installedApp: InstalledApp, completionHandler: ((Result<InstalledApp, Error>) -> Void)? = nil)
|
||||||
@@ -945,6 +958,53 @@ private extension MyAppsViewController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deactivateApps(for installedApp: InstalledApp, completion: @escaping (Bool) -> Void)
|
||||||
|
{
|
||||||
|
guard let activeAppsLimit = UserDefaults.standard.activeAppsLimit else { return completion(true) }
|
||||||
|
|
||||||
|
let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext)
|
||||||
|
.filter { $0.bundleIdentifier != installedApp.bundleIdentifier } // Don't count app towards total if it matches activating app
|
||||||
|
|
||||||
|
let activeAppsCount = activeApps.map { $0.appIDCount }.reduce(0, +)
|
||||||
|
|
||||||
|
let availableActiveApps = max(activeAppsLimit - activeAppsCount, 0)
|
||||||
|
guard installedApp.appIDCount > availableActiveApps else { return completion(true) }
|
||||||
|
|
||||||
|
let alertController = UIAlertController(title: NSLocalizedString("Cannot Activate More than 3 Apps", comment: ""), message: NSLocalizedString("Free developer accounts are limited to 3 active apps and app extensions. Please choose an app to deactivate.", comment: ""), preferredStyle: .alert)
|
||||||
|
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { (action) in
|
||||||
|
completion(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for app in activeApps where app.bundleIdentifier != StoreApp.altstoreAppID
|
||||||
|
{
|
||||||
|
alertController.addAction(UIAlertAction(title: app.name, style: .default) { (action) in
|
||||||
|
let availableActiveApps = availableActiveApps + app.appIDCount
|
||||||
|
if availableActiveApps >= installedApp.appIDCount
|
||||||
|
{
|
||||||
|
// There are enough slots now to activate the app, so pre-emptively
|
||||||
|
// mark it as active to provide visual feedback sooner.
|
||||||
|
installedApp.isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.deactivate(app) { (result) in
|
||||||
|
installedApp.managedObjectContext?.perform {
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure:
|
||||||
|
installedApp.isActive = false
|
||||||
|
completion(false)
|
||||||
|
|
||||||
|
case .success:
|
||||||
|
self.deactivateApps(for: installedApp, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
self.present(alertController, animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
func remove(_ installedApp: InstalledApp)
|
func remove(_ installedApp: InstalledApp)
|
||||||
{
|
{
|
||||||
let alertController = UIAlertController(title: nil, message: NSLocalizedString("Removing a sideloaded app only removes it from AltStore. You must also delete it from the home screen to fully uninstall the app.", comment: ""), preferredStyle: .actionSheet)
|
let alertController = UIAlertController(title: nil, message: NSLocalizedString("Removing a sideloaded app only removes it from AltStore. You must also delete it from the home screen to fully uninstall the app.", comment: ""), preferredStyle: .actionSheet)
|
||||||
@@ -1345,6 +1405,211 @@ extension MyAppsViewController: UICollectionViewDelegateFlowLayout
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MyAppsViewController: UICollectionViewDragDelegate
|
||||||
|
{
|
||||||
|
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem]
|
||||||
|
{
|
||||||
|
switch Section(rawValue: indexPath.section)!
|
||||||
|
{
|
||||||
|
case .updates, .noUpdates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
case .activeApps, .inactiveApps:
|
||||||
|
guard UserDefaults.standard.activeAppsLimit != nil else { return [] }
|
||||||
|
guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? InstalledAppCollectionViewCell else { return [] }
|
||||||
|
|
||||||
|
let item = self.dataSource.item(at: indexPath)
|
||||||
|
guard item.bundleIdentifier != StoreApp.altstoreAppID else { return [] }
|
||||||
|
|
||||||
|
let dragItem = UIDragItem(itemProvider: NSItemProvider(item: nil, typeIdentifier: nil))
|
||||||
|
dragItem.localObject = item
|
||||||
|
dragItem.previewProvider = {
|
||||||
|
let parameters = UIDragPreviewParameters()
|
||||||
|
parameters.backgroundColor = .clear
|
||||||
|
parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.iconImageView.bounds, cornerRadius: cell.bannerView.iconImageView.layer.cornerRadius)
|
||||||
|
|
||||||
|
let preview = UIDragPreview(view: cell.bannerView.iconImageView, parameters: parameters)
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
|
||||||
|
return [dragItem]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, dragPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters?
|
||||||
|
{
|
||||||
|
guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? InstalledAppCollectionViewCell else { return nil }
|
||||||
|
|
||||||
|
let parameters = UIDragPreviewParameters()
|
||||||
|
parameters.backgroundColor = .clear
|
||||||
|
parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.frame, cornerRadius: cell.bannerView.layer.cornerRadius)
|
||||||
|
|
||||||
|
return parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession)
|
||||||
|
{
|
||||||
|
let previousDestinationIndexPath = self.dropDestinationIndexPath
|
||||||
|
self.dropDestinationIndexPath = nil
|
||||||
|
|
||||||
|
if let indexPath = previousDestinationIndexPath
|
||||||
|
{
|
||||||
|
// Access cell directly to prevent UI glitches due to race conditions when refreshing
|
||||||
|
self.updateCell(at: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MyAppsViewController: UICollectionViewDropDelegate
|
||||||
|
{
|
||||||
|
func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool
|
||||||
|
{
|
||||||
|
return session.localDragSession != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal
|
||||||
|
{
|
||||||
|
guard
|
||||||
|
let activeAppsLimit = UserDefaults.standard.activeAppsLimit,
|
||||||
|
let installedApp = session.items.first?.localObject as? InstalledApp
|
||||||
|
else { return UICollectionViewDropProposal(operation: .cancel) }
|
||||||
|
|
||||||
|
// Retrieve header attributes for location calculations.
|
||||||
|
guard
|
||||||
|
let activeAppsHeaderAttributes = collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: Section.activeApps.rawValue)),
|
||||||
|
let inactiveAppsHeaderAttributes = collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: Section.inactiveApps.rawValue))
|
||||||
|
else { return UICollectionViewDropProposal(operation: .cancel) }
|
||||||
|
|
||||||
|
var dropDestinationIndexPath: IndexPath? = nil
|
||||||
|
|
||||||
|
defer
|
||||||
|
{
|
||||||
|
// Animate selection changes.
|
||||||
|
|
||||||
|
if dropDestinationIndexPath != self.dropDestinationIndexPath
|
||||||
|
{
|
||||||
|
let previousIndexPath = self.dropDestinationIndexPath
|
||||||
|
self.dropDestinationIndexPath = dropDestinationIndexPath
|
||||||
|
|
||||||
|
let indexPaths = [previousIndexPath, dropDestinationIndexPath].compactMap { $0 }
|
||||||
|
|
||||||
|
let propertyAnimator = UIViewPropertyAnimator(springTimingParameters: UISpringTimingParameters()) {
|
||||||
|
for indexPath in indexPaths
|
||||||
|
{
|
||||||
|
// Access cell directly so we can animate it correctly.
|
||||||
|
self.updateCell(at: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
propertyAnimator.startAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let point = session.location(in: collectionView)
|
||||||
|
|
||||||
|
if installedApp.isActive
|
||||||
|
{
|
||||||
|
// Deactivating
|
||||||
|
|
||||||
|
if point.y > inactiveAppsHeaderAttributes.frame.minY
|
||||||
|
{
|
||||||
|
// Inactive apps section.
|
||||||
|
return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
|
||||||
|
}
|
||||||
|
else if point.y > activeAppsHeaderAttributes.frame.minY
|
||||||
|
{
|
||||||
|
// Active apps section.
|
||||||
|
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return UICollectionViewDropProposal(operation: .cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Activating
|
||||||
|
|
||||||
|
guard point.y > activeAppsHeaderAttributes.frame.minY else {
|
||||||
|
// Above active apps section.
|
||||||
|
return UICollectionViewDropProposal(operation: .cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard point.y < inactiveAppsHeaderAttributes.frame.minY else {
|
||||||
|
// Inactive apps section.
|
||||||
|
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeAppsCount = (self.activeAppsDataSource.fetchedResultsController.fetchedObjects ?? []).map { $0.appIDCount }.reduce(0, +)
|
||||||
|
let availableActiveApps = max(activeAppsLimit - activeAppsCount, 0)
|
||||||
|
|
||||||
|
if installedApp.appIDCount <= availableActiveApps
|
||||||
|
{
|
||||||
|
// Enough active app slots, so no need to deactivate app first.
|
||||||
|
return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Not enough active app slots, so we need to deactivate an app.
|
||||||
|
|
||||||
|
// Provided destinationIndexPath is inaccurate.
|
||||||
|
guard let indexPath = collectionView.indexPathForItem(at: point), indexPath.section == Section.activeApps.rawValue else {
|
||||||
|
// Invalid destination index path.
|
||||||
|
return UICollectionViewDropProposal(operation: .cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
let installedApp = self.dataSource.item(at: indexPath)
|
||||||
|
guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else {
|
||||||
|
// Can't deactivate AltStore.
|
||||||
|
return UICollectionViewDropProposal(operation: .forbidden, intent: .insertIntoDestinationIndexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This app can be deactivated!
|
||||||
|
dropDestinationIndexPath = indexPath
|
||||||
|
return UICollectionViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator)
|
||||||
|
{
|
||||||
|
guard let installedApp = coordinator.session.items.first?.localObject as? InstalledApp else { return }
|
||||||
|
guard let destinationIndexPath = coordinator.destinationIndexPath else { return }
|
||||||
|
|
||||||
|
if installedApp.isActive
|
||||||
|
{
|
||||||
|
guard destinationIndexPath.section == Section.inactiveApps.rawValue else { return }
|
||||||
|
self.deactivate(installedApp)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
guard destinationIndexPath.section == Section.activeApps.rawValue else { return }
|
||||||
|
|
||||||
|
switch coordinator.proposal.intent
|
||||||
|
{
|
||||||
|
case .insertIntoDestinationIndexPath:
|
||||||
|
installedApp.isActive = true
|
||||||
|
|
||||||
|
let previousInstalledApp = self.dataSource.item(at: destinationIndexPath)
|
||||||
|
self.deactivate(previousInstalledApp) { (result) in
|
||||||
|
installedApp.managedObjectContext?.perform {
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure: installedApp.isActive = false
|
||||||
|
case .success: self.activate(installedApp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .insertAtDestinationIndexPath:
|
||||||
|
self.activate(installedApp)
|
||||||
|
|
||||||
|
case .unspecified: break
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension MyAppsViewController: NSFetchedResultsControllerDelegate
|
extension MyAppsViewController: NSFetchedResultsControllerDelegate
|
||||||
{
|
{
|
||||||
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
|
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
|
||||||
|
|||||||
Reference in New Issue
Block a user