diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.MainView.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.MainView.swift index 3bfeb9b..6f5eff2 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.MainView.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.MainView.swift @@ -72,7 +72,7 @@ extension Modern.ColorsDemo { // MARK: Private @LiveList(Modern.ColorsDemo.palettesPublisher) - private var palettes: LiveList.Items + private var palettes: ListSnapshot private let listView: ( _ listPublisher: ListPublisher, diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.UIKit.DetailView.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.UIKit.DetailView.swift index edae002..bedbe52 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.UIKit.DetailView.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.UIKit.DetailView.swift @@ -30,14 +30,19 @@ extension Modern.ColorsDemo.UIKit { return UIViewControllerType(self.palette) } - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Self.Context) {} + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Self.Context) { + + uiViewController.palette = Modern.ColorsDemo.dataStack.monitorObject( + self.palette.object! + ) + } static func dismantleUIViewController(_ uiViewController: UIViewControllerType, coordinator: Void) {} // MARK: Private - private let palette: ObjectPublisher + private var palette: ObjectPublisher } } diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.swift index 31ad4b3..510b292 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/Modern.ColorsDemo.swift @@ -46,7 +46,10 @@ extension Modern { static let palettesPublisher: ListPublisher = Modern.ColorsDemo.dataStack.publishList( From() - .sectionBy(\.$colorGroup) + .sectionBy( + \.$colorGroup, + sectionIndexTransformer: { $0?.first?.uppercased() } + ) .where(Modern.ColorsDemo.filter.whereClause()) .orderBy(.ascending(\.$hue)) ) @@ -57,7 +60,10 @@ extension Modern { try! Modern.ColorsDemo.palettesPublisher.refetch( From() - .sectionBy(\.$colorGroup) + .sectionBy( + \.$colorGroup, + sectionIndexTransformer: { $0?.first?.uppercased() } + ) .where(self.filter.whereClause()) .orderBy(.ascending(\.$hue)) ) diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.DetailView.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.DetailView.swift index e0fe2dc..7068502 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.DetailView.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.DetailView.swift @@ -15,10 +15,10 @@ extension Modern.ColorsDemo.SwiftUI { struct DetailView: View { /** - ⭐️ Sample 1: Setting an `ObjectPublisher` declared as an `@ObservedObject` + ⭐️ Sample 1: Using a `LiveObject` to observe object changes. Note that the `ObjectSnapshot` is always `Optional` */ @LiveObject - private var palette: LiveObject.Item? + private var palette: ObjectSnapshot? /** ⭐️ Sample 2: Setting properties that can be binded to controls (`Slider` in this case) by creating custom `@Binding` instances that updates the store when the values change. diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ItemView.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ItemView.swift index 3a64513..ea79711 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ItemView.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ItemView.swift @@ -14,22 +14,22 @@ extension Modern.ColorsDemo.SwiftUI { struct ItemView: View { /** - ⭐️ Sample 1: Setting an `ObjectPublisher` declared as an `@ObservedObject` + ⭐️ Sample 1: Using a `LiveObject` to observe object changes. Note that the `ObjectSnapshot` is always `Optional` */ @LiveObject - private var palette: LiveObject.Item? - - - // MARK: Internal + private var palette: ObjectSnapshot? + /** + ⭐️ Sample 2: Initializing a `LiveObject` from an existing `ObjectPublisher` + */ internal init(_ palette: ObjectPublisher) { self._palette = .init(palette) } - - // MARK: View - + /** + ⭐️ Sample 3: Readding values directly from the `ObjectSnapshot` + */ var body: some View { if let palette = self.palette { diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ListView.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ListView.swift index 884ea46..17114bf 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ListView.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.SwiftUI.ListView.swift @@ -14,22 +14,35 @@ extension Modern.ColorsDemo.SwiftUI { struct ListView: View { /** - ⭐️ Sample 1: Setting a sectioned `ListPublisher` declared as an `@ObservedObject` + ⭐️ Sample 1: Using a `LiveList` to observe list changes */ @LiveList - private var palettes: LiveList.Items + private var palettes: ListSnapshot /** - ⭐️ Sample 2: Assigning sections and items of the `ListPublisher` to corresponding `View`s + ⭐️ Sample 2: Initializing a `LiveList` from an existing `ListPublisher` */ - var body: some View { - return List { + init( + listPublisher: ListPublisher, + onPaletteTapped: @escaping (ObjectPublisher) -> Void + ) { + + self._palettes = .init(listPublisher) + self.onPaletteTapped = onPaletteTapped + } + + /** + ⭐️ Sample 3: Assigning sections and items of the `ListSnapshot` to corresponding `View`s by using the correct `ForEach` overloads. + */ + var body: some View { + + List { - ForEachSection(in: self.palettes) { sectionID, palettes in + ForEach(sectionIn: self.palettes) { section in - Section(header: Text(sectionID)) { + Section(header: Text(section.sectionID)) { - ForEach(palettes) { palette in + ForEach(objectIn: section) { palette in Button( action: { @@ -45,7 +58,7 @@ extension Modern.ColorsDemo.SwiftUI { } .onDelete { itemIndices in - self.deleteColors(at: itemIndices, in: sectionID) + self.deleteColors(at: itemIndices, in: section.sectionID) } } } @@ -53,18 +66,6 @@ extension Modern.ColorsDemo.SwiftUI { .animation(.default) .listStyle(PlainListStyle()) .edgesIgnoringSafeArea([]) - } - - - // MARK: Internal - - init( - listPublisher: ListPublisher, - onPaletteTapped: @escaping (ObjectPublisher) -> Void - ) { - - self._palettes = .init(listPublisher) - self.onPaletteTapped = onPaletteTapped } diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.DetailViewController.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.DetailViewController.swift index 7eb8b92..f342082 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.DetailViewController.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.DetailViewController.swift @@ -40,6 +40,15 @@ extension Modern.ColorsDemo.UIKit { /** ⭐️ Sample 3: We can end monitoring updates anytime. `removeObserver()` was called here for illustration purposes only. `ObjectMonitor`s safely remove deallocated observers automatically. */ + var palette: ObjectMonitor { + + didSet { + + oldValue.removeObserver(self) + self.startMonitoringObject() + } + } + deinit { self.palette.removeObserver(self) @@ -183,9 +192,8 @@ extension Modern.ColorsDemo.UIKit { equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10 ), - containerView.bottomAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.bottomAnchor, - constant: -10 + containerView.centerYAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.centerYAnchor ), containerView.trailingAnchor.constraint( equalTo: view.safeAreaLayoutGuide.trailingAnchor, @@ -223,8 +231,6 @@ extension Modern.ColorsDemo.UIKit { // MARK: Private - private let palette: ObjectMonitor - @available(*, unavailable) required init?(coder: NSCoder) { diff --git a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.ListViewController.swift b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.ListViewController.swift index 11be071..da31101 100644 --- a/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.ListViewController.swift +++ b/Demo/⭐️Sources/⭐️Demos/⭐️Modern/⭐️ColorsDemo/⭐️Modern.ColorsDemo.UIKit.ListViewController.swift @@ -15,9 +15,9 @@ extension Modern.ColorsDemo.UIKit { final class ListViewController: UITableViewController { /** - ⭐️ Sample 1: Setting up a `DiffableDataSource.TableViewAdapter` that will manage tableView snapshot updates automatically. We can use the built-in `DiffableDataSource.TableViewAdapter` type directly, but in our case we want to enabled `UITableView` cell deletions so we create a custom subclass `DeletionEnabledDataSource` (see declaration below). + ⭐️ Sample 1: Setting up a `DiffableDataSource.TableViewAdapter` that will manage tableView snapshot updates automatically. We can use the built-in `DiffableDataSource.TableViewAdapter` type directly, but in our case we want to enabled `UITableView` cell deletions so we create a custom subclass `CustomDataSource` (see declaration below). */ - private lazy var dataSource: DiffableDataSource.TableViewAdapter = DeletionEnabledDataSource( + private lazy var dataSource: DiffableDataSource.TableViewAdapter = CustomDataSource( tableView: self.tableView, dataStack: Modern.ColorsDemo.dataStack, cellProvider: { (tableView, indexPath, palette) in @@ -54,9 +54,11 @@ extension Modern.ColorsDemo.UIKit { } /** - ⭐️ Sample 4: This is the custom `DiffableDataSource.TableViewAdapter` subclass we wrote that enabled swipe-to-delete gestures on the `UITableView`. + ⭐️ Sample 4: This is the custom `DiffableDataSource.TableViewAdapter` subclass we wrote that enabled swipe-to-delete gestures and section index titles on the `UITableView`. */ - final class DeletionEnabledDataSource: DiffableDataSource.TableViewAdapter { + final class CustomDataSource: DiffableDataSource.TableViewAdapter { + + // MARK: UITableViewDataSource override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { @@ -79,6 +81,16 @@ extension Modern.ColorsDemo.UIKit { break } } + + override func sectionIndexTitles(for tableView: UITableView) -> [String]? { + + return self.sectionIndexTitlesForAllSections().compactMap({ $0 }) + } + + override func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int { + + return index + } } diff --git a/Sources/DataStack+DataSources.swift b/Sources/DataStack+DataSources.swift index a1817f4..d6cc5e1 100644 --- a/Sources/DataStack+DataSources.swift +++ b/Sources/DataStack+DataSources.swift @@ -56,6 +56,60 @@ extension DataStack { return context.objectPublisher(objectID: objectID) } + /** + Creates a `ListPublisher` that satisfy the specified `FetchChainableBuilderType` built from a chain of clauses. + ``` + let listPublisher = dataStack.listPublisher( + From() + .where(\.age > 18) + .orderBy(.ascending(\.age)) + ) + ``` + Multiple objects may then register themselves to be notified when changes are made to the fetched results. + ``` + listPublisher.addObserver(self) { (listPublisher) in + // handle changes + } + ``` + - parameter clauseChain: a `FetchChainableBuilderType` built from a chain of clauses + - returns: a `ListPublisher` that broadcasts changes to the fetched results + */ + public func publishList(_ clauseChain: B) -> ListPublisher { + + return self.publishList( + clauseChain.from, + clauseChain.fetchClauses + ) + } + + /** + Creates a `ListPublisher` for a sectioned list that satisfy the specified `FetchChainableBuilderType` built from a chain of clauses. + ``` + let listPublisher = dataStack.listPublisher( + From() + .sectionBy(\.age, { "\($0!) years old" }) + .where(\.age > 18) + .orderBy(.ascending(\.age)) + ) + ``` + Multiple objects may then register themselves to be notified when changes are made to the fetched results. + ``` + listPublisher.addObserver(self) { (listPublisher) in + // handle changes + } + ``` + - parameter clauseChain: a `SectionMonitorBuilderType` built from a chain of clauses + - returns: a `ListPublisher` that broadcasts changes to the fetched results + */ + public func publishList(_ clauseChain: B) -> ListPublisher { + + return self.publishList( + clauseChain.from, + clauseChain.sectionBy, + clauseChain.fetchClauses + ) + } + /** Creates a `ListPublisher` for the specified `From` and `FetchClause`s. Multiple objects may then register themselves to be notified when changes are made to the fetched results. @@ -93,32 +147,6 @@ extension DataStack { ) } - /** - Creates a `ListPublisher` that satisfy the specified `FetchChainableBuilderType` built from a chain of clauses. - ``` - let listPublisher = dataStack.listPublisher( - From() - .where(\.age > 18) - .orderBy(.ascending(\.age)) - ) - ``` - Multiple objects may then register themselves to be notified when changes are made to the fetched results. - ``` - listPublisher.addObserver(self) { (listPublisher) in - // handle changes - } - ``` - - parameter clauseChain: a `FetchChainableBuilderType` built from a chain of clauses - - returns: a `ListPublisher` that broadcasts changes to the fetched results - */ - public func publishList(_ clauseChain: B) -> ListPublisher { - - return self.publishList( - clauseChain.from, - clauseChain.fetchClauses - ) - } - /** Creates a `ListPublisher` for a sectioned list that satisfy the fetch clauses. Multiple objects may then register themselves to be notified when changes are made to the fetched results. @@ -161,37 +189,9 @@ extension DataStack { } ) } - - /** - Creates a `ListPublisher` for a sectioned list that satisfy the specified `FetchChainableBuilderType` built from a chain of clauses. - ``` - let listPublisher = dataStack.listPublisher( - From() - .sectionBy(\.age, { "\($0!) years old" }) - .where(\.age > 18) - .orderBy(.ascending(\.age)) - ) - ``` - Multiple objects may then register themselves to be notified when changes are made to the fetched results. - ``` - listPublisher.addObserver(self) { (listPublisher) in - // handle changes - } - ``` - - parameter clauseChain: a `SectionMonitorBuilderType` built from a chain of clauses - - returns: a `ListPublisher` that broadcasts changes to the fetched results - */ - public func publishList(_ clauseChain: B) -> ListPublisher { - - return self.publishList( - clauseChain.from, - clauseChain.sectionBy, - clauseChain.fetchClauses - ) - } - // MARK: - Deprecated + // MARK: Deprecated @available(*, deprecated, renamed: "publishObject(_:)") public func objectPublisher(_ object: O) -> ObjectPublisher { diff --git a/Sources/DataStack+Reactive.swift b/Sources/DataStack+Reactive.swift new file mode 100644 index 0000000..f9cdab8 --- /dev/null +++ b/Sources/DataStack+Reactive.swift @@ -0,0 +1,275 @@ +// +// DataStack+Reactive.swift +// CoreStore +// +// Copyright © 2021 John Rommel Estropia +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +#if canImport(Combine) + +import Combine + + +// MARK: - DataStack + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +extension DataStack: PublisherCompatible {} + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +extension PublisherProvider where Base == DataStack { + + // MARK: Public + + /** + Reactive extension for `CoreStore.DataStack`'s `importObject(...)` API. Creates an `ImportableObject` by importing from the specified import source. The event value will be the object instance correctly associated for the `DataStack`. + ``` + dataStack.reactive + .importObject( + Into(), + source: ["name": "John"] + ) + .sink( + receiveCompletion: { result in + // ... + }, + receiveValue: { (person) in + XCTAssertNotNil(person) + // ... + } + ) + .store(in: &cancellables) + ``` + - parameter into: an `Into` clause specifying the entity type + - parameter source: the object to import values from + - returns: A `Future` that publishes the imported object. The event value, if not `nil`, will be the object instance correctly associated for the `DataStack`. + */ + public func importObject( + _ into: Into, + source: O.ImportSource + ) -> Future { + + return .init { (promise) in + + self.base.perform( + asynchronous: { (transaction) -> O? in + + return try transaction.importObject( + into, + source: source + ) + }, + success: { promise(.success($0.flatMap(self.base.fetchExisting))) }, + failure: { promise(.failure($0)) } + ) + } + } + + /** + Reactive extension for `CoreStore.DataStack`'s `importObject(...)` API. Updates an existing `ImportableObject` by importing values from the specified import source. The event value will be the object instance correctly associated for the `DataStack`. + ``` + let existingPerson: Person = // ... + dataStack.reactive + .importObject( + existingPerson, + source: ["name": "John", "age": 30] + ) + .sink( + receiveCompletion: { result in + // ... + }, + receiveValue: { (person) in + XCTAssertEqual(person?.age, 30) + // ... + } + ) + .store(in: &cancellables) + ``` + - parameter object: the object to update + - parameter source: the object to import values from + - returns: A `Future` that publishes the imported object. The event value, if not `nil`, will be the object instance correctly associated for the `DataStack`. + */ + public func importObject( + _ object: O, + source: O.ImportSource + ) -> Future { + + return .init { (promise) in + + self.base.perform( + asynchronous: { (transaction) -> O? in + + guard let object = transaction.edit(object) else { + + try transaction.cancel() + } + try transaction.importObject( + object, + source: source + ) + return object + }, + success: { promise(.success($0.flatMap(self.base.fetchExisting))) }, + failure: { promise(.failure($0)) } + ) + } + } + + /** + Reactive extension for `CoreStore.DataStack`'s `importUniqueObject(...)` API. Updates an existing `ImportableUniqueObject` or creates a new instance by importing from the specified import source. The event value will be the object instance correctly associated for the `DataStack`. + ``` + dataStack.reactive + .importUniqueObject( + Into(), + source: ["name": "John", "age": 30] + ) + .sink( + receiveCompletion: { result in + // ... + }, + receiveValue: { (person) in + XCTAssertEqual(person?.age, 30) + // ... + } + ) + .store(in: &cancellables) + ``` + - parameter into: an `Into` clause specifying the entity type + - parameter source: the object to import values from + - returns: A `Future` for the imported object. The event value, if not `nil`, will be the object instance correctly associated for the `DataStack`. + */ + public func importUniqueObject( + _ into: Into, + source: O.ImportSource + ) -> Future { + + return .init { (promise) in + + self.base.perform( + asynchronous: { (transaction) -> O? in + + return try transaction.importUniqueObject( + into, + source: source + ) + }, + success: { promise(.success($0.flatMap(self.base.fetchExisting))) }, + failure: { promise(.failure($0)) } + ) + } + } + + /** + Reactive extension for `CoreStore.DataStack`'s `importUniqueObjects(...)` API. Updates existing `ImportableUniqueObject`s or creates them by importing from the specified array of import sources. `ImportableUniqueObject` methods are called on the objects in the same order as they are in the `sourceArray`, and are returned in an array with that same order. The event values will be object instances correctly associated for the `DataStack`. + ``` + dataStack.reactive + .importUniqueObjects( + Into(), + sourceArray: [ + ["name": "John"], + ["name": "Bob"], + ["name": "Joe"] + ] + ) + .sink( + receiveCompletion: { result in + // ... + }, + receiveValue: { (people) in + XCTAssertEqual(people?.count, 3) + // ... + } + ) + .store(in: &cancellables) + ``` + - Warning: If `sourceArray` contains multiple import sources with same ID, no merging will occur and ONLY THE LAST duplicate will be imported. + - parameter into: an `Into` clause specifying the entity type + - parameter sourceArray: the array of objects to import values from + - parameter preProcess: a closure that lets the caller tweak the internal `UniqueIDType`-to-`ImportSource` mapping to be used for importing. Callers can remove from/add to/update `mapping` and return the updated array from the closure. + - returns: A `Future` for the imported objects. The event values will be the object instances correctly associated for the `DataStack`. + */ + public func importUniqueObjects( + _ into: Into, + sourceArray: S, + preProcess: @escaping (_ mapping: [O.UniqueIDType: O.ImportSource]) throws -> [O.UniqueIDType: O.ImportSource] = { $0 } + ) -> Future<[O], CoreStoreError> where S.Iterator.Element == O.ImportSource { + + return .init { (promise) in + + self.base.perform( + asynchronous: { (transaction) -> [O] in + + return try transaction.importUniqueObjects( + into, + sourceArray: sourceArray, + preProcess: preProcess + ) + }, + success: { promise(.success(self.base.fetchExisting($0))) }, + failure: { promise(.failure($0)) } + ) + } + } + + /** + Reactive extension for `CoreStore.DataStack`'s `perform(asynchronous:...)` API. Performs a transaction asynchronously where `NSManagedObject` creates, updates, and deletes can be made. The changes are commited automatically after the `task` closure returns. The event value will be the value returned from the `task` closure. Any errors thrown from inside the `task` will be wrapped in a `CoreStoreError` and reported to the completion `.failure`. To cancel/rollback changes, call `transaction.cancel()`, which throws a `CoreStoreError.userCancelled`. + ``` + dataStack.reactive + .perform( + asynchronous: { (transaction) -> (inserted: Set, deleted: Set) in + + // ... + return ( + transaction.insertedObjects(), + transaction.deletedObjects() + ) + } + ) + .sink( + receiveCompletion: { result in + // ... + }, + receiveValue: { value in + let inserted = dataStack.fetchExisting(value0.inserted) + let deleted = dataStack.fetchExisting(value0.deleted) + // ... + } + ) + .store(in: &cancellables) + ``` + - parameter task: the asynchronous closure where creates, updates, and deletes can be made to the transaction. Transaction blocks are executed serially in a background queue, and all changes are made from a concurrent `NSManagedObjectContext`. + - returns: A `Future` whose event value be the value returned from the `task` closure. + */ + public func perform( + _ asynchronous: @escaping (AsynchronousDataTransaction) throws -> Output + ) -> Future { + + return .init { (promise) in + + self.base.perform( + asynchronous: asynchronous, + success: { promise(.success($0)) }, + failure: { promise(.failure($0)) } + ) + } + } +} + +#endif diff --git a/Sources/DiffableDataSource.BaseAdapter.swift b/Sources/DiffableDataSource.BaseAdapter.swift index f511cbd..eae290b 100644 --- a/Sources/DiffableDataSource.BaseAdapter.swift +++ b/Sources/DiffableDataSource.BaseAdapter.swift @@ -209,6 +209,25 @@ extension DiffableDataSource { return self.dispatcher.indexPath(for: itemID) } + + /** + Returns the section index title for the specified `section` if the `SectionBy` for this list has provided a `sectionIndexTransformer` + + - parameter section: the section index to search for + - returns: the section index title for the specified `section`, or `nil` if not found + */ + public func sectionIndexTitle(for section: Int) -> String? { + + return self.dispatcher.sectionIndexTitle(for: section) + } + + /** + Returns the section index titles for all sections if the `SectionBy` for this list has provided a `sectionIndexTransformer` + */ + public func sectionIndexTitlesForAllSections() -> [String?] { + + return self.dispatcher.sectionIndexTitlesForAllSections() + } // MARK: Internal diff --git a/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift b/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift index 860a578..d533af4 100644 --- a/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift +++ b/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift @@ -220,7 +220,7 @@ extension DiffableDataSource { } -// MARK: - Deprecated +// MARK: Deprecated extension DiffableDataSource { diff --git a/Sources/DiffableDataSource.CollectionViewAdapter-UIKit.swift b/Sources/DiffableDataSource.CollectionViewAdapter-UIKit.swift index 6dfb141..2294f1f 100644 --- a/Sources/DiffableDataSource.CollectionViewAdapter-UIKit.swift +++ b/Sources/DiffableDataSource.CollectionViewAdapter-UIKit.swift @@ -220,7 +220,7 @@ extension DiffableDataSource { } -// MARK: - Deprecated +// MARK: Deprecated extension DiffableDataSource { diff --git a/Sources/DiffableDataSource.TableViewAdapter-UIKit.swift b/Sources/DiffableDataSource.TableViewAdapter-UIKit.swift index eb7f8ab..c390202 100644 --- a/Sources/DiffableDataSource.TableViewAdapter-UIKit.swift +++ b/Sources/DiffableDataSource.TableViewAdapter-UIKit.swift @@ -157,6 +157,18 @@ extension DiffableDataSource { @objc open dynamic func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {} + + @objc + open dynamic func sectionIndexTitles(for tableView: UITableView) -> [String]? { + + return nil + } + + @objc + open dynamic func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int { + + return index + } // MARK: Private @@ -255,7 +267,7 @@ extension DiffableDataSource { } -// MARK: - Deprecated +// MARK: Deprecated extension DiffableDataSource { diff --git a/Sources/ForEach+SwiftUI.swift b/Sources/ForEach+SwiftUI.swift index bb0aa2f..45dd41e 100644 --- a/Sources/ForEach+SwiftUI.swift +++ b/Sources/ForEach+SwiftUI.swift @@ -36,21 +36,137 @@ extension ForEach where Content: View { // MARK: Public + /** + Creates an instance that creates views for each object in a `ListSnapshot`. + ``` + @LiveList + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(objectIn: self.people) { person in + + ProfileView(person) + } + } + .animation(.default) + } + ``` + + - parameter listSnapshot: The `ListSnapshot` that the `ForEach` instance uses to create views dynamically + - parameter content: The view builder that receives an `ObjectPublisher` instance and creates views dynamically. + */ public init( - _ listSnapshot: Data, + objectIn listSnapshot: Data, @ViewBuilder content: @escaping (ObjectPublisher) -> Content - ) where Data == LiveList.Items, ID == O.ObjectID { + ) where Data == ListSnapshot, ID == O.ObjectID { self.init(listSnapshot, id: \.cs_objectID, content: content) } + /** + Creates an instance that creates views for each object in a collection of `ObjectPublisher`s. + ``` + let people: [ObjectPublisher] + + var body: some View { + + List { + + ForEach(objectIn: self.people) { person in + + ProfileView(person) + } + } + .animation(.default) + } + ``` + + - parameter objectPublishers: The collection of `ObjectPublisher`s that the `ForEach` instance uses to create views dynamically + - parameter content: The view builder that receives an `ObjectPublisher` instance and creates views dynamically. + */ public init( - _ objectPublishers: Data, + objectIn objectPublishers: Data, @ViewBuilder content: @escaping (ObjectPublisher) -> Content ) where Data.Element == ObjectPublisher, ID == O.ObjectID { self.init(objectPublishers, id: \.cs_objectID, content: content) } + + /** + Creates an instance that creates views for `ListSnapshot` sections. + ``` + @LiveList + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(sectionIn: self.people) { section in + + Section(header: Text(section.sectionID)) { + + ForEach(objectIn: section) { person in + + ProfileView(person) + } + } + } + } + .animation(.default) + } + ``` + + - parameter listSnapshot: The `ListSnapshot` that the `ForEach` instance uses to create views dynamically + - parameter content: The view builder that receives a `ListSnapshot.SectionInfo` instance and creates views dynamically. + */ + public init( + sectionIn listSnapshot: ListSnapshot, + @ViewBuilder content: @escaping (ListSnapshot.SectionInfo) -> Content + ) where Data == [ListSnapshot.SectionInfo], ID == ListSnapshot.SectionID { + + let sections = listSnapshot.sections() + self.init(sections, id: \.sectionID, content: content) + } + + /** + Creates an instance that creates views for each object in a `ListSnapshot.SectionInfo`. + ``` + @LiveList + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(sectionIn: self.people) { section in + + Section(header: Text(section.sectionID)) { + + ForEach(objectIn: section) { person in + + ProfileView(person) + } + } + } + } + .animation(.default) + } + ``` + + - parameter sectionInfo: The `ListSnapshot.SectionInfo` that the `ForEach` instance uses to create views dynamically + - parameter content: The view builder that receives an `ObjectPublisher` instance and creates views dynamically. + */ + public init( + objectIn sectionInfo: Data, + @ViewBuilder content: @escaping (ObjectPublisher) -> Content + ) where Data == ListSnapshot.SectionInfo, ID == O.ObjectID { + + self.init(sectionInfo, id: \.cs_objectID, content: content) + } } #endif diff --git a/Sources/ForEachSection.swift b/Sources/ForEachSection.swift deleted file mode 100644 index 5680626..0000000 --- a/Sources/ForEachSection.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// ForEachSection.swift -// CoreStore -// -// Created by John Rommel Estropia on 2021/02/13. -// Copyright © 2021 John Rommel Estropia. All rights reserved. -// - -#if canImport(Combine) && canImport(SwiftUI) - -import Combine -import SwiftUI - - -// MARK: - ForEachSection - -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -public struct ForEachSection: View { - - // MARK: Internal - - public init( - in listSnapshot: LiveList.Items, - @ViewBuilder sections: @escaping ( - _ sectionID: ListSnapshot.SectionID, - _ objects: [ObjectPublisher] - ) -> Sections - ) { - - self.listSnapshot = listSnapshot - self.sections = sections - } - - - // MARK: View - - public var body: some View { - - ForEach(self.listSnapshot.sectionIDs, id: \.self) { sectionID in - - self.sections( - sectionID, - self.listSnapshot.items(inSectionWithID: sectionID) - ) - } - } - - - // MARK: Private - - private let listSnapshot: LiveList.Items - - private let sections: ( - _ sectionID: ListSnapshot.SectionID, - _ objects: [ObjectPublisher] - ) -> Sections -} - -#endif diff --git a/Sources/Internals.DiffableDataUIDispatcher.swift b/Sources/Internals.DiffableDataUIDispatcher.swift index 15f70b9..a09202c 100644 --- a/Sources/Internals.DiffableDataUIDispatcher.swift +++ b/Sources/Internals.DiffableDataUIDispatcher.swift @@ -192,9 +192,18 @@ extension Internals { return self.sections[section].elements.count } - func sectionIndexTitles() -> [String] { + func sectionIndexTitle(for section: Int) -> String? { - return self.sections.compactMap({ $0.indexTitle }) + guard self.sections.indices.contains(section) else { + + return nil + } + return self.sections[section].indexTitle + } + + func sectionIndexTitlesForAllSections() -> [String?] { + + return self.sections.map({ $0.indexTitle }) } diff --git a/Sources/ListReader.swift b/Sources/ListReader.swift index 9fcd253..5dfaa28 100644 --- a/Sources/ListReader.swift +++ b/Sources/ListReader.swift @@ -31,24 +31,69 @@ import SwiftUI // MARK: - ListReader +/** + A container view that reads list changes in a `ListPublisher` + */ @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) public struct ListReader: View { // MARK: Internal + /** + Creates an instance that creates views for `ListPublisher` changes. + ``` + let people: ListPublisher + + var body: some View { + + List { + + ListReader(self.people) { listSnapshot in + + ForEach(objectIn: listSnapshot) { person in + + ProfileView(person) + } + } + } + .animation(.default) + } + ``` + + - parameter listPublisher: The `ListPublisher` that the `ListReader` instance uses to create views dynamically + - parameter content: The view builder that receives an `ListSnapshot` instance and creates views dynamically. + */ public init( _ listPublisher: ListPublisher, - @ViewBuilder content: @escaping (Value) -> Content - ) where Value == LiveList.Items { + @ViewBuilder content: @escaping (ListSnapshot) -> Content + ) where Value == ListSnapshot { self._list = .init(listPublisher) self.content = content self.keyPath = \.self } + /** + Creates an instance that creates views for `ListPublisher` changes. + ``` + let people: ListPublisher + + var body: some View { + + ListReader(self.people, keyPath: \.count) { count in + + Text("Number of members: \(count)") + } + } + ``` + + - parameter listPublisher: The `ListPublisher` that the `ListReader` instance uses to create views dynamically + - parameter keyPath: A `KeyPath` for a property in the `ListSnapshot` whose value will be sent to the views + - parameter content: The view builder that receives the value from the property `KeyPath` and creates views dynamically. + */ public init( _ listPublisher: ListPublisher, - keyPath: KeyPath.Items, Value>, + keyPath: KeyPath, Value>, @ViewBuilder content: @escaping (Value) -> Content ) { @@ -69,10 +114,10 @@ public struct ListReader: View { // MARK: Private @LiveList - private var list: LiveList.Items + private var list: ListSnapshot private let content: (Value) -> Content - private let keyPath: KeyPath.Items, Value> + private let keyPath: KeyPath, Value> } #endif diff --git a/Sources/ListSnapshot.SectionInfo.swift b/Sources/ListSnapshot.SectionInfo.swift new file mode 100644 index 0000000..fa12f9a --- /dev/null +++ b/Sources/ListSnapshot.SectionInfo.swift @@ -0,0 +1,132 @@ +// +// ListSnapshot.SectionInfo.swift +// CoreStore +// +// Copyright © 2018 John Rommel Estropia +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import CoreData + + +// MARK: - ListSnapshot + +extension ListSnapshot { + + // MARK: - SectionInfo + + public struct SectionInfo: Hashable, RandomAccessCollection { + + // MARK: Private + + public let sectionID: SectionID + + public let itemIDs: [ItemID] + + + // MARK: RandomAccessCollection + + public var startIndex: Index { + + return self.itemIDs.startIndex + } + + public var endIndex: Index { + + return self.itemIDs.endIndex + } + + public func index(after i: Index) -> Index { + + return self.itemIDs.index(after: i) + } + + public func formIndex(after i: inout Index) { + + self.itemIDs.formIndex(after: &i) + } + + public func index(before i: Index) -> Index { + + return self.itemIDs.index(before: i) + } + + public func formIndex(before i: inout Index) { + + self.itemIDs.formIndex(before: &i) + } + + + // MARK: BidirectionalCollection + + public subscript(position: Int) -> ObjectPublisher { + + let itemID = self.itemIDs[position] + return self.context.objectPublisher(objectID: itemID) + } + + public func index(_ i: Index, offsetBy distance: Int) -> Index { + + return self.itemIDs.index(i, offsetBy: distance) + } + + public func index(_ i: Index, offsetBy distance: Int, limitedBy limit: Int) -> Index? { + + return self.itemIDs.index(i, offsetBy: distance, limitedBy: limit) + } + + public func distance(from start: Index, to end: Index) -> Int { + + return self.itemIDs.distance(from: start, to: end) + } + + public subscript(bounds: Range) -> ArraySlice { + + let itemIDs = self.itemIDs[bounds] + return ArraySlice(itemIDs.map(self.context.objectPublisher(objectID:))) + } + + + // MARK: Sequence + + public typealias Element = ObjectPublisher + + public typealias Index = Int + + + // MARK: Internal + + internal let context: NSManagedObjectContext + + internal init?( + sectionID: SectionID, + listSnapshot: ListSnapshot + ) { + + guard let context = listSnapshot.context else { + + return nil + } + self.sectionID = sectionID + self.itemIDs = listSnapshot.itemIDs(inSectionWithID: sectionID) + self.context = context + } + } +} diff --git a/Sources/ListSnapshot.swift b/Sources/ListSnapshot.swift index 4510f69..3e0390f 100644 --- a/Sources/ListSnapshot.swift +++ b/Sources/ListSnapshot.swift @@ -283,6 +283,29 @@ public struct ListSnapshot: RandomAccessCollection, Hashable { return self.diffableSnapshot.sectionIdentifier(containingItem: itemID) } + + /** + Returns an array of `SectionInfo` instances that contains a collection of `ObjectPublisher` items for each section. + */ + public func sections() -> [SectionInfo] { + + return self + .sectionIDs + .compactMap({ SectionInfo(sectionID: $0, listSnapshot: self) }) + } + + /** + Returns the `SectionInfo` that the specified `ItemID` belongs to, or `nil` if it is not in the list. + + - parameter itemID: the `ItemID` + - returns: the `SectionInfo` that the specified `ItemID` belongs to, or `nil` if it is not in the list + */ + public func section(containingItemWithID itemID: ItemID) -> SectionInfo? { + + return self + .sectionID(containingItemWithID: itemID) + .flatMap({ SectionInfo(sectionID: $0, listSnapshot: self) }) + } /** All object identifiers in the `ListSnapshot` @@ -688,6 +711,7 @@ public struct ListSnapshot: RandomAccessCollection, Hashable { // MARK: Internal + internal let context: NSManagedObjectContext? internal private(set) var diffableSnapshot: Internals.DiffableDataSourceSnapshot internal init() { @@ -696,7 +720,10 @@ public struct ListSnapshot: RandomAccessCollection, Hashable { self.context = nil } - internal init(diffableSnapshot: Internals.DiffableDataSourceSnapshot, context: NSManagedObjectContext) { + internal init( + diffableSnapshot: Internals.DiffableDataSourceSnapshot, + context: NSManagedObjectContext + ) { self.diffableSnapshot = diffableSnapshot self.context = context @@ -706,6 +733,5 @@ public struct ListSnapshot: RandomAccessCollection, Hashable { // MARK: Private private let id: UUID = .init() - private let context: NSManagedObjectContext? } diff --git a/Sources/LiveList.swift b/Sources/LiveList.swift index 9381d6c..c6557c8 100644 --- a/Sources/LiveList.swift +++ b/Sources/LiveList.swift @@ -31,14 +31,41 @@ import SwiftUI // MARK: - LiveList +/** + A property wrapper type that can read `ListPublisher` changes. + */ @propertyWrapper @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) public struct LiveList: DynamicProperty { // MARK: Public - public typealias Items = ListSnapshot - + /** + Creates an instance that observes `ListPublisher` changes and exposes a `ListSnapshot` value. + ``` + @LiveList + var people: ListSnapshot + + init(listPublisher: ListPublisher) { + + self._people = .init(listPublisher) + } + + var body: some View { + + List { + + ForEach(objectIn: self.people) { person in + + ProfileView(person) + } + } + .animation(.default) + } + ``` + + - parameter listPublisher: The `ListPublisher` that the `LiveList` will observe changes for + */ public init( _ listPublisher: ListPublisher ) { @@ -46,6 +73,105 @@ public struct LiveList: DynamicProperty { self.observer = .init(listPublisher: listPublisher) } + /** + Creates an instance that observes the specified `FetchChainableBuilderType` and exposes a `ListSnapshot` value. + ``` + @LiveList( + From() + .where(\.isMember == true) + .orderBy(.ascending(\.lastName)) + ) + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(objectIn: self.people) { person in + + ProfileView(person) + } + } + .animation(.default) + } + ``` + + - parameter clauseChain: a `FetchChainableBuilderType` built from a chain of clauses + */ + public init( + _ clauseChain: B, + in dataStack: DataStack + ) where B.ObjectType == Object { + + self.init(dataStack.publishList(clauseChain)) + } + + /** + Creates an instance that observes the specified `SectionMonitorBuilderType` and exposes a `ListSnapshot` value. + ``` + @LiveList( + From() + .sectionBy(\.age) + .where(\.isMember == true) + .orderBy(.ascending(\.lastName)) + ) + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(sectionIn: self.people) { section in + + Section(header: Text(section.sectionID)) { + + ForEach(objectIn: section) { person in + + ProfileView(person) + } + } + } + } + .animation(.default) + } + ``` + + - parameter clauseChain: a `SectionMonitorBuilderType` built from a chain of clauses + */ + public init( + _ clauseChain: B, + in dataStack: DataStack + ) where B.ObjectType == Object { + + self.init(dataStack.publishList(clauseChain)) + } + + /** + Creates an instance that observes the specified `From` and `FetchClause`s and exposes a `ListSnapshot` value. + ``` + @LiveList( + From(), + Where(\.isMember == true), + OrderBy(.ascending(\.lastName)) + ) + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(objectIn: self.people) { person in + + ProfileView(person) + } + } + .animation(.default) + } + ``` + + - parameter from: a `From` clause indicating the entity type + - parameter fetchClauses: a series of `FetchClause` instances for fetching the object list. Accepts `Where`, `OrderBy`, and `Tweak` clauses. + */ public init( _ from: From, _ fetchClauses: FetchClause..., @@ -55,6 +181,34 @@ public struct LiveList: DynamicProperty { self.init(from, fetchClauses, in: dataStack) } + /** + Creates an instance that observes the specified `From` and `FetchClause`s and exposes a `ListSnapshot` value. + ``` + @LiveList( + From(), + [ + Where(\.isMember == true), + OrderBy(.ascending(\.lastName)) + ] + ) + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(objectIn: self.people) { person in + + ProfileView(person) + } + } + .animation(.default) + } + ``` + + - parameter from: a `From` clause indicating the entity type + - parameter fetchClauses: a series of `FetchClause` instances for fetching the object list. Accepts `Where`, `OrderBy`, and `Tweak` clauses. + */ public init( _ from: From, _ fetchClauses: [FetchClause], @@ -64,14 +218,40 @@ public struct LiveList: DynamicProperty { self.init(dataStack.publishList(from, fetchClauses)) } - public init( - _ clauseChain: B, - in dataStack: DataStack - ) where B.ObjectType == Object { - - self.init(dataStack.publishList(clauseChain)) - } - + /** + Creates an instance that observes the specified `From`, `SectionBy`, and `FetchClause`s and exposes a sectioned `ListSnapshot` value. + ``` + @LiveList( + From(), + SectionBy(\.age), + Where(\.isMember == true), + OrderBy(.ascending(\.lastName)) + ) + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(sectionIn: self.people) { section in + + Section(header: Text(section.sectionID)) { + + ForEach(objectIn: section) { person in + + ProfileView(person) + } + } + } + } + .animation(.default) + } + ``` + + - parameter from: a `From` clause indicating the entity type + - parameter sectionBy: a `SectionBy` clause indicating the keyPath for the attribute to use when sorting the list into sections. + - parameter fetchClauses: a series of `FetchClause` instances for fetching the object list. Accepts `Where`, `OrderBy`, and `Tweak` clauses. + */ public init( _ from: From, _ sectionBy: SectionBy, @@ -82,6 +262,42 @@ public struct LiveList: DynamicProperty { self.init(from, sectionBy, fetchClauses, in: dataStack) } + /** + Creates an instance that observes the specified `From`, `SectionBy`, and `FetchClause`s and exposes a sectioned `ListSnapshot` value. + ``` + @LiveList( + From(), + SectionBy(\.age), + [ + Where(\.isMember == true), + OrderBy(.ascending(\.lastName)) + ] + ) + var people: ListSnapshot + + var body: some View { + + List { + + ForEach(sectionIn: self.people) { section in + + Section(header: Text(section.sectionID)) { + + ForEach(objectIn: section) { person in + + ProfileView(person) + } + } + } + } + .animation(.default) + } + ``` + + - parameter from: a `From` clause indicating the entity type + - parameter sectionBy: a `SectionBy` clause indicating the keyPath for the attribute to use when sorting the list into sections. + - parameter fetchClauses: a series of `FetchClause` instances for fetching the object list. Accepts `Where`, `OrderBy`, and `Tweak` clauses. + */ public init( _ from: From, _ sectionBy: SectionBy, @@ -92,18 +308,10 @@ public struct LiveList: DynamicProperty { self.init(dataStack.publishList(from, sectionBy, fetchClauses)) } - public init( - _ clauseChain: B, - in dataStack: DataStack - ) where B.ObjectType == Object { - - self.init(dataStack.publishList(clauseChain)) - } - // MARK: @propertyWrapper - public var wrappedValue: Items { + public var wrappedValue: ListSnapshot { return self.observer.items } @@ -133,7 +341,7 @@ public struct LiveList: DynamicProperty { private final class Observer: ObservableObject { @Published - var items: Items + var items: ListSnapshot let listPublisher: ListPublisher diff --git a/Sources/LiveObject.swift b/Sources/LiveObject.swift index fe74276..bff9004 100644 --- a/Sources/LiveObject.swift +++ b/Sources/LiveObject.swift @@ -31,14 +31,38 @@ import SwiftUI // MARK: - LiveObject +/** + A property wrapper type that can read `ObjectPublisher` changes. + */ @propertyWrapper @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) public struct LiveObject: DynamicProperty { // MARK: Public - public typealias Item = ObjectSnapshot - + /** + Creates an instance that observes `ObjectPublisher` changes and exposes an `Optional>` value. + ``` + @LiveObject + var person: ObjectSnapshot? + + init(objectPublisher: ObjectPublisher) { + + self._person = .init(objectPublisher) + } + + var body: some View { + + HStack { + + AsyncImage(self.person?.$avatarURL) + Text(self.person?.$fullName ?? "") + } + } + ``` + + - parameter objectPublisher: The `ObjectPublisher` that the `LiveObject` will observe changes for + */ public init(_ objectPublisher: ObjectPublisher?) { self.observer = .init(objectPublisher: objectPublisher) @@ -47,7 +71,7 @@ public struct LiveObject: DynamicProperty { // MARK: @propertyWrapper - public var wrappedValue: Item? { + public var wrappedValue: ObjectSnapshot? { return self.observer.item } @@ -72,7 +96,7 @@ public struct LiveObject: DynamicProperty { private final class Observer: ObservableObject { @Published - var item: Item? + var item: ObjectSnapshot? let objectPublisher: ObjectPublisher? diff --git a/Sources/ObjectReader.swift b/Sources/ObjectReader.swift index 8105a85..cad3eb0 100644 --- a/Sources/ObjectReader.swift +++ b/Sources/ObjectReader.swift @@ -31,24 +31,40 @@ import SwiftUI // MARK: - ObjectReader +/** + A container view that reads changes to an `ObjectPublisher` + */ @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) public struct ObjectReader: View { // MARK: Internal + /** + Creates an instance that creates views for `ObjectPublisher` changes. + + - parameter objectPublisher: The `ObjectPublisher` that the `ObjectReader` instance uses to create views dynamically + - parameter content: The view builder that receives an `Optional>` instance and creates views dynamically. + */ public init( _ objectPublisher: ObjectPublisher?, - @ViewBuilder content: @escaping (Value) -> Content - ) where Value == LiveObject.Item { + @ViewBuilder content: @escaping (ObjectSnapshot) -> Content + ) where Value == ObjectSnapshot { self._object = .init(objectPublisher) self.content = content self.keyPath = \.self } + /** + Creates an instance that creates views for `ObjectPublisher` changes. + + - parameter objectPublisher: The `ObjectPublisher` that the `ObjectReader` instance uses to create views dynamically + - parameter keyPath: A `KeyPath` for a property in the `ObjectSnapshot` whose value will be sent to the views + - parameter content: The view builder that receives the value from the property `KeyPath` and creates views dynamically. + */ public init( _ objectPublisher: ObjectPublisher?, - keyPath: KeyPath.Item, Value>, + keyPath: KeyPath, Value>, @ViewBuilder content: @escaping (Value) -> Content ) { @@ -72,10 +88,10 @@ public struct ObjectReader: View { // MARK: Private @LiveObject - private var object: LiveObject.Item? + private var object: ObjectSnapshot? private let content: (Value) -> Content - private let keyPath: KeyPath.Item, Value> + private let keyPath: KeyPath, Value> } #endif diff --git a/Sources/PublisherConvertible.swift b/Sources/PublisherConvertible.swift new file mode 100644 index 0000000..f6e0dbf --- /dev/null +++ b/Sources/PublisherConvertible.swift @@ -0,0 +1,52 @@ +// +// PublisherCompatible.swift +// CoreStore +// +// Copyright © 2021 John Rommel Estropia +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +#if canImport(Combine) + +import Combine + + +// MARK: - PublisherCompatible + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +public protocol PublisherCompatible { + + associatedtype ReactiveBase + + var reactive: PublisherProvider { get } +} + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +extension PublisherCompatible { + + // MARK: Public + + public var reactive: PublisherProvider { + + return .init(self) + } +} + +#endif diff --git a/Sources/PublisherProvider.swift b/Sources/PublisherProvider.swift new file mode 100644 index 0000000..c891fa3 --- /dev/null +++ b/Sources/PublisherProvider.swift @@ -0,0 +1,46 @@ +// +// PublisherProvider.swift +// CoreStore +// +// Copyright © 2021 John Rommel Estropia +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +#if canImport(Combine) + +import Combine + + +// MARK: - PublisherProvider + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +public struct PublisherProvider { + + // MARK: Public + + public let base: Base + + public init(_ base: Base) { + + self.base = base + } +} + +#endif