diff --git a/CHANGELOG.md b/CHANGELOG.md index be3a6d97..bcbc4b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,31 @@ The changelog for `JSQCoreDataKit`. Also see the [releases](https://github.com/j NEXT ----- +- TBA + +10.0.0 +------ + +This release closes the [10.0.0 milestone](https://github.com/jessesquires/JSQCoreDataKit/milestone/21?closed=1). + +### New + +- Introduced a new component, `FetchedResultsCoordinator`, which handles all of the boilerplate for setting up an `NSFetchedResultsController` and updating the content of a `UICollectionView`. All you need to do is provide a `FetchedResultsCellConfiguration` and an `NSFetchRequest`. See the documentation and example project for details. (#201) + +- Added `FetchedResultsController`, a generic subclass of `NSFetchedResultsController` that's nicer to use in Swift. + +### Changed + - Upgraded to Xcode 12.5 and Swift 5.4 +### Breaking + +- Dropped support for old OS versions: + - iOS 14+ now required + - tvOS 14+ now required + - watchOS 6+ now required + - macOS 10.14+ now required + 9.0.3 ----- diff --git a/Example/ExampleApp.xcodeproj/project.pbxproj b/Example/ExampleApp.xcodeproj/project.pbxproj index 8bc92bc2..0d7a2f31 100644 --- a/Example/ExampleApp.xcodeproj/project.pbxproj +++ b/Example/ExampleApp.xcodeproj/project.pbxproj @@ -7,9 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 0BE5B6D1269113940015587B /* CollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6D0269113940015587B /* CollectionViewCell.swift */; }; + 0BE5B6D3269114BB0015587B /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6D2269114BB0015587B /* Extensions.swift */; }; + 0BE5B6DB269142F30015587B /* CompanyCellConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6DA269142F30015587B /* CompanyCellConfig.swift */; }; + 0BE5B6DD269144A30015587B /* EmployeeCellConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6DC269144A30015587B /* EmployeeCellConfig.swift */; }; + 0BE5B6E326916F1D0015587B /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6E226916F1D0015587B /* CollectionViewController.swift */; }; 885394211BC9E9DE00699506 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885394201BC9E9DE00699506 /* AppDelegate.swift */; }; 885394231BC9E9DE00699506 /* CompanyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885394221BC9E9DE00699506 /* CompanyViewController.swift */; }; - 885394261BC9E9DE00699506 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 885394241BC9E9DE00699506 /* Main.storyboard */; }; 885394281BC9E9DE00699506 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 885394271BC9E9DE00699506 /* Assets.xcassets */; }; 8853942B1BC9E9DE00699506 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 885394291BC9E9DE00699506 /* LaunchScreen.storyboard */; }; 88B9F8C91BCA0A510053CD6D /* EmployeeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B9F8C81BCA0A510053CD6D /* EmployeeViewController.swift */; }; @@ -67,10 +71,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0BE5B6D0269113940015587B /* CollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewCell.swift; sourceTree = ""; }; + 0BE5B6D2269114BB0015587B /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 0BE5B6DA269142F30015587B /* CompanyCellConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanyCellConfig.swift; sourceTree = ""; }; + 0BE5B6DC269144A30015587B /* EmployeeCellConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeCellConfig.swift; sourceTree = ""; }; + 0BE5B6E226916F1D0015587B /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = ""; }; 8853941D1BC9E9DE00699506 /* ExampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 885394201BC9E9DE00699506 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 885394221BC9E9DE00699506 /* CompanyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanyViewController.swift; sourceTree = ""; }; - 885394251BC9E9DE00699506 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 885394271BC9E9DE00699506 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 8853942A1BC9E9DE00699506 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 8853942C1BC9E9DE00699506 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -135,11 +143,15 @@ children = ( 885394201BC9E9DE00699506 /* AppDelegate.swift */, 885394271BC9E9DE00699506 /* Assets.xcassets */, + 0BE5B6D0269113940015587B /* CollectionViewCell.swift */, + 0BE5B6E226916F1D0015587B /* CollectionViewController.swift */, + 0BE5B6DA269142F30015587B /* CompanyCellConfig.swift */, 885394221BC9E9DE00699506 /* CompanyViewController.swift */, + 0BE5B6DC269144A30015587B /* EmployeeCellConfig.swift */, 88B9F8C81BCA0A510053CD6D /* EmployeeViewController.swift */, + 0BE5B6D2269114BB0015587B /* Extensions.swift */, 8853942C1BC9E9DE00699506 /* Info.plist */, 885394291BC9E9DE00699506 /* LaunchScreen.storyboard */, - 885394241BC9E9DE00699506 /* Main.storyboard */, ); path = ExampleApp; sourceTree = ""; @@ -292,7 +304,6 @@ files = ( 8853942B1BC9E9DE00699506 /* LaunchScreen.storyboard in Resources */, 885394281BC9E9DE00699506 /* Assets.xcassets in Resources */, - 885394261BC9E9DE00699506 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -310,8 +321,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0BE5B6D3269114BB0015587B /* Extensions.swift in Sources */, + 0BE5B6DD269144A30015587B /* EmployeeCellConfig.swift in Sources */, + 0BE5B6DB269142F30015587B /* CompanyCellConfig.swift in Sources */, 885394231BC9E9DE00699506 /* CompanyViewController.swift in Sources */, + 0BE5B6E326916F1D0015587B /* CollectionViewController.swift in Sources */, 88B9F8C91BCA0A510053CD6D /* EmployeeViewController.swift in Sources */, + 0BE5B6D1269113940015587B /* CollectionViewCell.swift in Sources */, 885394211BC9E9DE00699506 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -335,14 +351,6 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - 885394241BC9E9DE00699506 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 885394251BC9E9DE00699506 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 885394291BC9E9DE00699506 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -402,7 +410,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -454,7 +462,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; diff --git a/Example/ExampleApp/AppDelegate.swift b/Example/ExampleApp/AppDelegate.swift index 2bd6f10b..c03889db 100644 --- a/Example/ExampleApp/AppDelegate.swift +++ b/Example/ExampleApp/AppDelegate.swift @@ -20,12 +20,15 @@ import UIKit @UIApplicationMain final class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? + let window = UIWindow() func application(_ application: UIApplication, // swiftlint:disable:next discouraged_optional_collection didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - true + let viewController = CompanyViewController() + let navigationController = UINavigationController(rootViewController: viewController) + self.window.rootViewController = navigationController + self.window.makeKeyAndVisible() + return true } } diff --git a/Example/ExampleApp/Base.lproj/Main.storyboard b/Example/ExampleApp/Base.lproj/Main.storyboard deleted file mode 100644 index e2f75872..00000000 --- a/Example/ExampleApp/Base.lproj/Main.storyboard +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Example/ExampleApp/CollectionViewCell.swift b/Example/ExampleApp/CollectionViewCell.swift new file mode 100644 index 00000000..27e84063 --- /dev/null +++ b/Example/ExampleApp/CollectionViewCell.swift @@ -0,0 +1,30 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import UIKit + +final class CollectionViewCell: UICollectionViewListCell { + + func configure(primaryText: String, secondaryText: String) { + var contentConfiguration = UIListContentConfiguration.subtitleCell() + contentConfiguration.text = primaryText + contentConfiguration.secondaryText = secondaryText + self.contentConfiguration = contentConfiguration + self.accessories = [.disclosureIndicator()] + } +} diff --git a/Example/ExampleApp/CollectionViewController.swift b/Example/ExampleApp/CollectionViewController.swift new file mode 100644 index 00000000..1b7d2f6f --- /dev/null +++ b/Example/ExampleApp/CollectionViewController.swift @@ -0,0 +1,53 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import UIKit + +class CollectionViewController: UICollectionViewController { + + init() { + super.init(collectionViewLayout: UICollectionViewCompositionalLayout.list()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.navigationItem.rightBarButtonItems = [ + UIBarButtonItem.add(target: self, selector: #selector(didTapAdd(_:))), + UIBarButtonItem.trash(target: self, selector: #selector(didTapDelete(_:))) + ] + } + + @objc + private func didTapAdd(_ sender: UIBarButtonItem) { + self.addAction() + } + + func addAction() { } + + @objc + private func didTapDelete(_ sender: UIBarButtonItem) { + self.deleteAction() + } + + func deleteAction() { } +} diff --git a/Example/ExampleApp/CompanyCellConfig.swift b/Example/ExampleApp/CompanyCellConfig.swift new file mode 100644 index 00000000..d7d469a5 --- /dev/null +++ b/Example/ExampleApp/CompanyCellConfig.swift @@ -0,0 +1,28 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import ExampleModel +import JSQCoreDataKit +import UIKit + +struct CompanyCellConfig: FetchedResultsCellConfiguration { + + func configure(cell: CollectionViewCell, with object: Company) { + cell.configure(primaryText: object.name, secondaryText: "$\(object.profits).00") + } +} diff --git a/Example/ExampleApp/CompanyViewController.swift b/Example/ExampleApp/CompanyViewController.swift index 1d5df28b..4409269e 100644 --- a/Example/ExampleApp/CompanyViewController.swift +++ b/Example/ExampleApp/CompanyViewController.swift @@ -21,40 +21,44 @@ import ExampleModel import JSQCoreDataKit import UIKit -final class CompanyViewController: UITableViewController, NSFetchedResultsControllerDelegate { +final class CompanyViewController: CollectionViewController { var stack: CoreDataStack! - var frc: NSFetchedResultsController! + + var coordinator: FetchedResultsCoordinator? // MARK: View lifecycle override func viewDidLoad() { super.viewDidLoad() - - showSpinner() + self.title = "JSQCoreDataKit" let model = CoreDataModel(name: modelName, bundle: modelBundle) - let factory = CoreDataStackProvider(model: model) - - factory.createStack { result in + let provider = CoreDataStackProvider(model: model) + provider.createStack { result in switch result { case .success(let stack): self.stack = stack - self.setupFRC() + self.setupCoordinator() + self.coordinator?.performFetch() case .failure(let err): assertionFailure("Error creating stack: \(err)") } - - self.hideSpinner() } } // MARK: Actions - @IBAction func didTapTrashButton(_ sender: UIBarButtonItem) { - let backgroundChildContext = stack.childContext(concurrencyType: .privateQueueConcurrencyType) + override func addAction() { + self.stack.mainContext.performAndWait { + _ = Company.newCompany(self.stack.mainContext) + self.stack.mainContext.saveSync() + } + } + override func deleteAction() { + let backgroundChildContext = stack.childContext(concurrencyType: .privateQueueConcurrencyType) backgroundChildContext.performAndWait { do { let objects = try backgroundChildContext.fetch(Company.fetchRequest) @@ -68,148 +72,24 @@ final class CompanyViewController: UITableViewController, NSFetchedResultsContro } } - @objc - func didTapAddButton(_ sender: UIBarButtonItem) { - stack.mainContext.performAndWait { - _ = Company.newCompany(self.stack.mainContext) - self.stack.mainContext.saveSync() - } - } - - // MARK: Segues - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "segue" { - let employeeVC = segue.destination as! EmployeeViewController - let company = frc.object(at: tableView.indexPathForSelectedRow!) - employeeVC.stack = stack - employeeVC.company = company - } - } - // MARK: Helpers - func setupFRC() { - frc = NSFetchedResultsController(fetchRequest: Company.fetchRequest, - managedObjectContext: stack.mainContext, - sectionNameKeyPath: nil, - cacheName: nil) - - frc.delegate = self - fetchData() - } - - func fetchData() { - do { - try frc.performFetch() - tableView.reloadData() - } catch { - assertionFailure("Failed to fetch: \(error)") - } - } - - private func showSpinner() { - let spinner = UIActivityIndicatorView(style: .gray) - spinner.startAnimating() - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) - } - - private func hideSpinner() { - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .add, - target: self, - action: #selector(didTapAddButton(_:))) - } - - // MARK: Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - frc?.fetchedObjects?.count ?? 0 - } - - func configureCell(_ cell: UITableViewCell, atIndexPath indexPath: IndexPath) { - let company = frc.object(at: indexPath) - cell.textLabel?.text = company.name - cell.detailTextLabel?.text = "$\(company.profits).00" - cell.accessoryType = .disclosureIndicator - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - configureCell(cell, atIndexPath: indexPath) - return cell + func setupCoordinator() { + self.coordinator = FetchedResultsCoordinator( + fetchRequest: Company.fetchRequest, + context: self.stack.mainContext, + sectionNameKeyPath: nil, + cacheName: nil, + cellConfiguration: CompanyCellConfig(), + collectionView: self.collectionView + ) } - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - "Company" - } - - // MARK: Table view delegate - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - true - } - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - if editingStyle == .delete { - let obj = frc.object(at: indexPath) - stack.mainContext.performAndWait { - self.stack.mainContext.delete(obj) - } - stack.mainContext.saveSync() - } - } - - // MARK: Fetched results controller delegate - - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - tableView.beginUpdates() - } - - func controller(_ controller: NSFetchedResultsController, - didChange sectionInfo: NSFetchedResultsSectionInfo, - atSectionIndex sectionIndex: Int, - for type: NSFetchedResultsChangeType) { - switch type { - case .insert: - tableView.insertSections(IndexSet(integer: sectionIndex), with: .fade) - - case .delete: - tableView.deleteSections(IndexSet(integer: sectionIndex), with: .fade) - - default: - break - } - } - - func controller(_ controller: NSFetchedResultsController, - didChange anObject: Any, - at indexPath: IndexPath?, - for type: NSFetchedResultsChangeType, - newIndexPath: IndexPath?) { - switch type { - case .insert: - tableView.insertRows(at: [newIndexPath!], with: .fade) - - case .delete: - tableView.deleteRows(at: [indexPath!], with: .fade) - - case .update: - configureCell(tableView.cellForRow(at: indexPath!)!, atIndexPath: indexPath!) - - case .move: - tableView.deleteRows(at: [indexPath!], with: .fade) - tableView.insertRows(at: [newIndexPath!], with: .fade) - @unknown default: - fatalError("Unknown change type \(type)") - } - } + // MARK: Collection view delegate - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - tableView.endUpdates() + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let company = self.coordinator![indexPath] + let employeeVC = EmployeeViewController(stack: self.stack, company: company) + self.navigationController?.pushViewController(employeeVC, animated: true) } } diff --git a/Example/ExampleApp/EmployeeCellConfig.swift b/Example/ExampleApp/EmployeeCellConfig.swift new file mode 100644 index 00000000..0e3e9613 --- /dev/null +++ b/Example/ExampleApp/EmployeeCellConfig.swift @@ -0,0 +1,28 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import ExampleModel +import JSQCoreDataKit +import UIKit + +struct EmployeeCellConfig: FetchedResultsCellConfiguration { + + func configure(cell: CollectionViewCell, with object: Employee) { + cell.configure(primaryText: object.name, secondaryText: "$\(object.salary).00") + } +} diff --git a/Example/ExampleApp/EmployeeViewController.swift b/Example/ExampleApp/EmployeeViewController.swift index 727d989c..f6774b60 100644 --- a/Example/ExampleApp/EmployeeViewController.swift +++ b/Example/ExampleApp/EmployeeViewController.swift @@ -21,34 +21,53 @@ import ExampleModel import JSQCoreDataKit import UIKit -final class EmployeeViewController: UITableViewController, NSFetchedResultsControllerDelegate { +final class EmployeeViewController: CollectionViewController { - var stack: CoreDataStack! - var frc: NSFetchedResultsController! - var company: Company! + let stack: CoreDataStack + let company: Company + + lazy var coordinator: FetchedResultsCoordinator = { + FetchedResultsCoordinator( + fetchRequest: Employee.fetchRequest(for: self.company), + context: self.stack.mainContext, + sectionNameKeyPath: nil, + cacheName: nil, + cellConfiguration: EmployeeCellConfig(), + collectionView: self.collectionView + ) + }() + + // MARK: Init + + init(stack: CoreDataStack, company: Company) { + self.stack = stack + self.company = company + super.init() + } // MARK: View lifecycle override func viewDidLoad() { super.viewDidLoad() - tableView.allowsSelection = false - setupFRC() + self.title = self.company.name + self.collectionView.allowsSelection = false + self.coordinator.performFetch() } // MARK: Actions - @IBAction func didTapAddButton(_ sender: UIBarButtonItem) { - stack.mainContext.performAndWait { + override func addAction() { + self.stack.mainContext.performAndWait { _ = Employee.newEmployee(self.stack.mainContext, company: self.company) self.stack.mainContext.saveSync() } } - @IBAction func didTapTrashButton(_ sender: UIBarButtonItem) { - let backgroundChildContext = stack.childContext() + override func deleteAction() { + let backgroundChildContext = self.stack.childContext() backgroundChildContext.performAndWait { do { - let objects = try backgroundChildContext.fetch(self.fetchRequest()) + let objects = try backgroundChildContext.fetch(Employee.fetchRequest(for: self.company)) for each in objects { backgroundChildContext.delete(each) } @@ -58,121 +77,4 @@ final class EmployeeViewController: UITableViewController, NSFetchedResultsContr } } } - - // MARK: Helpers - - func fetchRequest() -> NSFetchRequest { - let fetch = Employee.fetchRequest - fetch.predicate = NSPredicate(format: "company == %@", company) - return fetch - } - - func setupFRC() { - frc = NSFetchedResultsController(fetchRequest: fetchRequest(), - managedObjectContext: stack.mainContext, - sectionNameKeyPath: nil, - cacheName: nil) - frc.delegate = self - fetchData() - } - - func fetchData() { - do { - try frc.performFetch() - tableView.reloadData() - } catch { - assertionFailure("Failed to fetch: \(error)") - } - } - - // MARK: Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - frc?.fetchedObjects?.count ?? 0 - } - - func configureCell(_ cell: UITableViewCell, atIndexPath indexPath: IndexPath) { - let employee = frc.object(at: indexPath) - cell.textLabel?.text = employee.name - cell.detailTextLabel?.text = "$\(employee.salary).00" - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - configureCell(cell, atIndexPath: indexPath) - return cell - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - company.name - } - - // MARK: Table view delegate - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - true - } - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - if editingStyle == .delete { - let obj = frc.object(at: indexPath) - stack.mainContext.performAndWait { - self.stack.mainContext.delete(obj) - } - stack.mainContext.saveSync() - } - } - - // MARK: Fetched results controller delegate - - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - tableView.beginUpdates() - } - - func controller(_ controller: NSFetchedResultsController, - didChange sectionInfo: NSFetchedResultsSectionInfo, - atSectionIndex sectionIndex: Int, - for type: NSFetchedResultsChangeType) { - switch type { - case .insert: - tableView.insertSections(IndexSet(integer: sectionIndex), with: .fade) - - case .delete: - tableView.deleteSections(IndexSet(integer: sectionIndex), with: .fade) - - default: - break - } - } - - func controller(_ controller: NSFetchedResultsController, - didChange anObject: Any, - at indexPath: IndexPath?, - for type: NSFetchedResultsChangeType, - newIndexPath: IndexPath?) { - switch type { - case .insert: - tableView.insertRows(at: [newIndexPath!], with: .fade) - - case .delete: - tableView.deleteRows(at: [indexPath!], with: .fade) - - case .update: - configureCell(tableView.cellForRow(at: indexPath!)!, atIndexPath: indexPath!) - - case .move: - tableView.deleteRows(at: [indexPath!], with: .fade) - tableView.insertRows(at: [newIndexPath!], with: .fade) - @unknown default: - fatalError("Unknown change type \(type)") - } - } - - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - tableView.endUpdates() - } } diff --git a/Example/ExampleApp/Extensions.swift b/Example/ExampleApp/Extensions.swift new file mode 100644 index 00000000..0e4359c2 --- /dev/null +++ b/Example/ExampleApp/Extensions.swift @@ -0,0 +1,44 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import Foundation +import UIKit + +extension UICollectionViewCompositionalLayout { + + static func list() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { _, layoutEnvironment in + let configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + // configuration.headerMode = .supplementary + // configuration.footerMode = .supplementary + let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) + return section + } + } +} + +extension UIBarButtonItem { + + static func add(target: Any, selector: Selector) -> UIBarButtonItem { + UIBarButtonItem(barButtonSystemItem: .add, target: target, action: selector) + } + + static func trash(target: Any, selector: Selector) -> UIBarButtonItem { + UIBarButtonItem(barButtonSystemItem: .trash, target: target, action: selector) + } +} diff --git a/Example/ExampleApp/Info.plist b/Example/ExampleApp/Info.plist index 40c6215d..eabb3ae3 100644 --- a/Example/ExampleApp/Info.plist +++ b/Example/ExampleApp/Info.plist @@ -24,8 +24,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 diff --git a/Example/ExampleModel/ExampleModel.xcodeproj/project.pbxproj b/Example/ExampleModel/ExampleModel.xcodeproj/project.pbxproj index 42af2b92..d226bdb5 100644 --- a/Example/ExampleModel/ExampleModel.xcodeproj/project.pbxproj +++ b/Example/ExampleModel/ExampleModel.xcodeproj/project.pbxproj @@ -236,7 +236,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; MACOSX_DEPLOYMENT_TARGET = 10.12; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; @@ -295,7 +295,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; MACOSX_DEPLOYMENT_TARGET = 10.12; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; diff --git a/JSQCoreDataKit.xcodeproj/project.pbxproj b/JSQCoreDataKit.xcodeproj/project.pbxproj index 0ea520cb..67188bd5 100644 --- a/JSQCoreDataKit.xcodeproj/project.pbxproj +++ b/JSQCoreDataKit.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 0B1AD67726812DB300F17925 /* ModelFileExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B1AD67626812DB300F17925 /* ModelFileExtension.swift */; }; + 0BE5B6D726913EA60015587B /* FetchedResultsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6D626913EA60015587B /* FetchedResultsCoordinator.swift */; }; + 0BE5B6D926913F0D0015587B /* FetchedResultsCellConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6D826913F0D0015587B /* FetchedResultsCellConfiguration.swift */; }; + 0BE5B6DF26914BCA0015587B /* FetchedResultsDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6DE26914BCA0015587B /* FetchedResultsDiffableDataSource.swift */; }; + 0BE5B6E12691503E0015587B /* FetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE5B6E02691503E0015587B /* FetchedResultsController.swift */; }; 297C7D2D1C710A1D008E516B /* MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297C7D2C1C710A1D008E516B /* MigrationTests.swift */; }; 298F00F71C9C559100AEC77E /* Migrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 298F00F61C9C559100AEC77E /* Migrate.swift */; }; 881219C81B361EAF005E5AA7 /* JSQCoreDataKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 88002D4F1AB8F546001787DB /* JSQCoreDataKit.framework */; }; @@ -56,6 +60,10 @@ /* Begin PBXFileReference section */ 0B1AD67626812DB300F17925 /* ModelFileExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileExtension.swift; sourceTree = ""; }; + 0BE5B6D626913EA60015587B /* FetchedResultsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchedResultsCoordinator.swift; sourceTree = ""; }; + 0BE5B6D826913F0D0015587B /* FetchedResultsCellConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchedResultsCellConfiguration.swift; sourceTree = ""; }; + 0BE5B6DE26914BCA0015587B /* FetchedResultsDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchedResultsDiffableDataSource.swift; sourceTree = ""; }; + 0BE5B6E02691503E0015587B /* FetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchedResultsController.swift; sourceTree = ""; }; 297C7D2C1C710A1D008E516B /* MigrationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MigrationTests.swift; sourceTree = ""; }; 298F00F61C9C559100AEC77E /* Migrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Migrate.swift; sourceTree = ""; }; 88002D4F1AB8F546001787DB /* JSQCoreDataKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = JSQCoreDataKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -124,11 +132,15 @@ children = ( 8852A0CC1D459048006538B9 /* CoreDataEntityProtocol.swift */, 8873E21F1C51E09700DFE009 /* CoreDataModel.swift */, - 0B1AD67626812DB300F17925 /* ModelFileExtension.swift */, 8873E2211C51E09700DFE009 /* CoreDataStack.swift */, 8873E2221C51E09700DFE009 /* CoreDataStackProvider.swift */, + 0BE5B6D826913F0D0015587B /* FetchedResultsCellConfiguration.swift */, + 0BE5B6E02691503E0015587B /* FetchedResultsController.swift */, + 0BE5B6D626913EA60015587B /* FetchedResultsCoordinator.swift */, + 0BE5B6DE26914BCA0015587B /* FetchedResultsDiffableDataSource.swift */, 8873E2241C51E09700DFE009 /* Info.plist */, 298F00F61C9C559100AEC77E /* Migrate.swift */, + 0B1AD67626812DB300F17925 /* ModelFileExtension.swift */, 8873E21E1C51E09700DFE009 /* NSManagedObjectContext+Extensions.swift */, 8873E2261C51E09700DFE009 /* StoreType.swift */, ); @@ -311,13 +323,17 @@ buildActionMask = 2147483647; files = ( 8873E22C1C51E09700DFE009 /* CoreDataStackProvider.swift in Sources */, + 0BE5B6D726913EA60015587B /* FetchedResultsCoordinator.swift in Sources */, 0B1AD67726812DB300F17925 /* ModelFileExtension.swift in Sources */, 8852A0CD1D459048006538B9 /* CoreDataEntityProtocol.swift in Sources */, 8873E2301C51E09700DFE009 /* StoreType.swift in Sources */, + 0BE5B6DF26914BCA0015587B /* FetchedResultsDiffableDataSource.swift in Sources */, 8873E22B1C51E09700DFE009 /* CoreDataStack.swift in Sources */, 298F00F71C9C559100AEC77E /* Migrate.swift in Sources */, 8873E2291C51E09700DFE009 /* CoreDataModel.swift in Sources */, 8873E2281C51E09700DFE009 /* NSManagedObjectContext+Extensions.swift in Sources */, + 0BE5B6D926913F0D0015587B /* FetchedResultsCellConfiguration.swift in Sources */, + 0BE5B6E12691503E0015587B /* FetchedResultsController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -405,8 +421,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MACOSX_DEPLOYMENT_TARGET = 10.12; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = JSQCoreDataKit; @@ -416,10 +432,10 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4"; - TVOS_DEPLOYMENT_TARGET = 12.0; + TVOS_DEPLOYMENT_TARGET = 14.1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 4.0; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Debug; }; @@ -466,8 +482,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MACOSX_DEPLOYMENT_TARGET = 10.12; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = JSQCoreDataKit; RESOURCES_TARGETED_DEVICE_FAMILY = ""; @@ -475,11 +491,11 @@ SUPPORTED_PLATFORMS = "iphoneos appletvos watchos macosx iphonesimulator appletvsimulator watchsimulator"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4"; - TVOS_DEPLOYMENT_TARGET = 12.0; + TVOS_DEPLOYMENT_TARGET = 14.1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 4.0; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Release; }; diff --git a/Sources/FetchedResultsCellConfiguration.swift b/Sources/FetchedResultsCellConfiguration.swift new file mode 100644 index 00000000..5e4d4634 --- /dev/null +++ b/Sources/FetchedResultsCellConfiguration.swift @@ -0,0 +1,44 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +#if os(iOS) || os(tvOS) + +import CoreData +import UIKit + +public protocol FetchedResultsCellConfiguration { + + associatedtype Cell: UICollectionViewCell + + associatedtype Object: NSManagedObject + + typealias Registration = UICollectionView.CellRegistration + + func configure(cell: Cell, with object: Object) +} + +extension FetchedResultsCellConfiguration { + + public var registration: Registration { + Registration { cell, _, object in + self.configure(cell: cell, with: object) + } + } +} + +#endif diff --git a/Sources/FetchedResultsController.swift b/Sources/FetchedResultsController.swift new file mode 100644 index 00000000..e0ba9356 --- /dev/null +++ b/Sources/FetchedResultsController.swift @@ -0,0 +1,85 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import CoreData +import Foundation + +/// A generic `NSFetchedResultsController`. +public final class FetchedResultsController: NSFetchedResultsController { + + // MARK: Init + + /// Returns a fetch request controller initialized using the given arguments. + /// + /// - Parameters: + /// - fetchRequest: The fetch request used to get the objects. + /// - context: The managed object against which `fetchRequest` is executed. + /// - sectionNameKeyPath: A key path on result objects that returns the section name. + /// - cacheName: The name of the cache file the receiver should use. + /// + /// - Returns: An initialized fetch request controller. + public init(fetchRequest: NSFetchRequest, + context: NSManagedObjectContext, + sectionNameKeyPath: String?, + cacheName: String?) { + super.init( + fetchRequest: fetchRequest as! NSFetchRequest, + managedObjectContext: context, + sectionNameKeyPath: sectionNameKeyPath, + cacheName: cacheName + ) + } + + // MARK: Subscripts + + /// - Parameter indexPath: An index path of an object. + /// - Returns: The object at `indexPath`. + public subscript (indexPath: IndexPath) -> ObjectType { + self.object(at: indexPath) + } + + // MARK: Methods + + public func deleteCache() { + FetchedResultsController.deleteCache(withName: self.cacheName) + } + + public func numberOfSections() -> Int { + self.sections().count + } + + public func sections() -> [NSFetchedResultsSectionInfo] { + self.sections ?? [] + } + + public func numberOfItems(in section: Int) -> Int { + self.sections?[section].numberOfObjects ?? 0 + } + + public func fetchedObjects() -> [ObjectType] { + (self.fetchedObjects ?? []) as! [ObjectType] + } + + public func object(at indexPath: IndexPath) -> ObjectType { + super.object(at: indexPath) as! ObjectType + } + + public func indexPath(for object: ObjectType) -> IndexPath? { + self.indexPath(forObject: object) + } +} diff --git a/Sources/FetchedResultsCoordinator.swift b/Sources/FetchedResultsCoordinator.swift new file mode 100644 index 00000000..3f4f811d --- /dev/null +++ b/Sources/FetchedResultsCoordinator.swift @@ -0,0 +1,114 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +#if os(iOS) || os(tvOS) + +import CoreData +import Foundation +import UIKit + +// swiftlint:disable:next colon +public final class FetchedResultsCoordinator: + NSObject, NSFetchedResultsControllerDelegate where CellConfig.Object == Object { + + public let controller: FetchedResultsController + + public let cellConfiguration: CellConfig + + public var animateUpdates = true + + private let _dataSource: _FetchedResultsDiffableDataSource + + private unowned var _collectionView: UICollectionView + + public init(fetchRequest: NSFetchRequest, + context: NSManagedObjectContext, + sectionNameKeyPath: String?, + cacheName: String?, + cellConfiguration: CellConfig, + collectionView: UICollectionView) { + let controller = FetchedResultsController( + fetchRequest: fetchRequest, + context: context, + sectionNameKeyPath: sectionNameKeyPath, + cacheName: cacheName + ) + + self.controller = controller + self.cellConfiguration = cellConfiguration + self._collectionView = collectionView + self._dataSource = _FetchedResultsDiffableDataSource( + collectionView: collectionView, + controller: controller, + cellConfig: cellConfiguration + ) + + super.init() + controller.delegate = self + collectionView.dataSource = self._dataSource + } + + // MARK: Subscripts + + public subscript (indexPath: IndexPath) -> Object { + self.controller[indexPath] + } + + // MARK: Methods + + public func performFetch() { + do { + try self.controller.performFetch() + } catch { + assertionFailure("FetchedResultsController failed to perform fetch: \(error)") + } + } + + public func object(at indexPath: IndexPath) -> Object { + self.controller.object(at: indexPath) + } + + // MARK: NSFetchedResultsControllerDelegate + + /// :nodoc: + public func controller(_ controller: NSFetchedResultsController, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + var fetchedSnapshot = snapshot as _FetchedResultsDiffableSnapshot + let currentSnapshot = self._dataSource.snapshot() + + // taken from: https://www.avanderlee.com/swift/diffable-data-sources-core-data/ + let reloadIdentifiers: [NSManagedObjectID] = fetchedSnapshot.itemIdentifiers.compactMap { itemIdentifier in + guard let currentIndex = currentSnapshot.indexOfItem(itemIdentifier), + let index = fetchedSnapshot.indexOfItem(itemIdentifier), + index == currentIndex else { + return nil + } + guard let existingObject = try? controller.managedObjectContext.existingObject(with: itemIdentifier), + existingObject.isUpdated else { + return nil + } + return itemIdentifier + } + fetchedSnapshot.reloadItems(reloadIdentifiers) + + let shouldAnimate = self.animateUpdates && self._collectionView.numberOfSections != 0 + self._dataSource.apply(fetchedSnapshot, animatingDifferences: shouldAnimate) + } +} + +#endif diff --git a/Sources/FetchedResultsDiffableDataSource.swift b/Sources/FetchedResultsDiffableDataSource.swift new file mode 100644 index 00000000..0a32f04b --- /dev/null +++ b/Sources/FetchedResultsDiffableDataSource.swift @@ -0,0 +1,44 @@ +// +// Created by Jesse Squires +// https://www.jessesquires.com +// +// +// Documentation +// https://jessesquires.github.io/JSQCoreDataKit +// +// +// GitHub +// https://github.com/jessesquires/JSQCoreDataKit +// +// +// License +// Copyright © 2015-present Jesse Squires +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +#if os(iOS) || os(tvOS) + +import CoreData +import UIKit + +typealias _FetchedResultsDiffableDataSource = UICollectionViewDiffableDataSource + +typealias _FetchedResultsDiffableSnapshot = NSDiffableDataSourceSnapshot + +extension _FetchedResultsDiffableDataSource { + + convenience init( + collectionView: UICollectionView, + controller: FetchedResultsController, + cellConfig: CellConfig) where CellConfig.Object == Object { + let registration = cellConfig.registration + + self.init(collectionView: collectionView) { collectionView, indexPath, objectID in + let object = controller.object(at: indexPath) + precondition(object.objectID == objectID) + return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: object) + } + } +} + +#endif