diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme index 194c762..96ed47b 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme @@ -26,8 +26,19 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + + + + -**Composed** is a protocol oriented framework for composing data from various sources in our app. It provides various concrete implementations for common use cases. +`Composed` is a protocol oriented framework for composing data from multiple sources and building flexible UIs that display the data. + +The primary benefits of using Composed include: + +- The library makes heavy use of protocol-oriented design allowing your types to opt-in to behaviour rather than inherit it by default. +- Each section is isolated from the others, removing the need to think about how the data is composed. > If you prefer to look at code, there's a demo project here: [ComposedDemo](http://github.com/composed-swift/composed-demo) -The library bases everything on just 2 primitives, `Section` and `SectionProvider`. +The package contains 3 libraries, each built on top of each other: -The primary benefits of using Composed include: +- Composed +- ComposedUI +- ComposedLayouts -- The library makes heavy use of protocol-oriented design allowing your types to opt-in to behaviour rather than inherit it by default. -- From an API perspective you generally only care about a single section at a time, and so it's irrelevant to you how it's being composed. +## Composed -1. [Getting Started](#Getting-Started) -2. [Behind the Scenes](#Behind-the-Scenes) -3. [User Interfaces](http://github.com/composed-swift/composedui) +The `Composed` library provides the data layer. `Composed` is centered around primitives, `Section` and `SectionProvider`. ## Getting Started @@ -21,34 +25,73 @@ Composed includes 3 pre-defined sections as well as 2 providers that should sati ### Sections -**ArraySection** -Represents a section that manages its elements via an `Array`. This type of section is useful for representing in-memory data. +A `Section` is a collection of data with a simple set of requirements: + +```swift +/// Represents a single section of data. +public protocol Section: AnyObject { + /// The number of elements in this section + var numberOfElements: Int { get } + + /// The delegate that will respond to updates + var updateDelegate: SectionUpdateDelegate? { get set } +} +``` + +`Composed` provides some sections that should cover a majority of use cases. + +#### `ArraySection` + +Represents a section that manages a collection of same-type elements by using an `Array` backing store. This type of section is useful for representing in-memory data, e.g. data loaded from a network or read from the file system. + +#### `SingleElementSection` + +Represents a section that manages a single element. This section is useful when only have a single element to manage. + +If the stored value is `nil` it will return `0` for `numberOfElements`, allowing for any UI elements the section provides to be hidden (more on this later). + +#### `FlatSection` + +A `FlatSection` behaved similarly to a `ComposedSectionProvider` but rather than providing a collections of sections it returns a single section that contains every element in the flattened collection of `Section`s and `SectionProvider`s. This has limited use for data alone but proves useful when representing the data in the UI; `FlatSection` allows for multiple sections to be displayed in a single UI section, enabling features such as headers that pin to visible bounds ("sticky headers"). + +#### `ManagedSection` + +`ManagedSection` wraps an `NSManagedObjectContext` and responds to the `NSFetchedResultsControllerDelegate` functions by forwarding them to the `SectionUpdateDelegate`. This enables a single data hierarchy to include a mixture of sections that are backed by core data and other storage mechanisms. + +### `SectionProvider`s + +`SectionProvider`s are the next layer up, dealing with `Section`s directly by providing an ordered collection of sections: + +```swift +/// Represents a collection of `Section`'s. +public protocol SectionProvider: AnyObject { + /// The child sections contained in this provider + var sections: [Section] { get } -**ManagedSection** -Represents a section that provides its elements via an `NSFetchedResultsController`. This section is useful for representing data managed by CoreData. + /// The delegate that will respond to updates + var updateDelegate: SectionProviderUpdateDelegate? { get set } +} +``` -**SingleElementSection** -Represents a section that manages a single element. This section is useful when only have a single element to manage. Hint: Use `Optional` to represent an element that may or may not exist. +#### `ComposedSectionProvider` -### Providers +Represents an collection of `Section`'s and `SectionProvider`'s. The provider supports infinite nesting, including other `ComposedSectionProvider`'s, by providing a flattened hierarchy. -**ComposedSectionProvider** -Represents an collection of `Section`'s and `SectionProvider`'s. The provider supports infinite nesting, including other `ComposedSectionProvider`'s. All children will be active at all times, so `numberOfSections` and `numberOfElements(in:)` will return values representative of all children. +#### `SegmentedSectionProvider` -**SegmentedSectionProvider** -Represents an collection of `Section`'s and `SectionProvider`'s. The provider supports infinite nesting, including other `SegmentedSectionProvider`'s. One or zero children may be active at any time, so `numberOfSections` and `numberOfElements(in:)` will return values representative of the currenly active child only. +Provides the same nesting support as `ComposedSectionProvider` but allows for different segments of children to be active. This could be used to represent a series of tabs with different section providers in each tab. ### Example Lets say we wanted to represent a users contacts library. Our contacts will have 2 groups, family and friends. Using Composed, we can easily model that as such: - + ```swift let family = ArraySection() family.append(Person(name: "Dad")) family.append(Person(name: "Mum")) -let friends = ArraySection() -friends.append(Person(name: "Best mate")) +let businesses = ArraySection() +businesses.append(Person(name: "ACME Inc.")) ``` At this point we have 2 separate sections for representing our 2 groups of contacts. Now we can use a provider to compose these 2 together: @@ -56,7 +99,7 @@ At this point we have 2 separate sections for representing our 2 groups of conta ```swift let contacts = ComposedSectionProvider() contacts.append(family) -contacts.append(friends) +contacts.append(businesses) ``` That's it! Now we can query our data using the provider without either of the individual sections even being aware that they're now contained in a larger structure: @@ -66,75 +109,25 @@ contacts.numberOfSections // 2 contacts.numberOfElements(in: 1) // 1 ``` -If we want to query individual data in a section (assuming we don't already have a reference to it): +Swapping to a `FlatSection` would flatten these sections while maintaining all the data: ```swift -let people = contacts.sections[0] as? ArraySection -people.element(at: 1) // Mum -``` - -> Note: we have to cast the section to a known type because SectionProvider's can contain _any_ type of section as well as other nested providers. - -### Opt-In Behaviours - -If we now subclass ArraySection, we can extend our section through protocol conformance to do something more interesting: - -```swift -final class People: ArraySection { ... } - -protocol SelectionHandling: Section { - func didSelect(at index: Int) -} - -extension People: SelectionHandling { - func didSelect(at index: Int) { - let person = element(at: index) - print(person.name) - } -} -``` - -In order to make this work, _something_ needs to call `didSelect`, so for the purposes of this example we'll leave out some details but to give you a preview for how you can build something like this yourself: - -```swift -// Assume we want to select the 2nd element in the 1st section -let section = provider.sections[0] as? SelectionHandling -section?.didSelect(at: 1) // Mum +let contacts = FlatSection() +contacts.append(family) +contacts.append(businesses) +contacts.numberOfElements // 3 ``` -Composed is handling all of the mapping and structure, allowing us to focus entirely on behavious and extension. - -## Behind the Scenes +## ComposedUI -### Section +The `ComposedUI` library builds on top of `Composed` by providing protocols that enable `Section`s to provide UI elements that can then be displayed by a view coordinator. -A section represents exactly what it says, a single section. The best thing about that is that we have no need for `IndexPath`'s within a section. Just indexes! +### UI Coordinators -### SectionProvider +Various coordinators are provided that enable interfacing with `UIKit` by adding extra protocol conformances to your `Section`s. -A section provider is a container type that contains either sections or other providers. Allowing infinite nesting and therefore infinite possibilities. - -### Mappings - -Mappings provide the glue between your 'tree' structure and the resulting `flattened` structure. Lets take a look at an example. - -```swift - -// we can define our structure as such: -- Provider - - Section 1 - - Provider - - Section 2 - - Section 3 - - Section 4 - -// mappings will then convert this to: -- Section 1 -- Section 2 -- Section 3 -- Section 4 -``` +#### `CollectionCoordinator` -Furthermore, mappings take care of the conversion from local to global indexes and more importantly `IndexPath`'s which allows for even more interesting use cases. +`CollectionCoordinator` allows for the most flexible UIs by coordinating with a `UICollectionView`. -> To find out more, checkout [ComposedUI](http://github.com/composed-swift/ComposedUI) which provides user interface implementations that work with `UICollectionView` and `UITableView`, allowing you to power an entire screen from simple reusable `Section`s. +View the [`CollectionCoordinator` README.md](./Sources/ComposedUI/CollectionView/README.md) to learn more. diff --git a/Sources/Composed/Core/Section.swift b/Sources/Composed/Core/Section.swift index d240971..a7927e6 100644 --- a/Sources/Composed/Core/Section.swift +++ b/Sources/Composed/Core/Section.swift @@ -2,33 +2,49 @@ import Foundation import CoreGraphics /// Represents a single section of data. -public protocol Section: class { - +public protocol Section: AnyObject { /// The number of elements in this section var numberOfElements: Int { get } /// The delegate that will respond to updates var updateDelegate: SectionUpdateDelegate? { get set } - } public extension Section { - /// Returns true if the section contains no elements, false otherwise var isEmpty: Bool { return numberOfElements == 0 } + /// Perform multiple updates in a single batch, ensuring a single layout pass and animation is used for all updates. + /// + /// Changes will be reduced in to the minimal total changes required, based on the calls made to the `updateDelegate`. If + /// no `updateDelegate` has been set the `updates` closure is called with `nil`, allowing changes to still be made. + /// + /// Some updates are not correct reduced, or you may wish to avoid this batching behaviour. To enable this the `forceReloadData` + /// parameter can be set to `true`. Note that passing `true` is not supported if another batch updates that does not force reload + /// data is currently being performed. + /// + /// - Parameter forceReloadData: If `true` all updates will be ignored and `reloadData` will be called after all updates are applied. + /// - Parameter updates: A closure that applies the updates. + func performBatchUpdates(forceReloadData: Bool = false, _ updates: (_ updateDelegate: SectionUpdateDelegate?) -> Void) { + if let updateDelegate = updateDelegate { + updateDelegate.section(self, willPerformBatchUpdates: { + updates(updateDelegate) + }, forceReloadData: forceReloadData) + } else { + updates(nil) + } + } } /// A delegate that will respond to update events from a `Section` -public protocol SectionUpdateDelegate: class { - - /// Notifies the delegate before a section will process updates - /// - Parameter section: The section that will be updated - func willBeginUpdating(_ section: Section) - - /// Notifies the delegate after a section has processed updates - /// - Parameter section: The section that was updated - func didEndUpdating(_ section: Section) +public protocol SectionUpdateDelegate: AnyObject { + /// Notifies the delegate that the section will perform a series of updates. + /// + /// The delegate must call the `updates` closure synchronously. + /// + /// - Parameter section: The section that will be updated. + /// - Parameter updates: A closure that will perform the updates. + func section(_ section: Section, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) /// Notifies the delegate that all sections should be invalidated, ignoring individual updates /// - Parameter section: The section that requested the invalidation @@ -82,4 +98,13 @@ public protocol SectionUpdateDelegate: class { /// - destinationIndex: The final index where the element will be moved to func section(_ section: Section, move sourceIndex: Int, to destinationIndex: Int) + /// Notifies the delegate that the section invalidated its header. + /// - Parameters: + /// - section: The section that invalidated its header. + func sectionDidInvalidateHeader(_ section: Section) + + /// Notifies the delegate that the section invalidated its footer. + /// - Parameters: + /// - section: The section that invalidated its footer. + func sectionDidInvalidateFooter(_ section: Section) } diff --git a/Sources/Composed/Core/SectionProvider.swift b/Sources/Composed/Core/SectionProvider.swift index 55a7aff..f1f04dd 100644 --- a/Sources/Composed/Core/SectionProvider.swift +++ b/Sources/Composed/Core/SectionProvider.swift @@ -1,17 +1,12 @@ import Foundation /// Represents a collection of `Section`'s. -public protocol SectionProvider: class { - +public protocol SectionProvider: AnyObject { /// The child sections contained in this provider var sections: [Section] { get } - /// The number of sections in this provider - var numberOfSections: Int { get } - /// The delegate that will respond to updates var updateDelegate: SectionProviderUpdateDelegate? { get set } - } public extension SectionProvider { @@ -25,6 +20,27 @@ public extension SectionProvider { return sections.count } + /// Perform multiple updates in a single batch, ensuring a single layout pass and animation is used for all updates. + /// + /// Changes will be reduced in to the minimal total changes required, based on the calls made to the `updateDelegate`. If + /// no `updateDelegate` has been set the `updates` closure is called with `nil`, allowing changes to still be made. + /// + /// Some updates are not correct reduced, or you may wish to avoid this batching behaviour. To enable this the `forceReloadData` + /// parameter can be set to `true`. Note that passing `true` is not supported if another batch updates that does not force reload + /// data is currently being performed. + /// + /// - Parameter forceReloadData: If `true` all updates will be ignored and `reloadData` will be called after all updates are applied. + /// - Parameter updates: A closure that applies the updates. + func performBatchUpdates(forceReloadData: Bool = false, _ updates: (_ updateDelegate: SectionProviderUpdateDelegate?) -> Void) { + if let updateDelegate = updateDelegate { + updateDelegate.provider(self, willPerformBatchUpdates: { + updates(updateDelegate) + }, forceReloadData: forceReloadData) + } else { + updates(nil) + } + } + } /// Represents a collection of `SectionProvider`'s @@ -45,15 +61,14 @@ public protocol AggregateSectionProvider: SectionProvider { } /// A delegate that will respond to update events from a `SectionProvider` -public protocol SectionProviderUpdateDelegate: class { - - /// /// Notifies the delegate before a provider will process updates - /// - Parameter provider: The provider that will be updated - func willBeginUpdating(_ provider: SectionProvider) - - /// Notifies the delegate after a provider has processed updates - /// - Parameter provider: The provider that was updated - func didEndUpdating(_ provider: SectionProvider) +public protocol SectionProviderUpdateDelegate: AnyObject { + /// Notifies the delegate that the section provider will perform a series of updates. + /// + /// The delegate must call the `updates` closure synchronously. + /// + /// - Parameter provider: The section provider that will be updated. + /// - Parameter updates: A closure that will perform the updates. + func provider(_ provider: SectionProvider, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) /// Notifies the delegate that all sections should be invalidated, ignoring individual updates /// - Parameter provider: The provider that requested the invalidation @@ -77,12 +92,12 @@ public protocol SectionProviderUpdateDelegate: class { // Default implementations to minimise `SectionProvider` implementation requirements public extension SectionProviderUpdateDelegate where Self: SectionProvider { - func willBeginUpdating(_ provider: SectionProvider) { - updateDelegate?.willBeginUpdating(self) - } - - func didEndUpdating(_ provider: SectionProvider) { - updateDelegate?.didEndUpdating(self) + func provider(_ provider: SectionProvider, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) { + if let updateDelegate = updateDelegate { + updateDelegate.provider(self, willPerformBatchUpdates: updates, forceReloadData: forceReloadData) + } else { + updates() + } } func invalidateAll(_ provider: SectionProvider) { diff --git a/Sources/Composed/Core/SectionProviderMapping.swift b/Sources/Composed/Core/SectionProviderMapping.swift index 303fab4..1b6409c 100644 --- a/Sources/Composed/Core/SectionProviderMapping.swift +++ b/Sources/Composed/Core/SectionProviderMapping.swift @@ -1,15 +1,9 @@ import UIKit /// A delegate for responding to mapping updates -public protocol SectionProviderMappingDelegate: class { +public protocol SectionProviderMappingDelegate: AnyObject { - /// Notifies the delegate that the mapping will being updating - /// - Parameter mapping: The mapping that provided this update - func mappingWillBeginUpdating(_ mapping: SectionProviderMapping) - - /// Notifies the delegate that the mapping did end updating - /// - Parameter mapping: The mapping that provided this update - func mappingDidEndUpdating(_ mapping: SectionProviderMapping) + func mapping(_ mapping: SectionProviderMapping, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) /// Notifies the delegate that the mapping was invalidated /// - Parameter mapping: The mapping that provided this update @@ -39,12 +33,6 @@ public protocol SectionProviderMappingDelegate: class { /// - indexPaths: The element indexPaths func mapping(_ mapping: SectionProviderMapping, didRemoveElementsAt indexPaths: [IndexPath]) - /// Notifies the delegate that the mapping did update sections - /// - Parameters: - /// - mapping: The mapping that provided this update - /// - sections: The section indexes - func mapping(_ mapping: SectionProviderMapping, didUpdateSections sections: IndexSet) - /// Notifies the delegate that the mapping did update elements /// - Parameters: /// - mapping: The mapping that provided this update @@ -82,12 +70,21 @@ public protocol SectionProviderMappingDelegate: class { /// - destinationIndexPath: The final indexPath func mapping(_ mapping: SectionProviderMapping, move sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) + /// Notifies the delegate that the section invalidated its header. + /// - Parameters: + /// - sectionIndex: The index of the section that invalidated its header. + func mappingDidInvalidateHeader(at sectionIndex: Int) + + /// Notifies the delegate that the section invalidated its footer. + /// - Parameters: + /// - sectionIndex: The index of the section that invalidated its footer. + func mappingDidInvalidateFooter(at sectionIndex: Int) + } /// An object that encapsulates the logic required to map `SectionProvider`s to a global context, /// allowing elements in a `Section` to be referenced via an `IndexPath` public final class SectionProviderMapping: SectionProviderUpdateDelegate, SectionUpdateDelegate { - /// The delegate that will respond to updates public weak var delegate: SectionProviderMappingDelegate? @@ -140,12 +137,12 @@ public final class SectionProviderMapping: SectionProviderUpdateDelegate, Sectio return IndexSet(indexes.map { $0 + offset }) } - public func willBeginUpdating(_ provider: SectionProvider) { - delegate?.mappingWillBeginUpdating(self) - } - - public func didEndUpdating(_ provider: SectionProvider) { - delegate?.mappingDidEndUpdating(self) + public func provider(_ provider: SectionProvider, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) { + if let delegate = delegate { + delegate.mapping(self, willPerformBatchUpdates: updates, forceReloadData: forceReloadData) + } else { + updates() + } } public func provider(_ provider: SectionProvider, didInsertSections sections: [Section], at indexes: IndexSet) { @@ -187,12 +184,12 @@ public final class SectionProviderMapping: SectionProviderUpdateDelegate, Sectio delegate?.mappingDidInvalidate(self) } - public func willBeginUpdating(_ section: Section) { - delegate?.mappingWillBeginUpdating(self) - } - - public func didEndUpdating(_ section: Section) { - delegate?.mappingDidEndUpdating(self) + public func section(_ section: Section, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) { + if let delegate = delegate { + delegate.mapping(self, willPerformBatchUpdates: updates, forceReloadData: forceReloadData) + } else { + updates() + } } public func section(_ section: Section, didInsertElementAt index: Int) { @@ -229,6 +226,16 @@ public final class SectionProviderMapping: SectionProviderUpdateDelegate, Sectio delegate?.mapping(self, didMoveElementsAt: [(source, destination)]) } + public func sectionDidInvalidateHeader(_ section: Section) { + guard let sectionOffset = self.sectionOffset(of: section) else { return } + delegate?.mappingDidInvalidateHeader(at: sectionOffset) + } + + public func sectionDidInvalidateFooter(_ section: Section) { + guard let sectionOffset = self.sectionOffset(of: section) else { return } + delegate?.mappingDidInvalidateFooter(at: sectionOffset) + } + // Rebuilds the cached providers to improve lookup performance. // This is generally only required when a sections are either inserted or removed, so it should be fairly efficient. private func rebuildSectionOffsets() { diff --git a/Sources/Composed/Providers/ComposedSectionProvider.swift b/Sources/Composed/Providers/ComposedSectionProvider.swift index 7b8164f..e1ec741 100644 --- a/Sources/Composed/Providers/ComposedSectionProvider.swift +++ b/Sources/Composed/Providers/ComposedSectionProvider.swift @@ -2,22 +2,22 @@ import Foundation /** Represents an collection of `Section`'s and `SectionProvider`'s. The provider supports infinite nesting, including other `ComposedSectionProvider`'s. All children will be active at all times, so `numberOfSections` and `numberOfElements(in:)` will return values representative of all children. - - let provider = ComposedSectionProvider() - provider.append(section1) // 5 elements - provider.append(section2) // 3 elements - - provider.numberOfSections // returns 2 - provider.numberOfElements(in: 0) // returns 5 - provider.numberOfElements(in: 1) // return2 3 + + let provider = ComposedSectionProvider() + provider.append(section1) // 5 elements + provider.append(section2) // 3 elements + + provider.numberOfSections // returns 2 + provider.numberOfElements(in: 0) // returns 5 + provider.numberOfElements(in: 1) // return2 3 */ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpdateDelegate { - + /// Represents either a section or a provider private enum Child: Equatable { case provider(SectionProvider) case section(Section) - + static func == (lhs: Child, rhs: Child) -> Bool { switch (lhs, rhs) { case let (.section(lhs), .section(rhs)): return lhs === rhs @@ -26,14 +26,14 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd } } } - + open weak var updateDelegate: SectionProviderUpdateDelegate? - + /// Returns all the sections this provider contains public private(set) var sections: [Section] = [] - + public private(set) var numberOfSections: Int = 0 - + /// Returns all the providers this provider contains public var providers: [SectionProvider] { return children.compactMap { kind in @@ -44,31 +44,31 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd } } } - + /// Represents all of the children this provider contains private var children: [Child] = [] - + /// A flag indicating if a child provider is currently removing sections. /// /// See: `sectionOffset(for:)` private var isRemovingChildProviderSections = false - + public init() { } - + /// Returns the number of elements in the specified section /// - Parameter section: The section index /// - Returns: The number of elements public func numberOfElements(in section: Int) -> Int { return sections[section].numberOfElements } - + /// Calculate the offset for the first section of `provider`, relative to this section provider. /// /// - parameter provider: The provide to calculate the offset of. /// - returns: The offset for the provider, or `nil` if it is not in the hierarchy. public func sectionOffset(for provider: SectionProvider) -> Int? { guard provider !== self else { return 0 } - + /// This functions provides a fast path for when the `provider` is the last provider in the list of /// children. This is provided to speed up appends. However, it relies on the provider's `numberOfSections` /// to be in-sync with `sections` and `numberOfSections` on `self`. If these values are not in-sync this @@ -85,9 +85,9 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd break } } - + var offset: Int = 0 - + for child in children { switch child { case .section: @@ -98,15 +98,15 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd } else if let childProvider = childProvider as? AggregateSectionProvider, let sectionOffset = childProvider.sectionOffset(for: provider) { return offset + sectionOffset } - + offset += childProvider.numberOfSections } } - + // Provider is not in the hierarchy return nil } - + public func sectionOffset(for section: Section) -> Int? { // A quick test for if this is the last child is a small optimisation, mainly // beneficial when the section has just been appended. @@ -116,16 +116,16 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd default: break } - + var offset: Int = 0 - + for child in children { switch child { case .section(let childSection): if childSection === section { return offset } - + offset += 1 case .provider(let childProvider): if let childProvider = childProvider as? AggregateSectionProvider { @@ -133,15 +133,15 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd return offset + index } } - + offset += childProvider.numberOfSections } } - + // Provider is not in the hierachy return nil } - + /// Returns the first index of the `section`, or `nil` if the section is not a child of this /// composed section provider. /// @@ -150,7 +150,7 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd public func firstIndex(of section: Section) -> Int? { children.firstIndex(of: .section(section)) } - + /// Returns the first index of the `sectionProvider`, or `nil` if the section is not a child of /// this composed section provider. /// @@ -159,71 +159,73 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd public func firstIndex(of sectionProvider: SectionProvider) -> Int? { children.firstIndex(of: .provider(sectionProvider)) } - + /// Appends the specified `SectionProvider` to the provider /// - Parameter child: The `SectionProvider` to append public func append(_ child: SectionProvider) { insert(child, at: children.count) } - + /// Appends the specified `Section` to the provider /// - Parameter child: The `Section` to append public func append(_ child: Section) { insert(child, at: children.count) } - + /// Inserts the specified `Section` at the given index /// - Parameters: /// - child: The `Section` to insert /// - index: The index where the `Section` should be inserted public func insert(_ child: Section, at index: Int) { + assert(!contains(child), "Attemting to insert a section that is already a child") guard (0...children.count).contains(index) else { fatalError("Index out of bounds: \(index)") } - - updateDelegate?.willBeginUpdating(self) - children.insert(.section(child), at: index) - numberOfSections += 1 - let sectionOffset = self.sectionOffset(for: child)! - sections.insert(child, at: sectionOffset) - updateDelegate?.provider(self, didInsertSections: [child], at: IndexSet(integer: sectionOffset)) - updateDelegate?.didEndUpdating(self) + + performBatchUpdates { updateDelegate in + children.insert(.section(child), at: index) + numberOfSections += 1 + let sectionOffset = self.sectionOffset(for: child)! + sections.insert(child, at: sectionOffset) + updateDelegate?.provider(self, didInsertSections: [child], at: IndexSet(integer: sectionOffset)) + } } - + /// Inserts the specified `SectionProvider` at the given index /// - Parameters: /// - child: The `SectionProvider` to insert /// - index: The index where the `SectionProvider` should be inserted public func insert(_ child: SectionProvider, at index: Int) { + assert(!contains(child), "Attemtping to append a section provider that is already a child") guard (0...children.count).contains(index) else { fatalError("Index out of bounds: \(index)") } - + child.updateDelegate = self - - updateDelegate?.willBeginUpdating(self) - children.insert(.provider(child), at: index) - numberOfSections += child.sections.count - let firstIndex = sectionOffset(for: child)! - let endIndex = firstIndex + child.sections.count - sections.insert(contentsOf: child.sections, at: firstIndex) - updateDelegate?.provider(self, didInsertSections: child.sections, at: IndexSet(integersIn: firstIndex.. Bool { firstIndex(of: section) != nil } - + /// Returns a bool indicating if the section provider contains `sectionProvider`. /// /// - Parameter sectionProvider: The section provider to search for. @@ -307,7 +315,7 @@ extension ComposedSectionProvider { public func contains(_ sectionProvider: SectionProvider) -> Bool { firstIndex(of: sectionProvider) != nil } - + /// Inserts the provided section after an existing section. If `existingSection` is not a child /// of this composed section provider this function does nothing. /// @@ -319,13 +327,13 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSection: Section, after existingSection: Section) -> Int? { guard let existingSectionIndex = firstIndex(of: existingSection) else { return nil } - + let newIndex = existingSectionIndex + 1 insert(newSection, at: newIndex) - + return newIndex } - + /// Inserts the provided section provider after an existing section. If `existingSection` is not /// a child of this composed section provider this function does nothing. /// @@ -337,13 +345,13 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSectionProvider: SectionProvider, after existingSection: Section) -> Int? { guard let existingSectionIndex = firstIndex(of: existingSection) else { return nil } - + let newIndex = existingSectionIndex + 1 insert(newSectionProvider, at: newIndex) - + return newIndex } - + /// Inserts the provided section after an existing section provider. If `existingSectionProvider` /// is not a child of this composed section provider this function does nothing. /// @@ -355,13 +363,13 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSection: Section, after existingSectionProvider: SectionProvider) -> Int? { guard let existingSectionProviderIndex = firstIndex(of: existingSectionProvider) else { return nil } - + let newIndex = existingSectionProviderIndex + 1 insert(newSection, at: newIndex) - + return newIndex } - + /// Inserts the provided section provider after an existing section provider. If `existingSectionProvider` /// is not a child of this composed section provider this function does nothing. /// @@ -373,13 +381,13 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSectionProvider: SectionProvider, after existingSectionProvider: SectionProvider) -> Int? { guard let existingSectionProviderIndex = firstIndex(of: existingSectionProvider) else { return nil } - + let newIndex = existingSectionProviderIndex + 1 insert(newSectionProvider, at: newIndex) - + return newIndex } - + /// Inserts the provided section before an existing section. If `existingSection` is not a child /// of this composed section provider this function does nothing. /// @@ -391,12 +399,12 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSection: Section, before existingSection: Section) -> Int? { guard let newIndex = firstIndex(of: existingSection) else { return nil } - + insert(newSection, at: newIndex) - + return newIndex } - + /// Inserts the provided section provider before an existing section. If `existingSection` is not /// a child of this composed section provider this function does nothing. /// @@ -408,12 +416,12 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSectionProvider: SectionProvider, before existingSection: Section) -> Int? { guard let newIndex = firstIndex(of: existingSection) else { return nil } - + insert(newSectionProvider, at: newIndex) - + return newIndex } - + /// Inserts the provided section before an existing section provider. If `existingSectionProvider` /// is not a child of this composed section provider this function does nothing. /// @@ -425,12 +433,12 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSection: Section, before existingSectionProvider: SectionProvider) -> Int? { guard let newIndex = firstIndex(of: existingSectionProvider) else { return nil } - + insert(newSection, at: newIndex) - + return newIndex } - + /// Inserts the provided section provider before an existing section provider. If `existingSectionProvider` /// is not a child of this composed section provider this function does nothing. /// @@ -442,9 +450,9 @@ extension ComposedSectionProvider { @discardableResult public func insert(_ newSectionProvider: SectionProvider, before existingSectionProvider: SectionProvider) -> Int? { guard let newIndex = firstIndex(of: existingSectionProvider) else { return nil } - + insert(newSectionProvider, at: newIndex) - + return newIndex } } diff --git a/Sources/Composed/Providers/SegmentedSectionProvider.swift b/Sources/Composed/Providers/SegmentedSectionProvider.swift index 5a9009e..cbb42ce 100644 --- a/Sources/Composed/Providers/SegmentedSectionProvider.swift +++ b/Sources/Composed/Providers/SegmentedSectionProvider.swift @@ -48,11 +48,11 @@ open class SegmentedSectionProvider: AggregateSectionProvider, SectionProviderUp // if the value won't result in a change, ignore it if _currentIndex == newIndex { return } - updateDelegate?.willBeginUpdating(self) - _currentIndex = newIndex - updateDelegate(forRemovalOf: children[oldIndex]) - updateDelegate(forInsertionOf: children[newIndex]) - updateDelegate?.didEndUpdating(self) + performBatchUpdates { changesReducer in + _currentIndex = newIndex + updateDelegate(forRemovalOf: children[oldIndex]) + updateDelegate(forInsertionOf: children[newIndex]) + } } } @@ -205,18 +205,18 @@ open class SegmentedSectionProvider: AggregateSectionProvider, SectionProviderUp } // if our index is still technically valid else if _currentIndex <= children.count - 1 { - updateDelegate?.willBeginUpdating(self) - updateDelegate(forRemovalOf: child) - updateDelegate(forInsertionOf: currentChild) - updateDelegate?.didEndUpdating(self) + performBatchUpdates { _ in + updateDelegate(forRemovalOf: child) + updateDelegate(forInsertionOf: currentChild) + } } // if our index should be decremented else { - updateDelegate?.willBeginUpdating(self) - _currentIndex -= 1 - updateDelegate(forRemovalOf: child) - updateDelegate(forInsertionOf: currentChild) - updateDelegate?.didEndUpdating(self) + performBatchUpdates { _ in + _currentIndex -= 1 + updateDelegate(forRemovalOf: child) + updateDelegate(forInsertionOf: currentChild) + } } } diff --git a/Sources/Composed/Sections/ArraySection.swift b/Sources/Composed/Sections/ArraySection.swift index 2c80d63..6366c51 100644 --- a/Sources/Composed/Sections/ArraySection.swift +++ b/Sources/Composed/Sections/ArraySection.swift @@ -7,17 +7,17 @@ import Foundation `ArraySection` conforms to the following protocols from the standard library: - Sequence - MutableCollection - RandomAccessCollection - BidirectionalCollection - RangeReplaceableCollection + Sequence + MutableCollection + RandomAccessCollection + BidirectionalCollection + RangeReplaceableCollection Example usage: - let section = ArraySection() - section.append(contentsOf: [1, 2, 3]) - section.numberOfElements // returns 3 + let section = ArraySection() + section.append(contentsOf: [1, 2, 3]) + section.numberOfElements // returns 3 */ open class ArraySection: Section, ExpressibleByArrayLiteral { @@ -27,13 +27,17 @@ open class ArraySection: Section, ExpressibleByArrayLiteral { /// Represents the elements this section contains public private(set) var elements: [Element] + public init(elements: [Element]) { + self.elements = elements + } + public required init() { elements = [] } /// Makes an `ArraySection` containing the specified elements /// - Parameter elements: The elements to append - required public init(arrayLiteral elements: Element...) { + public required init(arrayLiteral elements: Element...) { self.elements = elements } @@ -71,79 +75,81 @@ extension ArraySection: MutableCollection, RandomAccessCollection, Bidirectional public subscript(position: Index) -> Element { get { return elements[position] } set(newValue) { - updateDelegate?.willBeginUpdating(self) - elements[position] = newValue - updateDelegate?.section(self, didUpdateElementAt: position) - updateDelegate?.didEndUpdating(self) + performBatchUpdates { _ in + elements[position] = newValue + updateDelegate?.section(self, didUpdateElementAt: position) + } } } public func append(_ newElement: Element) { - updateDelegate?.willBeginUpdating(self) - elements.append(newElement) - updateDelegate?.section(self, didInsertElementAt: elements.count - 1) - updateDelegate?.didEndUpdating(self) + performBatchUpdates { _ in + elements.append(newElement) + updateDelegate?.section(self, didInsertElementAt: elements.count - 1) + } } public func append(contentsOf newElements: S) where S: Sequence, Element == S.Element { - updateDelegate?.willBeginUpdating(self) - let oldCount = elements.count - elements.append(contentsOf: newElements) - let newCount = elements.count - (oldCount..(contentsOf newElements: C, at i: Index) where C: Collection, Element == C.Element { - updateDelegate?.willBeginUpdating(self) - let oldCount = elements.count - elements.insert(contentsOf: newElements, at: i) - let newCount = elements.count - (oldCount.. Element { - updateDelegate?.willBeginUpdating(self) - let element = elements.removeLast() - updateDelegate?.section(self, didRemoveElementAt: elements.count) - updateDelegate?.didEndUpdating(self) + var element: Element! + performBatchUpdates { _ in + element = elements.removeLast() + updateDelegate?.section(self, didRemoveElementAt: elements.count) + } return element } /// Removes the last `k` (number of) elements /// - Parameter k: The number of elements to remove from the end public func removeLast(_ k: Int) { - updateDelegate?.willBeginUpdating(self) - let oldCount = elements.count - elements.removeLast(k) - let newCount = elements.count - (newCount..).forEach { + updateDelegate?.section(self, didRemoveElementAt: $0) + } } - updateDelegate?.didEndUpdating(self) } @discardableResult public func remove(at position: Index) -> Element { - updateDelegate?.willBeginUpdating(self) - let element = elements.remove(at: position) - updateDelegate?.section(self, didRemoveElementAt: position) - updateDelegate?.didEndUpdating(self) + var element: Element! + performBatchUpdates { _ in + element = elements.remove(at: position) + updateDelegate?.section(self, didRemoveElementAt: position) + } return element } @@ -157,11 +163,11 @@ extension ArraySection: MutableCollection, RandomAccessCollection, Bidirectional /// Removes all elements from this section public func removeAll() { - updateDelegate?.willBeginUpdating(self) - let indexes = IndexSet(integersIn: indices) - indexes.forEach { updateDelegate?.section(self, didRemoveElementAt: $0) } - elements.removeAll() - updateDelegate?.didEndUpdating(self) + performBatchUpdates { _ in + let indexes = IndexSet(integersIn: indices) + indexes.sorted(by: >).forEach { updateDelegate?.section(self, didRemoveElementAt: $0) } + elements.removeAll() + } } public func removeAll(where shouldBeRemoved: (Element) throws -> Bool) rethrows { @@ -184,12 +190,14 @@ extension ArraySection: Hashable where Element: Hashable { } extension ArraySection: RangeReplaceableCollection { - public func replaceSubrange(_ subrange: R, with newElements: C) where C.Element == Element, R.Bound == Index { - elements.replaceSubrange(subrange, with: newElements) - updateDelegate?.invalidateAll(self) + performBatchUpdates { updateDelegate in + elements.replaceSubrange(subrange, with: newElements) + for index in subrange.relative(to: elements) { + updateDelegate?.section(self, didUpdateElementAt: index) + } + } } - } extension ArraySection: CustomStringConvertible { diff --git a/Sources/Composed/Sections/FlatSection+SelectionHandler.swift b/Sources/Composed/Sections/FlatSection+SelectionHandler.swift new file mode 100644 index 0000000..aa03bde --- /dev/null +++ b/Sources/Composed/Sections/FlatSection+SelectionHandler.swift @@ -0,0 +1,105 @@ +extension FlatSection: SelectionHandler { + public final var selectionHandlingSections: [SelectionHandler] { + sections.compactMap { $0 as? SelectionHandler } + } + + public var allowsMultipleSelection: Bool { + let selectionHandlingSection = sections.compactMap { $0 as? SelectionHandler } + if selectionHandlingSection.isEmpty { + return false + } else if selectionHandlingSection.count == 1 { + return selectionHandlingSection.first!.allowsMultipleSelection + } else { + return true + } + } + + /// Returns all element indexes that are currently selected + public var selectedIndexes: [Int] { + return sections.flatMap { section -> [Int] in + guard let section = section as? SelectionHandler else { return [] } + let offset = self.indexForFirstElement(of: section)! + return section.selectedIndexes.map { $0 + offset } + } + } + + /// When a highlight is attempted, this method will be called giving the caller a chance to prevent it + /// - Parameter index: The element index + public func shouldHighlight(at index: Int) -> Bool { + guard let sectionMeta = self.sectionForElementIndex(index) else { return false } + guard let section = sectionMeta.section as? SelectionHandler else { return false } + + let sectionIndex = index - sectionMeta.offset + return section.shouldHighlight(at: sectionIndex) + } + + /// When a selection is attempted, this method will be called giving the caller a chance to prevent it + /// - Parameter index: The element index + public func shouldSelect(at index: Int) -> Bool { + guard let sectionMeta = self.sectionForElementIndex(index) else { return false } + guard let section = sectionMeta.section as? SelectionHandler else { return false } + + let sectionIndex = index - sectionMeta.offset + return section.shouldSelect(at: sectionIndex) + } + + /// When a selection occurs, this method will be called to notify the section + /// - Parameter index: The element index + public func didSelect(at index: Int) { + guard let sectionMeta = self.sectionForElementIndex(index) else { return } + guard let section = sectionMeta.section as? SelectionHandler else { return } + + let sectionIndex = index - sectionMeta.offset + section.didSelect(at: sectionIndex) + } + + /// When a deselection is attempted, this method will be called giving the caller a chance to prevent it + /// - Parameter index: The element index + public func shouldDeselect(at index: Int) -> Bool { + guard let sectionMeta = self.sectionForElementIndex(index) else { return false } + guard let section = sectionMeta.section as? SelectionHandler else { return false } + + let sectionIndex = index - sectionMeta.offset + return section.shouldDeselect(at: sectionIndex) + } + + /// When a deselection occurs, this method will be called to notify the section + /// - Parameter index: The element index + public func didDeselect(at index: Int) { + guard let sectionMeta = self.sectionForElementIndex(index) else { return } + guard let section = sectionMeta.section as? SelectionHandler else { return } + + let sectionIndex = index - sectionMeta.offset + return section.didDeselect(at: sectionIndex) + } + + /// Selects the element at the specified index + /// - Parameter index: The element index + public func select(index: Int) { + guard let sectionMeta = self.sectionForElementIndex(index) else { return } + guard let section = sectionMeta.section as? SelectionHandler else { return } + + let sectionIndex = index - sectionMeta.offset + section.select(index: sectionIndex) + } + + /// Deselects the element at the specified index + /// - Parameter index: The element index + public func deselect(index: Int) { + guard let sectionMeta = self.sectionForElementIndex(index) else { return } + guard let section = sectionMeta.section as? SelectionHandler else { return } + + let sectionIndex = index - sectionMeta.offset + section.deselect(index: sectionIndex) + } + + /// Selects all elements in this section + public func selectAll() { + selectionHandlingSections.forEach { $0.selectAll() } + } + + /// Deselects all elements in this section + public func deselectAll() { + selectionHandlingSections.forEach { $0.deselectAll() } + } +} diff --git a/Sources/Composed/Sections/FlatSection.swift b/Sources/Composed/Sections/FlatSection.swift new file mode 100644 index 0000000..dcdf0e8 --- /dev/null +++ b/Sources/Composed/Sections/FlatSection.swift @@ -0,0 +1,455 @@ +import Foundation + +/// A section that flattens each of its children in to a single section. +open class FlatSection: Section, CustomReflectable, SectionUpdateDelegate, SectionProviderUpdateDelegate { + private enum Child { + /// A single section. + case section(Section) + + /// An object that provides 0 or more sections. + case sectionProvider(SectionProvider) + + func equals(_ otherSection: Section) -> Bool { + switch self { + case .section(let section): + return section === otherSection + case .sectionProvider: + return false + } + } + + func equals(_ otherSectionProvider: SectionProvider) -> Bool { + switch self { + case .sectionProvider(let sectionProvider): + return sectionProvider === otherSectionProvider + case .section: + return false + } + } + } + + public private(set) var sections: ContiguousArray
= [] + + public var numberOfElements: Int { + sections.map(\.numberOfElements).reduce(0, +) + } + + public weak var updateDelegate: SectionUpdateDelegate? + + public var customMirror: Mirror { + Mirror( + self, + children: [ + "children": children, + ], + displayStyle: .struct + ) + } + + private var children: [Child] = [] + + public init() {} + + open func section(_ section: Section, didRemoveElementAt index: Int) { + guard let sectionOffset = indexForFirstElement(of: section) else { return } + updateDelegate?.section(self, didRemoveElementAt: sectionOffset + index) + } + + open func section(_ section: Section, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) { + if let updateDelegate = updateDelegate { + updateDelegate.section(section, willPerformBatchUpdates: updates, forceReloadData: forceReloadData) + } else { + updates() + } + } + + open func invalidateAll(_ section: Section) { + updateDelegate?.invalidateAll(self) + } + + open func section(_ section: Section, didInsertElementAt index: Int) { + guard let sectionOffset = indexForFirstElement(of: section) else { return } + updateDelegate?.section(self, didInsertElementAt: sectionOffset + index) + } + + open func section(_ section: Section, didUpdateElementAt index: Int) { + guard let sectionOffset = indexForFirstElement(of: section) else { return } + updateDelegate?.section(self, didUpdateElementAt: sectionOffset + index) + } + + open func section(_ section: Section, didMoveElementAt index: Int, to newIndex: Int) { + guard let sectionOffset = indexForFirstElement(of: section) else { return } + updateDelegate?.section(self, didMoveElementAt: sectionOffset + index, to: newIndex + index) + } + + open func selectedIndexes(in section: Section) -> [Int] { + guard let allSelectedIndexes = updateDelegate?.selectedIndexes(in: self) else { return [] } + guard let sectionIndexes = indexesRange(for: section) else { return [] } + + return allSelectedIndexes + .filter(sectionIndexes.contains(_:)) + .map { $0 - sectionIndexes.startIndex } + } + + open func section(_ section: Section, select index: Int) { + guard let sectionOffset = indexForFirstElement(of: section) else { return } + updateDelegate?.section(self, select: sectionOffset + index) + } + + open func section(_ section: Section, deselect index: Int) { + guard let sectionOffset = indexForFirstElement(of: section) else { return } + updateDelegate?.section(self, deselect: sectionOffset + index) + } + + open func section(_ section: Section, move sourceIndex: Int, to destinationIndex: Int) { + guard let sectionOffset = indexForFirstElement(of: section) else { return } + updateDelegate?.section(self, move: sourceIndex + sectionOffset, to: destinationIndex + sectionOffset) + } + + open func sectionDidInvalidateHeader(_ section: Section) { + // Headers of children are currently ignored. + } + + open func sectionDidInvalidateFooter(_ section: Section) { + // Footers of children are currently ignored. + } + + open func provider(_ provider: SectionProvider, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) { + if let updateDelegate = updateDelegate { + updateDelegate.section(self, willPerformBatchUpdates: updates, forceReloadData: forceReloadData) + } else { + updates() + } + } + + open func invalidateAll(_ provider: SectionProvider) { + sections = ContiguousArray(children.flatMap { child -> [Section] in + switch child { + case .section(let section): + return [section] + case .sectionProvider(let sectionProvider): + return sectionProvider.sections + } + }) + updateDelegate?.invalidateAll(self) + } + + open func provider(_ provider: SectionProvider, didInsertSections sections: [Section], at indexes: IndexSet) { + guard let providerSectionIndex = sectionIndex(of: provider) else { + assertionFailure(#function + " has been called for a provider that is not a child") + return + } + + performBatchUpdates { _ in + for (section, index) in zip(sections, indexes) { + section.updateDelegate = self + + let sectionIndex = index + providerSectionIndex + self.sections.insert(section, at: sectionIndex) + let firstSectionIndex = self.indexForFirstElement(of: section)! + + (firstSectionIndex.. (section: Section, offset: Int)? { + var offset = 0 + + for child in children { + switch child { + case .section(let section): + if !section.isEmpty, offset == index { + return (section, offset) + } else if index < offset + section.numberOfElements { + return (section, offset) + } + + offset += section.numberOfElements + case .sectionProvider(let sectionProvider): + for section in sectionProvider.sections { + if !section.isEmpty, offset == index { + return (section, offset) + } else if index < offset + section.numberOfElements { + return (section, offset) + } + + offset += section.numberOfElements + } + } + } + + return nil + } + + public final func indexForFirstElement(of section: Section) -> Int? { + var offset = 0 + + for childSection in sections { + if childSection === section { + return offset + } + + offset += childSection.numberOfElements + } + + return nil + } + + public final func indexForFirstElement(of sectionProvider: SectionProvider) -> Int? { + var offset = 0 + + for child in children { + switch child { + case .section(let section): + offset += section.numberOfElements + case .sectionProvider(let childSectionProvider): + if childSectionProvider === sectionProvider { + return offset + } else if let aggregate = childSectionProvider as? AggregateSectionProvider, let sectionOffset = aggregate.sectionOffset(for: sectionProvider) { + return childSectionProvider.sections[0.. Range? { + guard let sectionOffset = indexForFirstElement(of: section) else { return nil } + return (sectionOffset.. Int? { + var index = 0 + + for child in children { + switch child { + case .section(let childSection): + if childSection === section { + return index + } + case .sectionProvider: + break + } + + index += 1 + } + + return nil + } + + public final func childIndex(of sectionProvider: SectionProvider) -> Int? { + var index = 0 + + for child in children { + switch child { + case .section: + break + case .sectionProvider(let childSectionProvider): + if childSectionProvider === sectionProvider { + return index + } + } + + index += 1 + } + + return nil + } + + private func indexForFirstElement(of child: Child) -> Int? { + switch child { + case .section(let childSection): + return indexForFirstElement(of: childSection) + case .sectionProvider(let sectionProvider): + return indexForFirstElement(of: sectionProvider) + } + } + + /// The index of the first section of `sectionProvider` in the `sections` array. + /// + /// - parameter sectionProvider: The section provider to calculate the section index of. + /// - returns: The index of the first section of `sectionProvider` in the `sections` array, or `nil` if it is not in the hierarchy. + private func sectionIndex(of sectionProvider: SectionProvider) -> Int? { + var index = 0 + + for child in children { + switch child { + case .section: + index += 1 + case .sectionProvider(let childSectionProvider): + if childSectionProvider === sectionProvider { + return index + } else if let aggregate = childSectionProvider as? AggregateSectionProvider, let offset = aggregate.sectionOffset(for: sectionProvider) { + return index + offset + } + + index += childSectionProvider.numberOfSections + } + } + + return nil + } +} diff --git a/Sources/Composed/Sections/ManagedSection.swift b/Sources/Composed/Sections/ManagedSection.swift index 7df49b1..123e6af 100644 --- a/Sources/Composed/Sections/ManagedSection.swift +++ b/Sources/Composed/Sections/ManagedSection.swift @@ -92,9 +92,11 @@ open class ManagedSection: NSObject, NSFetchedResultsControllerDelegate isSuspended = false } + private var batchUpdates: [(_ updateDelegate: SectionUpdateDelegate?) -> Void] = [] + public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { guard !isSuspended else { return } - updateDelegate?.willBeginUpdating(self) + batchUpdates = [] } public func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { @@ -102,18 +104,28 @@ open class ManagedSection: NSObject, NSFetchedResultsControllerDelegate switch type { case .insert: - updateDelegate?.section(self, didInsertElementAt: newIndexPath!.item) + batchUpdates.append({ updateDelegate in + updateDelegate?.section(self, didInsertElementAt: newIndexPath!.item) + }) case .delete: let sections = fetchedResultsController?.sections ?? [] if !sections.isEmpty, sections.first?.numberOfObjects == 0 { - updateDelegate?.invalidateAll(self) + batchUpdates.append({ updateDelegate in + updateDelegate?.invalidateAll(self) + }) } else { - updateDelegate?.section(self, didRemoveElementAt: indexPath!.item) + batchUpdates.append({ updateDelegate in + updateDelegate?.section(self, didRemoveElementAt: indexPath!.item) + }) } case .update: - updateDelegate?.section(self, didUpdateElementAt: indexPath!.item) + batchUpdates.append({ updateDelegate in + updateDelegate?.section(self, didUpdateElementAt: indexPath!.item) + }) case .move: - updateDelegate?.section(self, didMoveElementAt: indexPath!.item, to: newIndexPath!.item) + batchUpdates.append({ updateDelegate in + updateDelegate?.section(self, didMoveElementAt: indexPath!.item, to: newIndexPath!.item) + }) default: fatalError("Unsupported type") } @@ -121,7 +133,10 @@ open class ManagedSection: NSObject, NSFetchedResultsControllerDelegate public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { guard !isSuspended else { return } - updateDelegate?.didEndUpdating(self) + performBatchUpdates { updateDelegate in + batchUpdates.forEach { $0(updateDelegate) } + } + batchUpdates = [] } } diff --git a/Sources/Composed/Sections/SingleElementSection.swift b/Sources/Composed/Sections/SingleElementSection.swift index 3737a51..9204669 100644 --- a/Sources/Composed/Sections/SingleElementSection.swift +++ b/Sources/Composed/Sections/SingleElementSection.swift @@ -26,34 +26,36 @@ open class SingleElementSection: Section { public init(element: Element) { self.element = element - switch element as Any { - case Optional.none: numberOfElements = 0 - default: numberOfElements = 1 + if case Optional.none = element as Any { + numberOfElements = 0 + } else { + numberOfElements = 1 } } /// Replaces the element with the specified element /// - Parameter element: The new element - public func replace(element: Element) { - updateDelegate?.willBeginUpdating(self) - let wasEmpty = isEmpty - self.element = element - - switch element as Any { - case Optional.none: numberOfElements = 0 - default: numberOfElements = 1 - } - - switch (wasEmpty, isEmpty) { - case (true, true): - break - case (true, false): - updateDelegate?.section(self, didInsertElementAt: 0) - case (false, true): - updateDelegate?.section(self, didRemoveElementAt: 0) - case (false, false): - updateDelegate?.section(self, didUpdateElementAt: 0) + open func replace(element: Element) { + performBatchUpdates { _ in + let wasEmpty = isEmpty + self.element = element + + if case Optional.none = element as Any { + numberOfElements = 0 + } else { + numberOfElements = 1 + } + + switch (wasEmpty, isEmpty) { + case (true, true): + break + case (true, false): + updateDelegate?.section(self, didInsertElementAt: 0) + case (false, true): + updateDelegate?.section(self, didRemoveElementAt: 0) + case (false, false): + updateDelegate?.section(self, didUpdateElementAt: 0) + } } - updateDelegate?.didEndUpdating(self) } } diff --git a/Sources/ComposedLayouts/CollectionCompositionalLayoutHandler.swift b/Sources/ComposedLayouts/CollectionCompositionalLayoutHandler.swift index 8b91d37..43f1337 100644 --- a/Sources/ComposedLayouts/CollectionCompositionalLayoutHandler.swift +++ b/Sources/ComposedLayouts/CollectionCompositionalLayoutHandler.swift @@ -3,7 +3,7 @@ import ComposedUI @available(iOS 13.0, *) /// Conform your section to this protocol to provide a layout section for a `UICollectionViewCompositionalLayout` -public protocol CompositionalLayoutHandler: CollectionSectionProvider { +public protocol CompositionalLayoutHandler: UICollectionViewSection { /// Return a layout section for this section /// - Parameter environment: The current environment for this layout diff --git a/Sources/ComposedLayouts/CollectionCoordinator+FlowLayout.swift b/Sources/ComposedLayouts/CollectionCoordinator+FlowLayout.swift index 56c7a05..c02b5d3 100644 --- a/Sources/ComposedLayouts/CollectionCoordinator+FlowLayout.swift +++ b/Sources/ComposedLayouts/CollectionCoordinator+FlowLayout.swift @@ -11,51 +11,50 @@ extension CollectionCoordinator: UICollectionViewDelegateFlowLayout { return metrics } - private func metrics(for section: CollectionFlowLayoutHandler, collectionView: UICollectionView, layout: UICollectionViewFlowLayout) -> CollectionFlowLayoutMetrics { - return section.layoutMetrics(suggested: suggestedMetrics(for: layout), environment: environment(collectionView: collectionView, layout: layout)) + private func metrics(for section: CollectionFlowLayoutHandler, collectionView: UICollectionView, layout: UICollectionViewFlowLayout, rootSectionIndex: Int) -> CollectionFlowLayoutMetrics { + return section.layoutMetrics(suggested: suggestedMetrics(for: layout), environment: environment(collectionView: collectionView, layout: layout, rootSectionIndex: rootSectionIndex)) } - private func environment(collectionView: UICollectionView, layout: UICollectionViewFlowLayout) -> CollectionFlowLayoutEnvironment { - let size = collectionView.bounds.insetBy(dx: layout.sectionInset.left + layout.sectionInset.right, dy: 0).size - return CollectionFlowLayoutEnvironment(contentSize: size, traitCollection: collectionView.traitCollection) + private func environment(collectionView: UICollectionView, layout: UICollectionViewFlowLayout, rootSectionIndex: Int) -> CollectionFlowLayoutEnvironment { + return CollectionFlowLayoutEnvironment(collectionCoordinator: self, collectionView: collectionView, layout: layout, rootSectionIndex: rootSectionIndex) } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { guard let layout = collectionViewLayout as? UICollectionViewFlowLayout else { return .zero } let suggested = layout.estimatedItemSize == .zero ? layout.itemSize : layout.estimatedItemSize guard let section = sectionProvider.sections[indexPath.section] as? CollectionFlowLayoutHandler else { return suggested } - let metrics = self.metrics(for: section, collectionView: collectionView, layout: layout) - return section.sizeForItem(at: indexPath.item, suggested: suggested, metrics: metrics, environment: environment(collectionView: collectionView, layout: layout)) + let metrics = self.metrics(for: section, collectionView: collectionView, layout: layout, rootSectionIndex: indexPath.section) + return section.sizeForItem(at: indexPath.item, suggested: suggested, metrics: metrics, environment: environment(collectionView: collectionView, layout: layout, rootSectionIndex: indexPath.section)) } - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt sectionIndex: Int) -> UIEdgeInsets { guard let layout = collectionViewLayout as? UICollectionViewFlowLayout else { return .zero } - guard let section = sectionProvider.sections[section] as? CollectionFlowLayoutHandler else { return layout.sectionInset } - return metrics(for: section, collectionView: collectionView, layout: layout).contentInsets + guard let section = sectionProvider.sections[sectionIndex] as? CollectionFlowLayoutHandler else { return layout.sectionInset } + return metrics(for: section, collectionView: collectionView, layout: layout, rootSectionIndex: sectionIndex).contentInsets } - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt sectionIndex: Int) -> CGFloat { guard let layout = collectionViewLayout as? UICollectionViewFlowLayout else { return 0 } - guard let section = sectionProvider.sections[section] as? CollectionFlowLayoutHandler else { return layout.minimumLineSpacing } - return metrics(for: section, collectionView: collectionView, layout: layout).minimumLineSpacing + guard let section = sectionProvider.sections[sectionIndex] as? CollectionFlowLayoutHandler else { return layout.minimumLineSpacing } + return metrics(for: section, collectionView: collectionView, layout: layout, rootSectionIndex: sectionIndex).minimumLineSpacing } - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt sectionIndex: Int) -> CGFloat { guard let layout = collectionViewLayout as? UICollectionViewFlowLayout else { return 0 } - guard let section = sectionProvider.sections[section] as? CollectionFlowLayoutHandler else { return layout.minimumInteritemSpacing } - return metrics(for: section, collectionView: collectionView, layout: layout).minimumInteritemSpacing + guard let section = sectionProvider.sections[sectionIndex] as? CollectionFlowLayoutHandler else { return layout.minimumInteritemSpacing } + return metrics(for: section, collectionView: collectionView, layout: layout, rootSectionIndex: sectionIndex).minimumInteritemSpacing } - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection sectionIndex: Int) -> CGSize { guard let layout = collectionViewLayout as? UICollectionViewFlowLayout else { return .zero } - guard let section = sectionProvider.sections[section] as? CollectionFlowLayoutHandler else { return layout.headerReferenceSize } - return section.referenceHeaderSize(suggested: layout.headerReferenceSize, environment: environment(collectionView: collectionView, layout: layout)) + guard let section = sectionProvider.sections[sectionIndex] as? CollectionFlowLayoutHandler else { return layout.headerReferenceSize } + return section.referenceHeaderSize(suggested: layout.headerReferenceSize, environment: environment(collectionView: collectionView, layout: layout, rootSectionIndex: sectionIndex)) } - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection sectionIndex: Int) -> CGSize { guard let layout = collectionViewLayout as? UICollectionViewFlowLayout else { return .zero } - guard let section = sectionProvider.sections[section] as? CollectionFlowLayoutHandler else { return layout.footerReferenceSize } - return section.referenceFooterSize(suggested: layout.footerReferenceSize, environment: environment(collectionView: collectionView, layout: layout)) + guard let section = sectionProvider.sections[sectionIndex] as? CollectionFlowLayoutHandler else { return layout.footerReferenceSize } + return section.referenceFooterSize(suggested: layout.footerReferenceSize, environment: environment(collectionView: collectionView, layout: layout, rootSectionIndex: sectionIndex)) } } diff --git a/Sources/ComposedLayouts/CollectionFlowLayoutHandler.swift b/Sources/ComposedLayouts/CollectionFlowLayoutHandler.swift index 1758453..5b78cc8 100644 --- a/Sources/ComposedLayouts/CollectionFlowLayoutHandler.swift +++ b/Sources/ComposedLayouts/CollectionFlowLayoutHandler.swift @@ -15,23 +15,40 @@ public struct CollectionFlowLayoutMetrics { /// Represents the current environment for a `UICollectionView` public struct CollectionFlowLayoutEnvironment { - /// Returns the current size of the collectionView - public let contentSize: CGSize + /// Returns the current size of ``collectionView``, minus horizontal content insets. + public var contentSize: CGSize { + var contentSize = collectionView.bounds.size + contentSize.width -= collectionView.contentInset.left + collectionView.contentInset.right + return contentSize + } + /// Returns the current traits of the collectionView - public let traitCollection: UITraitCollection + public var traitCollection: UITraitCollection { + collectionView.traitCollection + } + + public let collectionCoordinator: CollectionCoordinator + + public let collectionView: UICollectionView + + public let layout: UICollectionViewFlowLayout + + public let rootSectionIndex: Int /// Instantiates a new instance /// - Parameters: - /// - contentSize: The current content size for this environment - /// - traitCollection: The current traits for this environment - public init(contentSize: CGSize, traitCollection: UITraitCollection) { - self.contentSize = contentSize - self.traitCollection = traitCollection + /// - contentSize: The collection view collectionView + /// - layout: The flow layout + public init(collectionCoordinator: CollectionCoordinator, collectionView: UICollectionView, layout: UICollectionViewFlowLayout, rootSectionIndex: Int) { + self.collectionCoordinator = collectionCoordinator + self.collectionView = collectionView + self.layout = layout + self.rootSectionIndex = rootSectionIndex } } /// Conform your section to this protocol to override sizing and metric values for a `UICollectionViewFlowLayout` -public protocol CollectionFlowLayoutHandler: CollectionSectionProvider { +public protocol CollectionFlowLayoutHandler: UICollectionViewSection { /// Return the size for the element at the specified index /// - Parameters: diff --git a/Sources/ComposedLayouts/CollectionFlowLayoutSizingStrategy.swift b/Sources/ComposedLayouts/CollectionFlowLayoutSizingStrategy.swift index aef1e4e..a8a46d1 100644 --- a/Sources/ComposedLayouts/CollectionFlowLayoutSizingStrategy.swift +++ b/Sources/ComposedLayouts/CollectionFlowLayoutSizingStrategy.swift @@ -21,7 +21,11 @@ open class CollectionFlowLayoutSizingStrategy { public let columnCount: Int /// The sizing mode to use for sizing cells - public let sizingMode: SizingMode + public var sizingMode: SizingMode { + didSet { + clearCachedSizes() + } + } /// The metrics used for calculating column widths public let metrics: CollectionFlowLayoutMetrics @@ -37,6 +41,10 @@ open class CollectionFlowLayoutSizingStrategy { self.metrics = metrics } + public func clearCachedSizes() { + cachedSizes = [:] + } + private var cachedSizes: [Int: CGSize] = [:] private func cachedSize(forElementAt index: Int) -> CGSize? { switch sizingMode { @@ -75,7 +83,7 @@ open class CollectionFlowLayoutSizingStrategy { cachedSizes[index] = size return size case .automatic(_, let prototype): - let targetView: UIView? + let targetView: UIView let targetSize = CGSize(width: width, height: 0) if let cell = prototype as? UICollectionViewCell { @@ -84,11 +92,10 @@ open class CollectionFlowLayoutSizingStrategy { targetView = prototype } - let size = targetView?.systemLayoutSizeFitting( + let size = targetView.systemLayoutSizeFitting( targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) - ?? .zero cachedSizes[index] = size return size diff --git a/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift b/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift index a04ab4f..819bc4f 100644 --- a/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift +++ b/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift @@ -1,8 +1,9 @@ import UIKit import Composed +import os.log /// Conform to this protocol to receive `CollectionCoordinator` events -public protocol CollectionCoordinatorDelegate: class { +public protocol CollectionCoordinatorDelegate: AnyObject { /// Return a background view to be shown in the `UICollectionView` when its content is empty. Defaults to nil /// - Parameters: @@ -26,6 +27,30 @@ open class CollectionCoordinator: NSObject { let nibName: String let bundle: Bundle let reuseIdentifier: String + let supplementaryViewKind: String? + + internal init(nibName: String, bundle: Bundle, reuseIdentifier: String, supplementaryViewKind: String? = nil) { + self.nibName = nibName + self.bundle = bundle + self.reuseIdentifier = reuseIdentifier + self.supplementaryViewKind = supplementaryViewKind + } + } + + private struct ClassRegistration: Equatable { + static func == (lhs: CollectionCoordinator.ClassRegistration, rhs: CollectionCoordinator.ClassRegistration) -> Bool { + lhs.class == rhs.class && lhs.reuseIdentifier == rhs.reuseIdentifier && lhs.supplementaryViewKind == rhs.supplementaryViewKind + } + + let `class`: UIView.Type + let reuseIdentifier: String + let supplementaryViewKind: String? + + internal init(class: UIView.Type, reuseIdentifier: String, supplementaryViewKind: String? = nil) { + self.class = `class` + self.reuseIdentifier = reuseIdentifier + self.supplementaryViewKind = supplementaryViewKind + } } /// Get/set the delegate for this coordinator @@ -38,17 +63,25 @@ open class CollectionCoordinator: NSObject { return mapper.provider } - private var mapper: SectionProviderMapping + /// If `true` this `CollectionCoordinator` instance will log changes to the system log. + public var enableLogs: Bool = false + + /// A closure that will be called whenever a debug log message is produced. + public var logger: ((_ message: String) -> Void)? + + internal var changesReducer = ChangesReducer() - private var defersUpdate: Bool = false - private var sectionRemoves: [() -> Void] = [] - private var sectionInserts: [() -> Void] = [] - private var sectionUpdates: [() -> Void] = [] + /// A flag indicating if the `updates` closure is currently being called in a call to `performBatchUpdates`. + /// + /// This is used to prevent multiple calls to `performBatchUpdates` once all the updates have been applied to + /// the collection view, which can cause the data to be out of sync. + fileprivate var isPerformingUpdates = false - private var removes: [() -> Void] = [] - private var inserts: [() -> Void] = [] - private var changes: [() -> Void] = [] - private var moves: [() -> Void] = [] + /// A (temporary) flag that is set to `true` when batch updates should be ignored because `reloadData` + /// will be called after the updates. + fileprivate var reloadDataBatchUpdates = false + + private var mapper: SectionProviderMapping private let collectionView: UICollectionView @@ -64,8 +97,14 @@ open class CollectionCoordinator: NSObject { private weak var originalDropDelegate: UICollectionViewDropDelegate? private var dropDelegateObserver: NSKeyValueObservation? - private var cachedProviders: [CollectionElementsProvider] = [] + private var cachedElementsProviders: [UICollectionViewSectionElementsProvider] = [] + private var cellSectionMap = [UICollectionViewCell: (CollectionCellElement, Section)]() + + // Prevent registering the same cell multiple times; this might break reuse. + // See: https://developer.apple.com/forums/thread/681739 + // The post applies to the newer API, but maybe it was always true? private var nibRegistrations = Set() + private var classRegistrations = [ClassRegistration]() /// Make a new coordinator with the specified collectionView and sectionProvider /// - Parameters: @@ -153,185 +192,307 @@ open class CollectionCoordinator: NSObject { open func invalidateVisibleCells() { for (indexPath, cell) in zip(collectionView.indexPathsForVisibleItems, collectionView.visibleCells) { let elements = elementsProvider(for: indexPath.section) - elements.cell.configure(cell, indexPath.item, mapper.provider.sections[indexPath.section]) + elements.cell(for: indexPath.item).configure(cell, indexPath.item, mapper.provider.sections[indexPath.section]) } } // Prepares and caches the section to improve performance private func prepareSections() { - cachedProviders.removeAll() + debugLog("Preparing sections") + + cachedElementsProviders.removeAll() mapper.delegate = self for index in 0.. Void) { + self.mapping(mapping, willPerformBatchUpdates: updates, forceReloadData: false) } - public func mappingDidEndUpdating(_ mapping: SectionProviderMapping) { + public func mapping(_ mapping: SectionProviderMapping, willPerformBatchUpdates updates: () -> Void, forceReloadData: Bool) { assert(Thread.isMainThread) + + guard !changesReducer.hasActiveUpdates else { + assert(!forceReloadData, "Cannot reload data while inside `performBatchUpdates`") + + // The changes reducer will only have active updates after `beginUpdating` has + // been called, which is done inside `performBatchUpdates`. This ensures that any + // `updates` closure that trigger other updates and call in to this again have + // their updates applied in the same batch. + updates() + return + } + + guard !forceReloadData else { + debugLog("Performing updates before reloading data") + updates() + + prepareSections() + + debugLog("Reloading data") + collectionView.reloadData() + return + } + + guard !isPerformingUpdates else { + print("Batch updates are being applied to \(self) after a previous batch has been applied but before the collection view has finished laying out. This can occur when the configuration for one of your views triggers an update. Since the update has not yet finished this can cause data to be out of sync. See \(#filePath):L\(#line) for more details. Calling `reloadData`.") + mappingDidInvalidate(mapping) + return + } + + isPerformingUpdates = true + + /** + Ensure collection view has been laid out, essentially ensuring that it will not be called + by the collection view itself, which may trigger fetching of stale data and cause a crash. + + At this point the `updates` closure has not been called, so any updates about to be applied + have not yet been reflected in the data layer. + + This is mainly for making crashes here easier to debug. + */ + debugLog("Layout out collection view, if needed") + collectionView.layoutIfNeeded() + debugLog("Collection view has been laid out") collectionView.performBatchUpdates({ - if defersUpdate { - prepareSections() + debugLog("Starting batch updates") + changesReducer.beginUpdating() + + updates() + + prepareSections() + + guard let changeset = changesReducer.endUpdating() else { + assertionFailure("Calls to `beginUpdating` must be balanced with calls to `endUpdating`") + return } - removes.forEach { $0() } - inserts.forEach { $0() } - changes.forEach { $0() } - moves.forEach { $0() } - sectionRemoves.forEach { $0() } - sectionInserts.forEach { $0() } - sectionUpdates.forEach { $0() } - reset() - defersUpdate = false - }) - } + debugLog("Deleting sections \(changeset.groupsRemoved.sorted(by: >))") + collectionView.deleteSections(IndexSet(changeset.groupsRemoved)) - public func mapping(_ mapping: SectionProviderMapping, didUpdateSections sections: IndexSet) { - assert(Thread.isMainThread) - sectionUpdates.append { [weak self] in - guard let self = self else { return } - if !self.defersUpdate { self.prepareSections() } - self.collectionView.reloadSections(sections) - } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + debugLog("Deleting items \(changeset.elementsRemoved.sorted(by: >))") + collectionView.deleteItems(at: Array(changeset.elementsRemoved)) + + debugLog("Reloaded sections \(changeset.groupsUpdated.sorted(by: >))") + collectionView.reloadSections(IndexSet(changeset.groupsUpdated)) + + debugLog("Inserting items \(changeset.elementsInserted.sorted(by: <))") + collectionView.insertItems(at: Array(changeset.elementsInserted)) + + debugLog("Reloading items \(changeset.elementsUpdated.sorted(by: <))") + collectionView.reloadItems(at: Array(changeset.elementsUpdated)) + + changeset.elementsMoved.forEach { move in + debugLog("Moving \(move.from) to \(move.to)") + collectionView.moveItem(at: move.from, to: move.to) + } + + debugLog("Inserting sections \(changeset.groupsInserted.sorted(by: >))") + collectionView.insertSections(IndexSet(changeset.groupsInserted)) + + debugLog("Batch updates have been applied") + }, completion: { [weak self] isFinished in + self?.debugLog("Batch updates completed. isFinished: \(isFinished)") + }) + isPerformingUpdates = false + debugLog("`performBatchUpdates` call has completed") } public func mapping(_ mapping: SectionProviderMapping, didInsertSections sections: IndexSet) { assert(Thread.isMainThread) - sectionInserts.append { [weak self] in - guard let self = self else { return } - if !self.defersUpdate { self.prepareSections() } - self.collectionView.insertSections(sections) + + debugLog(#function + "\(Array(sections))") + + guard !reloadDataBatchUpdates else { return } + + guard isPerformingUpdates else { + prepareSections() + collectionView.insertSections(sections) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.insertGroups(sections) } public func mapping(_ mapping: SectionProviderMapping, didRemoveSections sections: IndexSet) { assert(Thread.isMainThread) - sectionRemoves.append { [weak self] in - guard let self = self else { return } - if !self.defersUpdate { self.prepareSections() } - self.collectionView.deleteSections(sections) + + debugLog(#function + "\(Array(sections))") + + guard !reloadDataBatchUpdates else { return } + + guard isPerformingUpdates else { + prepareSections() + collectionView.deleteSections(sections) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.removeGroups(sections) } public func mapping(_ mapping: SectionProviderMapping, didInsertElementsAt indexPaths: [IndexPath]) { assert(Thread.isMainThread) - inserts.append { [weak self] in - guard let self = self else { return } - self.collectionView.insertItems(at: indexPaths) + + debugLog(#function + "\(indexPaths)") + + guard !reloadDataBatchUpdates else { return } + + guard isPerformingUpdates else { + prepareSections() + collectionView.insertItems(at: indexPaths) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.insertElements(at: indexPaths) } public func mapping(_ mapping: SectionProviderMapping, didRemoveElementsAt indexPaths: [IndexPath]) { assert(Thread.isMainThread) - removes.append { [weak self] in - guard let self = self else { return } - self.collectionView.deleteItems(at: indexPaths) + + debugLog(#function + "\(indexPaths)") + + guard !reloadDataBatchUpdates else { return } + + guard isPerformingUpdates else { + prepareSections() + collectionView.deleteItems(at: indexPaths) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.removeElements(at: indexPaths) } public func mapping(_ mapping: SectionProviderMapping, didUpdateElementsAt indexPaths: [IndexPath]) { assert(Thread.isMainThread) - changes.append { [weak self] in - guard let self = self else { return } + + debugLog(#function + "\(indexPaths)") + + guard !reloadDataBatchUpdates else { return } + + guard isPerformingUpdates else { + prepareSections() var indexPathsToReload: [IndexPath] = [] for indexPath in indexPaths { guard let section = self.sectionProvider.sections[indexPath.section] as? CollectionUpdateHandler, - !section.prefersReload(forElementAt: indexPath.item), - let cell = self.collectionView.cellForItem(at: indexPath) else { - indexPathsToReload.append(indexPath) - continue + !section.prefersReload(forElementAt: indexPath.item), + let cell = self.collectionView.cellForItem(at: indexPath) else { + indexPathsToReload.append(indexPath) + continue } - self.cachedProviders[indexPath.section].cell.configure(cell, indexPath.item, self.mapper.provider.sections[indexPath.section]) + self.cachedElementsProviders[indexPath.section].cell(for: indexPath.item).configure(cell, indexPath.item, self.mapper.provider.sections[indexPath.section]) } guard !indexPathsToReload.isEmpty else { return } @@ -341,19 +502,26 @@ extension CollectionCoordinator: SectionProviderMappingDelegate { self.collectionView.reloadItems(at: indexPathsToReload) CATransaction.setDisableActions(false) CATransaction.commit() + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.updateElements(at: indexPaths) } public func mapping(_ mapping: SectionProviderMapping, didMoveElementsAt moves: [(IndexPath, IndexPath)]) { assert(Thread.isMainThread) - self.moves.append { [weak self] in - guard let self = self else { return } - moves.forEach { self.collectionView.moveItem(at: $0.0, to: $0.1) } + + debugLog(#function + "\(moves)") + + guard !reloadDataBatchUpdates else { return } + + guard isPerformingUpdates else { + prepareSections() + moves.forEach { collectionView.moveItem(at: $0.0, to: $0.1) } + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.moveElements(moves) } public func mapping(_ mapping: SectionProviderMapping, selectedIndexesIn section: Int) -> [Int] { @@ -373,9 +541,63 @@ extension CollectionCoordinator: SectionProviderMappingDelegate { } public func mapping(_ mapping: SectionProviderMapping, move sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { - collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath) + guard !reloadDataBatchUpdates else { return } + // TODO: Check `isPerformingBatchedUpdates` + self.mapping(mapping, didMoveElementsAt: [(sourceIndexPath, destinationIndexPath)]) } + public func mappingDidInvalidateHeader(at sectionIndex: Int) { + // Ensure elements provider is available, views have been registered, etc. + prepareSections() + + let elementsProvider = self.elementsProvider(for: sectionIndex) + let section = self.mapper.provider.sections[sectionIndex] + + // Without performing these changes inside a batch updates the header + // may briefly be hidden, causing a "flash" to occur. + collectionView.performBatchUpdates { + let context = UICollectionViewFlowLayoutInvalidationContext() + context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader, at: [IndexPath(item: 0, section: sectionIndex)]) + invalidateLayout(with: context) + + if + let headerView = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: sectionIndex)), + let header = elementsProvider.header, + header.kind.rawValue == UICollectionView.elementKindSectionHeader + { + header.configure(headerView, sectionIndex, section) + } + } + } + + public func mappingDidInvalidateFooter(at sectionIndex: Int) { + // Ensure elements provider is available, views have been registered, etc. + prepareSections() + + let elementsProvider = self.elementsProvider(for: sectionIndex) + let section = self.mapper.provider.sections[sectionIndex] + + debugLog("Section \(sectionIndex) invalidated footer") + + // Without performing these changes inside a batch updates the footer + // may briefly be hidden, causing a "flash" to occur. + collectionView.performBatchUpdates { + let context = UICollectionViewFlowLayoutInvalidationContext() + context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter, at: [IndexPath(item: 0, section: sectionIndex)]) + invalidateLayout(with: context) + + // Even when invalidating the layout the collection view may not + // request the view again, so it won't be reconfigured. Maybe it + // will only request the view again if the size changes? + if + let footerView = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: IndexPath(item: 0, section: sectionIndex)), + let footer = elementsProvider.footer, + footer.kind.rawValue == UICollectionView.elementKindSectionFooter + { + footer.configure(footerView, sectionIndex, section) + } + } + } } // MARK: - UICollectionViewDataSource @@ -398,7 +620,7 @@ extension CollectionCoordinator: UICollectionViewDataSource { let elements = elementsProvider(for: indexPath.section) let section = mapper.provider.sections[indexPath.section] - elements.cell.willAppear(cell, indexPath.item, section) + elements.cell(for: indexPath.item).willAppear?(cell, indexPath.item, section) } public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { @@ -406,17 +628,17 @@ extension CollectionCoordinator: UICollectionViewDataSource { defer { originalDelegate?.collectionView?(collectionView, didEndDisplaying: cell, forItemAt: indexPath) } - - guard indexPath.section < sectionProvider.numberOfSections else { return } - let elements = elementsProvider(for: indexPath.section) - let section = mapper.provider.sections[indexPath.section] - elements.cell.didDisappear(cell, indexPath.item, section) + if let (cellElement, section) = cellSectionMap[cell] { + cellElement.didDisappear?(cell, indexPath.item, section) + cellSectionMap.removeValue(forKey: cell) + } } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { assert(Thread.isMainThread) let elements = elementsProvider(for: indexPath.section) - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: elements.cell.reuseIdentifier, for: indexPath) + let cellElement = elements.cell(for: indexPath.item) + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellElement.reuseIdentifier, for: indexPath) if let handler = sectionProvider.sections[indexPath.section] as? EditingHandler { if let handler = sectionProvider.sections[indexPath.section] as? CollectionEditingHandler { @@ -426,7 +648,9 @@ extension CollectionCoordinator: UICollectionViewDataSource { } } - elements.cell.configure(cell, indexPath.item, mapper.provider.sections[indexPath.section]) + let section = mapper.provider.sections[indexPath.section] + cellSectionMap[cell] = (cellElement, section) + cellElement.configure(cell, indexPath.item, section) return cell } @@ -436,14 +660,17 @@ extension CollectionCoordinator: UICollectionViewDataSource { originalDelegate?.collectionView?(collectionView, willDisplaySupplementaryView: view, forElementKind: elementKind, at: indexPath) } - guard indexPath.section > sectionProvider.numberOfSections else { return } + guard indexPath.section < sectionProvider.numberOfSections else { return } + let elements = elementsProvider(for: indexPath.section) let section = mapper.provider.sections[indexPath.section] if let header = elements.header, header.kind.rawValue == elementKind { - elements.header?.willAppear?(view, indexPath.section, section) + header.willAppear?(view, indexPath.section, section) + header.configure(view, indexPath.section, section) } else if let footer = elements.footer, footer.kind.rawValue == elementKind { - elements.footer?.willAppear?(view, indexPath.section, section) + footer.willAppear?(view, indexPath.section, section) + footer.configure(view, indexPath.section, section) } else { // the original delegate can handle this } @@ -465,7 +692,7 @@ extension CollectionCoordinator: UICollectionViewDataSource { } else { guard let view = originalDataSource?.collectionView?(collectionView, viewForSupplementaryElementOfKind: kind, at: indexPath) else { // when in production its better to return 'something' to prevent crashing - assertionFailure("Unsupported supplementary kind: \(kind) at indexPath: \(indexPath). Did you forget to register your header or footer?") + assertionFailure("Unsupported supplementary kind: \(kind) at indexPath: \(indexPath). Check if your layout it returning attributes for the supplementary element at \(indexPath)") return collectionView.dequeue(supplementary: PlaceholderSupplementaryView.self, ofKind: PlaceholderSupplementaryView.kind, for: indexPath) } @@ -479,6 +706,7 @@ extension CollectionCoordinator: UICollectionViewDataSource { originalDelegate?.collectionView?(collectionView, didEndDisplayingSupplementaryView: view, forElementOfKind: elementKind, at: indexPath) } + guard !indexPath.isEmpty else { return } guard indexPath.section < sectionProvider.numberOfSections else { return } let elements = elementsProvider(for: indexPath.section) let section = mapper.provider.sections[indexPath.section] @@ -492,11 +720,11 @@ extension CollectionCoordinator: UICollectionViewDataSource { } } - private func elementsProvider(for section: Int) -> CollectionElementsProvider { - guard cachedProviders.indices.contains(section) else { + private func elementsProvider(for section: Int) -> UICollectionViewSectionElementsProvider { + guard cachedElementsProviders.indices.contains(section) else { fatalError("No UI configuration available for section \(section)") } - return cachedProviders[section] + return cachedElementsProviders[section] } } @@ -508,8 +736,8 @@ extension CollectionCoordinator { public func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler, - provider.allowsContextMenu(forElementAt: indexPath.item), - let cell = collectionView.cellForItem(at: indexPath) else { return nil } + provider.allowsContextMenu(forElementAt: indexPath.item), + let cell = collectionView.cellForItem(at: indexPath) else { return nil } let preview = provider.contextMenu(previewForElementAt: indexPath.item, cell: cell) return UIContextMenuConfiguration(identifier: indexPath.string, previewProvider: preview) { suggestedElements in return provider.contextMenu(forElementAt: indexPath.item, cell: cell, suggestedActions: suggestedElements) @@ -519,21 +747,21 @@ extension CollectionCoordinator { public func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return nil } guard let cell = collectionView.cellForItem(at: indexPath), - let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } + let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } return provider.contextMenu(previewForHighlightingElementAt: indexPath.item, cell: cell) } public func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return nil } guard let cell = collectionView.cellForItem(at: indexPath), - let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } + let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } return provider.contextMenu(previewForDismissingElementAt: indexPath.item, cell: cell) } public func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return } guard let cell = collectionView.cellForItem(at: indexPath), - let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return } + let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return } provider.contextMenu(willPerformPreviewActionForElementAt: indexPath.item, cell: cell, animator: animator) } @@ -703,8 +931,8 @@ extension CollectionCoordinator: UICollectionViewDropDelegate { guard !indexPath.isEmpty else { return nil } guard let section = sectionProvider.sections[indexPath.section] as? CollectionDragHandler, - let cell = collectionView.cellForItem(at: indexPath) else { - return originalDragDelegate?.collectionView?(collectionView, dragPreviewParametersForItemAt: indexPath) + let cell = collectionView.cellForItem(at: indexPath) else { + return originalDragDelegate?.collectionView?(collectionView, dragPreviewParametersForItemAt: indexPath) } return section.dragSession(previewParametersForElementAt: indexPath.item, cell: cell) @@ -712,7 +940,7 @@ extension CollectionCoordinator: UICollectionViewDropDelegate { public func collectionView(_ collectionView: UICollectionView, dropPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters? { guard let section = sectionProvider.sections[indexPath.section] as? CollectionDropHandler, - let cell = collectionView.cellForItem(at: indexPath) else { + let cell = collectionView.cellForItem(at: indexPath) else { return originalDropDelegate? .collectionView?(collectionView, dropPreviewParametersForItemAt: indexPath) } @@ -750,8 +978,8 @@ extension CollectionCoordinator: UICollectionViewDropDelegate { let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0) guard coordinator.proposal.operation == .move, - let section = sectionProvider.sections[destinationIndexPath.section] as? MoveHandler else { - return + let section = sectionProvider.sections[destinationIndexPath.section] as? MoveHandler else { + return } let item = coordinator.items.lazy diff --git a/Sources/ComposedUI/CollectionView/CollectionElement.swift b/Sources/ComposedUI/CollectionView/CollectionElement.swift index 7f25546..4bbdcb4 100644 --- a/Sources/ComposedUI/CollectionView/CollectionElement.swift +++ b/Sources/ComposedUI/CollectionView/CollectionElement.swift @@ -20,11 +20,8 @@ public enum CollectionElementKind { /// Defines an element used by a `CollectionSection` to provide configurations for a cell, header and/or footer. public protocol CollectionElement { - /// A typealias for representing a `UICollectionReusableView` - associatedtype View: UICollectionReusableView - /// The method to use for registering and dequeueing a view for this element - var dequeueMethod: DequeueMethod { get } + var dequeueMethod: AnyDequeueMethod { get } /// A closure that will be called whenever the elements view needs to be configured var configure: (UICollectionReusableView, Int, Section) -> Void { get } @@ -32,19 +29,30 @@ public protocol CollectionElement { /// The reuseIdentifier to use for this element var reuseIdentifier: String { get } + /// A closure that will be called before the elements view is appeared + var willAppear: ((UICollectionReusableView, Int, Section) -> Void)? { get } + + /// A closure that will be called after the elements view has disappeared + var didDisappear: ((UICollectionReusableView, Int, Section) -> Void)? { get } + +} + +extension CollectionElement { + public var willAppear: ((UICollectionReusableView, Int, Section) -> Void)? { nil } + public var didDisappear: ((UICollectionReusableView, Int, Section) -> Void)? { nil } } /// Defines a cell element to be used by a `CollectionSection` to provide a configuration for a cell -public final class CollectionCellElement: CollectionElement where View: UICollectionViewCell { +open class CollectionCellElement: CollectionElement { - public let dequeueMethod: DequeueMethod + public let dequeueMethod: AnyDequeueMethod public let configure: (UICollectionReusableView, Int, Section) -> Void public let reuseIdentifier: String /// The closure that will be called before the elements view appears - public let willAppear: (UICollectionReusableView, Int, Section) -> Void + public let willAppear: ((UICollectionReusableView, Int, Section) -> Void)? /// The closure that will be called after the elements view disappears - public let didDisappear: (UICollectionReusableView, Int, Section) -> Void + public let didDisappear: ((UICollectionReusableView, Int, Section) -> Void)? /// Makes a new element for representing a cell /// - Parameters: @@ -52,22 +60,46 @@ public final class CollectionCellElement: CollectionElement where View: UI /// - dequeueMethod: The method to use for registering and dequeueing a cell for this element /// - reuseIdentifier: The reuseIdentifier to use for this element /// - configure: A closure that will be called whenever the elements view needs to be configured - public init
(section: Section, + public init(section: Section, dequeueMethod: DequeueMethod, reuseIdentifier: String? = nil, configure: @escaping (View, Int, Section) -> Void) - where Section: Composed.Section { - self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier - self.dequeueMethod = dequeueMethod + where Section: Composed.Section { + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod.erasedAsAnyDequeueMethod - // swiftlint:disable force_cast + // swiftlint:disable force_cast + + self.configure = { view, index, section in + configure(view as! View, index, section as! Section) + } - self.configure = { view, index, section in - configure(view as! View, index, section as! Section) - } + willAppear = nil + didDisappear = nil + } + + /// Makes a new element for representing a cell + /// - Parameters: + /// - section: The section where this element's cell will be shown in + /// - dequeueMethod: The method to use for registering and dequeueing a cell for this element + /// - reuseIdentifier: The reuseIdentifier to use for this element + /// - configure: A closure that will be called whenever the elements view needs to be configured + public init(section: Section, + dequeueMethod: AnyDequeueMethod, + reuseIdentifier: String? = nil, + configure: @escaping (View, Int, Section) -> Void) + where Section: Composed.Section { + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod + + // swiftlint:disable force_cast + + self.configure = { view, index, section in + configure(view as! View, index, section as! Section) + } - willAppear = { _, _, _ in } - didDisappear = { _, _, _ in } + willAppear = nil + didDisappear = nil } /// Makes a new element for representing a cell @@ -78,37 +110,102 @@ public final class CollectionCellElement: CollectionElement where View: UI /// - configure: A closure that will be called whenever the elements view needs to be configured /// - willAppear: A closure that will be called before the elements view appears /// - didDisappear: A closure that will be called after the elements view disappears - public init
(section: Section, + public init(section: Section, dequeueMethod: DequeueMethod, reuseIdentifier: String? = nil, configure: @escaping (View, Int, Section) -> Void, willAppear: ((View, Int, Section) -> Void)? = nil, didDisappear: ((View, Int, Section) -> Void)? = nil) - where Section: Composed.Section { - self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier - self.dequeueMethod = dequeueMethod + where Section: Composed.Section { + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod.erasedAsAnyDequeueMethod - // swiftlint:disable force_cast + // swiftlint:disable force_cast + + self.configure = { view, index, section in + configure(view as! View, index, section as! Section) + } - self.configure = { view, index, section in - configure(view as! View, index, section as! Section) - } + self.willAppear = { view, index, section in + willAppear?(view as! View, index, section as! Section) + } + + self.didDisappear = { view, index, section in + didDisappear?(view as! View, index, section as! Section) + } + } + + /// Makes a new element for representing a cell + /// - Parameters: + /// - section: The section where this element's cell will be shown in + /// - dequeueMethod: The method to use for registering and dequeueing a cell for this element + /// - reuseIdentifier: The reuseIdentifier to use for this element + /// - configure: A closure that will be called whenever the elements view needs to be configured + /// - willAppear: A closure that will be called before the elements view appears + /// - didDisappear: A closure that will be called after the elements view disappears + public init(section: Section, + dequeueMethod: AnyDequeueMethod, + reuseIdentifier: String? = nil, + configure: @escaping (View, Int, Section) -> Void, + willAppear: ((View, Int, Section) -> Void)? = nil, + didDisappear: ((View, Int, Section) -> Void)? = nil) + where Section: Composed.Section { + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod + + // swiftlint:disable force_cast + + self.configure = { view, index, section in + configure(view as! View, index, section as! Section) + } + + self.willAppear = { view, index, section in + willAppear?(view as! View, index, section as! Section) + } + + self.didDisappear = { view, index, section in + didDisappear?(view as! View, index, section as! Section) + } + } + + /// Makes a new element for representing a cell + /// - Parameters: + /// - dequeueMethod: The method to use for registering and dequeueing a cell for this element + /// - reuseIdentifier: The reuseIdentifier to use for this element + /// - configure: A closure that will be called whenever the elements view needs to be configured + /// - willAppear: A closure that will be called before the elements view appears + /// - didDisappear: A closure that will be called after the elements view disappears + public init( + dequeueMethod: AnyDequeueMethod, + reuseIdentifier: String? = nil, + configure: @escaping (View, Int) -> Void, + willAppear: ((View, Int) -> Void)? = nil, + didDisappear: ((View, Int) -> Void)? = nil) { + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod + + // swiftlint:disable force_cast + + self.configure = { view, index, _ in + configure(view as! View, index) + } - self.willAppear = { view, index, section in - willAppear?(view as! View, index, section as! Section) - } + self.willAppear = { view, index, _ in + willAppear?(view as! View, index) + } - self.didDisappear = { view, index, section in - didDisappear?(view as! View, index, section as! Section) - } + // TODO: Fix a memory leak that occurs here? It appears to only be caused when `FlattenedCollectionCellElement` is used. + self.didDisappear = { view, index, _ in + didDisappear?(view as! View, index) + } } } /// Defines a supplementary element to be used by a `CollectionSection` to provide a configuration for a supplementary view -public final class CollectionSupplementaryElement: CollectionElement where View: UICollectionReusableView { +public final class CollectionSupplementaryElement: CollectionElement { - public let dequeueMethod: DequeueMethod + public let dequeueMethod: AnyDequeueMethod public let configure: (UICollectionReusableView, Int, Section) -> Void public let reuseIdentifier: String @@ -127,23 +224,51 @@ public final class CollectionSupplementaryElement: CollectionElement where /// - reuseIdentifier: The reuseIdentifier to use for this element /// - kind: The `elementKind` this element represents /// - configure: A closure that will be called whenever the elements view needs to be configured - public init
(section: Section, - dequeueMethod: DequeueMethod, - reuseIdentifier: String? = nil, - kind: CollectionElementKind = .automatic, - configure: @escaping (View, Int, Section) -> Void) - where Section: Composed.Section { - self.kind = kind - self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier - self.dequeueMethod = dequeueMethod - - self.configure = { view, index, section in - // swiftlint:disable force_cast - configure(view as! View, index, section as! Section) - } - - willAppear = nil - didDisappear = nil + public init( + section: Section, + dequeueMethod: DequeueMethod, + reuseIdentifier: String? = nil, + kind: CollectionElementKind = .automatic, + configure: @escaping (_ view: View, _ sectionIndex: Int, _ section: Section) -> Void + ) where Section: Composed.Section { + self.kind = kind + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod.erasedAsAnyDequeueMethod + + self.configure = { view, index, section in + // swiftlint:disable force_cast + configure(view as! View, index, section as! Section) + } + + willAppear = nil + didDisappear = nil + } + + /// Makes a new element for representing a supplementary view + /// - Parameters: + /// - section: The section where this element's view will be shown in + /// - dequeueMethod: The method to use for registering and dequeueing a view for this element + /// - reuseIdentifier: The reuseIdentifier to use for this element + /// - kind: The `elementKind` this element represents + /// - configure: A closure that will be called whenever the elements view needs to be configured + public init( + section: Section, + dequeueMethod: AnyDequeueMethod, + reuseIdentifier: String? = nil, + kind: CollectionElementKind = .automatic, + configure: @escaping (_ view: View, _ sectionIndex: Int, _ section: Section) -> Void + ) where Section: Composed.Section { + self.kind = kind + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod + + self.configure = { view, index, section in + // swiftlint:disable force_cast + configure(view as! View, index, section as! Section) + } + + willAppear = nil + didDisappear = nil } /// Makes a new element for representing a supplementary view @@ -155,31 +280,68 @@ public final class CollectionSupplementaryElement: CollectionElement where /// - configure: A closure that will be called whenever the elements view needs to be configured /// - willAppear: A closure that will be called before the elements view appears /// - didDisappear: A closure that will be called after the elements view disappears - public init
(section: Section, + public init( + section: Section, dequeueMethod: DequeueMethod, reuseIdentifier: String? = nil, kind: CollectionElementKind = .automatic, configure: @escaping (View, Int, Section) -> Void, willAppear: ((View, Int, Section) -> Void)? = nil, - didDisappear: ((View, Int, Section) -> Void)? = nil) - where Section: Composed.Section { - self.kind = kind - self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier - self.dequeueMethod = dequeueMethod + didDisappear: ((View, Int, Section) -> Void)? = nil + ) where Section: Composed.Section { + self.kind = kind + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod.erasedAsAnyDequeueMethod - // swiftlint:disable force_cast + // swiftlint:disable force_cast + + self.configure = { view, index, section in + configure(view as! View, index, section as! Section) + } - self.configure = { view, index, section in - configure(view as! View, index, section as! Section) - } + self.willAppear = { view, index, section in + willAppear?(view as! View, index, section as! Section) + } - self.willAppear = { view, index, section in - willAppear?(view as! View, index, section as! Section) - } + self.didDisappear = { view, index, section in + didDisappear?(view as! View, index, section as! Section) + } + } - self.didDisappear = { view, index, section in - didDisappear?(view as! View, index, section as! Section) - } + /// Makes a new element for representing a supplementary view + /// - Parameters: + /// - section: The section where this element's view will be shown in + /// - dequeueMethod: The method to use for registering and dequeueing a view for this element + /// - reuseIdentifier: The reuseIdentifier to use for this element + /// - kind: The `elementKind` this element represents + /// - configure: A closure that will be called whenever the elements view needs to be configured + /// - willAppear: A closure that will be called before the elements view appears + /// - didDisappear: A closure that will be called after the elements view disappears + public init( + dequeueMethod: DequeueMethod, + reuseIdentifier: String? = nil, + kind: CollectionElementKind = .automatic, + configure: @escaping (View, Int) -> Void, + willAppear: ((View, Int) -> Void)? = nil, + didDisappear: ((View, Int) -> Void)? = nil + ) { + self.kind = kind + self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier + self.dequeueMethod = dequeueMethod.erasedAsAnyDequeueMethod + + // swiftlint:disable force_cast + + self.configure = { view, index, _ in + configure(view as! View, index) + } + + self.willAppear = { view, index, _ in + willAppear?(view as! View, index) + } + + self.didDisappear = { view, index, _ in + didDisappear?(view as! View, index) + } } } diff --git a/Sources/ComposedUI/CollectionView/CollectionSection.swift b/Sources/ComposedUI/CollectionView/CollectionSection.swift index 02261ba..3e9ecbc 100644 --- a/Sources/ComposedUI/CollectionView/CollectionSection.swift +++ b/Sources/ComposedUI/CollectionView/CollectionSection.swift @@ -3,16 +3,16 @@ import Composed /// Defines a configuration for a section in a `UICollectionView`. /// The section must contain a cell element, but can also optionally include a header and/or footer element. -open class CollectionSection: CollectionElementsProvider { +open class CollectionSection: SingleUICollectionViewSectionElementsProvider { /// The cell configuration element - public let cell: CollectionCellElement + public let cell: CollectionCellElement /// The header configuration element - public let header: CollectionSupplementaryElement? + public let header: CollectionSupplementaryElement? /// The footer configuration element - public let footer: CollectionSupplementaryElement? + public let footer: CollectionSupplementaryElement? /// The number of elements in this section open var numberOfElements: Int { @@ -28,76 +28,48 @@ open class CollectionSection: CollectionElementsProvider { /// - cell: The cell configuration element /// - header: The header configuration element /// - footer: The footer configuration element - public init(section: Section, - cell: CollectionCellElement, - header: CollectionSupplementaryElement
? = nil, - footer: CollectionSupplementaryElement