diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Composed-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Composed-Package.xcscheme
new file mode 100644
index 0000000..8aca10d
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/Composed-Package.xcscheme
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposedLayouts.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedLayouts.xcscheme
new file mode 100644
index 0000000..372da89
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedLayouts.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme
new file mode 100644
index 0000000..194c762
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Package.resolved b/Package.resolved
index 5499762..0aa1493 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,22 +1,40 @@
{
"object": {
"pins": [
+ {
+ "package": "CwlCatchException",
+ "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git",
+ "state": {
+ "branch": null,
+ "revision": "f809deb30dc5c9d9b78c872e553261a61177721a",
+ "version": "2.0.0"
+ }
+ },
+ {
+ "package": "CwlPreconditionTesting",
+ "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git",
+ "state": {
+ "branch": null,
+ "revision": "02b7a39a99c4da27abe03cab2053a9034379639f",
+ "version": "2.0.0"
+ }
+ },
{
"package": "Nimble",
- "repositoryURL": "https://github.com/quick/nimble",
+ "repositoryURL": "https://github.com/Quick/Nimble.git",
"state": {
"branch": null,
- "revision": "f8657642dfdec9973efc79cc68bcef43a653a2bc",
- "version": "8.0.2"
+ "revision": "e491a6731307bb23783bf664d003be9b2fa59ab5",
+ "version": "9.0.0"
}
},
{
"package": "Quick",
- "repositoryURL": "https://github.com/quick/quick",
+ "repositoryURL": "https://github.com/Quick/Quick.git",
"state": {
"branch": null,
- "revision": "94df9b449508344667e5afc7e80f8bcbff1e4c37",
- "version": "2.1.0"
+ "revision": "0038bcbab4292f3b028632556507c124e5ba69f3",
+ "version": "3.0.0"
}
}
]
diff --git a/Package.swift b/Package.swift
index 570498b..490b54f 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,5 +1,4 @@
// swift-tools-version:5.2
-
import PackageDescription
let package = Package(
@@ -11,17 +10,32 @@ let package = Package(
.library(
name: "Composed",
targets: ["Composed"]),
+ .library(
+ name: "ComposedLayouts",
+ targets: ["ComposedLayouts"]),
+ .library(
+ name: "ComposedUI",
+ targets: ["ComposedUI"]),
],
dependencies: [
- .package(name: "Quick", url: "https://github.com/quick/quick", from: "2.0.0"),
- .package(name: "Nimble", url: "https://github.com/quick/nimble", from: "8.0.0"),
+ .package(name: "Quick", url: "https://github.com/Quick/Quick.git", from: "3.0.0"),
+ .package(name: "Nimble", url: "https://github.com/Quick/Nimble.git", from: "9.0.0"),
],
targets: [
- .target(
- name: "Composed",
- dependencies: []),
+ .target(name: "Composed"),
.testTarget(
name: "ComposedTests",
dependencies: ["Quick", "Nimble", "Composed"]),
+
+ .target(
+ name: "ComposedLayouts",
+ dependencies: ["Composed", "ComposedUI"]),
+
+ .target(
+ name: "ComposedUI",
+ dependencies: ["Composed"]),
+ .testTarget(
+ name: "ComposedUITests",
+ dependencies: ["Quick", "Nimble", "ComposedUI"]),
]
)
diff --git a/Sources/Composed/Core/Section.swift b/Sources/Composed/Core/Section.swift
index d240971..5abc3a0 100644
--- a/Sources/Composed/Core/Section.swift
+++ b/Sources/Composed/Core/Section.swift
@@ -82,4 +82,9 @@ 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 where the move should be performed
+ func sectionDidInvalidateHeader(_ section: Section)
+
}
diff --git a/Sources/Composed/Core/SectionProvider.swift b/Sources/Composed/Core/SectionProvider.swift
index 2acb16c..55a7aff 100644
--- a/Sources/Composed/Core/SectionProvider.swift
+++ b/Sources/Composed/Core/SectionProvider.swift
@@ -37,10 +37,10 @@ public protocol AggregateSectionProvider: SectionProvider {
context of the callee
- parameter provider: The provider to calculate the section offset of
- - returns: The section offset of the provided section provider, or -1 if
+ - returns: The section offset of the provided section provider, or `nil` if
the section provider is not in the hierachy
*/
- func sectionOffset(for provider: SectionProvider) -> Int
+ func sectionOffset(for provider: SectionProvider) -> Int?
}
diff --git a/Sources/Composed/Core/SectionProviderMapping.swift b/Sources/Composed/Core/SectionProviderMapping.swift
index b7db471..bba8648 100644
--- a/Sources/Composed/Core/SectionProviderMapping.swift
+++ b/Sources/Composed/Core/SectionProviderMapping.swift
@@ -82,6 +82,11 @@ 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)
+
}
/// An object that encapsulates the logic required to map `SectionProvider`s to a global context,
@@ -229,6 +234,11 @@ 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)
+ }
+
// 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() {
@@ -242,10 +252,8 @@ public final class SectionProviderMapping: SectionProviderUpdateDelegate, Sectio
func addOffsets(forChildrenOf aggregate: AggregateSectionProvider, offset: Int = 0) {
for child in aggregate.providers {
- let aggregateSectionOffset = aggregate.sectionOffset(for: child)
-
- guard aggregateSectionOffset > -1 else {
- assertionFailure("AggregateSectionProvider should return a value > -1 fo.r section offset of child \(child)")
+ guard let aggregateSectionOffset = aggregate.sectionOffset(for: child) else {
+ assertionFailure("AggregateSectionProvider should return a non-nil value for section offset of child \(child)")
continue
}
diff --git a/Sources/Composed/Providers/ComposedSectionProvider.swift b/Sources/Composed/Providers/ComposedSectionProvider.swift
index a85c437..4ea9ac9 100644
--- a/Sources/Composed/Providers/ComposedSectionProvider.swift
+++ b/Sources/Composed/Providers/ComposedSectionProvider.swift
@@ -65,8 +65,8 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd
/// 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 `-1` if it is not in the hierarchy.
- public func sectionOffset(for provider: SectionProvider) -> Int {
+ /// - 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
@@ -95,11 +95,8 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd
case .provider(let childProvider):
if childProvider === provider {
return offset
- } else if let childProvider = childProvider as? AggregateSectionProvider {
- let sectionOffset = childProvider.sectionOffset(for: provider)
- if sectionOffset != -1 {
- return offset + sectionOffset
- }
+ } else if let childProvider = childProvider as? AggregateSectionProvider, let sectionOffset = childProvider.sectionOffset(for: provider) {
+ return offset + sectionOffset
}
offset += childProvider.numberOfSections
@@ -107,10 +104,10 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd
}
// Provider is not in the hierarchy
- return -1
+ return nil
}
- public func sectionOffset(for section: Section) -> Int {
+ 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.
switch children.last {
@@ -142,7 +139,7 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd
}
// Provider is not in the hierachy
- return -1
+ return nil
}
/// Returns the first index of the `section`, or `nil` if the section is not a child of this
@@ -185,7 +182,7 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd
updateDelegate?.willBeginUpdating(self)
children.insert(.section(child), at: index)
numberOfSections += 1
- let sectionOffset = self.sectionOffset(for: child)
+ let sectionOffset = self.sectionOffset(for: child)!
sections.insert(child, at: sectionOffset)
updateDelegate?.provider(self, didInsertSections: [child], at: IndexSet(integer: sectionOffset))
updateDelegate?.didEndUpdating(self)
@@ -203,7 +200,7 @@ open class ComposedSectionProvider: AggregateSectionProvider, SectionProviderUpd
updateDelegate?.willBeginUpdating(self)
children.insert(.provider(child), at: index)
numberOfSections += child.sections.count
- let firstIndex = sectionOffset(for: child)
+ 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.. Int {
+ public func sectionOffset(for provider: SectionProvider) -> Int? {
guard provider !== self else { return 0 }
var offset: Int = 0
@@ -117,11 +117,8 @@ open class SegmentedSectionProvider: AggregateSectionProvider, SectionProviderUp
case .provider(let childProvider):
if childProvider === provider {
return offset
- } else if let childProvider = childProvider as? AggregateSectionProvider {
- let sectionOffset = childProvider.sectionOffset(for: provider)
- if sectionOffset != -1 {
- return offset + sectionOffset
- }
+ } else if let childProvider = childProvider as? AggregateSectionProvider, let sectionOffset = childProvider.sectionOffset(for: provider) {
+ return offset + sectionOffset
}
offset += childProvider.numberOfSections
@@ -129,7 +126,7 @@ open class SegmentedSectionProvider: AggregateSectionProvider, SectionProviderUp
break
}
- return -1
+ return nil
}
/// Appends the specified `SectionProvider` to the provider
diff --git a/Sources/Composed/Sections/FlatSection+SelectionHandler.swift b/Sources/Composed/Sections/FlatSection+SelectionHandler.swift
new file mode 100644
index 0000000..44502a6
--- /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 }
+ }
+
+ open 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
+ open 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
+ open 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
+ open 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
+ open 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
+ open 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
+ open 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
+ open 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
+ open 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
+ open func selectAll() {
+ selectionHandlingSections.forEach { $0.selectAll() }
+ }
+
+ /// Deselects all elements in this section
+ open 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..bbaad99
--- /dev/null
+++ b/Sources/Composed/Sections/FlatSection.swift
@@ -0,0 +1,426 @@
+import Foundation
+
+/// A section that flattens each of its children in to a single section.
+open class FlatSection: Section, CustomReflectable {
+ private enum Child {
+ /// A single section.
+ case section(Section)
+
+ /// An object that provides 0 or more sections.
+ case sectionProvider(SectionProvider)
+ }
+
+ 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,
+ ]
+ )
+ }
+
+ private var children: [Child] = []
+
+ public init() {}
+
+ public func append(_ section: Section) {
+ updateDelegate?.willBeginUpdating(self)
+
+ let indexOfFirstChildElement = numberOfElements
+ children.append(.section(section))
+ sections.append(section)
+ section.updateDelegate = self
+
+ (0.. (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
+ }
+}
+
+extension FlatSection: SectionUpdateDelegate {
+ public func willBeginUpdating(_ section: Section) {
+ updateDelegate?.willBeginUpdating(self)
+ }
+
+ public func didEndUpdating(_ section: Section) {
+ updateDelegate?.didEndUpdating(self)
+ }
+
+ public func invalidateAll(_ section: Section) {
+ updateDelegate?.invalidateAll(self)
+ }
+
+ public func section(_ section: Section, didInsertElementAt index: Int) {
+ guard let sectionOffset = indexForFirstElement(of: section) else { return }
+ updateDelegate?.section(self, didInsertElementAt: sectionOffset + index)
+ }
+
+ public func section(_ section: Section, didRemoveElementAt index: Int) {
+ guard let sectionOffset = indexForFirstElement(of: section) else { return }
+ updateDelegate?.section(self, didRemoveElementAt: sectionOffset + index)
+ }
+
+ public func section(_ section: Section, didUpdateElementAt index: Int) {
+ guard let sectionOffset = indexForFirstElement(of: section) else { return }
+ updateDelegate?.section(self, didUpdateElementAt: sectionOffset + index)
+ }
+
+ public 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)
+ }
+
+ public 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 }
+ }
+
+ public func section(_ section: Section, select index: Int) {
+ guard let sectionOffset = indexForFirstElement(of: section) else { return }
+ updateDelegate?.section(self, select: sectionOffset + index)
+ }
+
+ public func section(_ section: Section, deselect index: Int) {
+ guard let sectionOffset = indexForFirstElement(of: section) else { return }
+ updateDelegate?.section(self, deselect: sectionOffset + index)
+ }
+
+ public 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)
+ }
+
+ public func sectionDidInvalidateHeader(_ section: Section) {
+ // Headers of children are currently ignored.
+ }
+}
+
+extension FlatSection: SectionProviderUpdateDelegate {
+ public func willBeginUpdating(_ provider: SectionProvider) {
+ updateDelegate?.willBeginUpdating(self)
+ }
+
+ public func didEndUpdating(_ provider: SectionProvider) {
+ updateDelegate?.didEndUpdating(self)
+ }
+
+ public 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)
+ }
+
+ public 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
+ }
+
+ updateDelegate?.willBeginUpdating(self)
+
+ 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.. NSCollectionLayoutSection?
+
+}
diff --git a/Sources/ComposedLayouts/CollectionCoordinator+CompositionalLayout.swift b/Sources/ComposedLayouts/CollectionCoordinator+CompositionalLayout.swift
new file mode 100644
index 0000000..82c1fe9
--- /dev/null
+++ b/Sources/ComposedLayouts/CollectionCoordinator+CompositionalLayout.swift
@@ -0,0 +1,17 @@
+import UIKit
+import ComposedUI
+
+@available(iOS 13.0, *)
+public extension UICollectionViewCompositionalLayout {
+
+ /// Instantiates a new `UICollectionViewCompositionalLayout` for the specified `CollectionCoordinator`
+ /// - Parameter coordinator: The coordinator that will use this layout to provide layout data for its sections
+ convenience init(coordinator: CollectionCoordinator) {
+ self.init { [weak coordinator] index, environment in
+ guard coordinator?.sectionProvider.sections.indices.contains(index) == true,
+ let section = coordinator?.sectionProvider.sections[index] as? CompositionalLayoutHandler else { return nil }
+ return section.compositionalLayoutSection(environment: environment)
+ }
+ }
+
+}
diff --git a/Sources/ComposedLayouts/CollectionCoordinator+FlowLayout.swift b/Sources/ComposedLayouts/CollectionCoordinator+FlowLayout.swift
new file mode 100644
index 0000000..56c7a05
--- /dev/null
+++ b/Sources/ComposedLayouts/CollectionCoordinator+FlowLayout.swift
@@ -0,0 +1,61 @@
+import UIKit
+import ComposedUI
+
+extension CollectionCoordinator: UICollectionViewDelegateFlowLayout {
+
+ private func suggestedMetrics(for layout: UICollectionViewFlowLayout) -> CollectionFlowLayoutMetrics {
+ var metrics = CollectionFlowLayoutMetrics()
+ metrics.contentInsets = layout.sectionInset
+ metrics.minimumInteritemSpacing = layout.minimumInteritemSpacing
+ metrics.minimumLineSpacing = layout.minimumLineSpacing
+ 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 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)
+ }
+
+ 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))
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: 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
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: 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
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: 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
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: 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))
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: 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))
+ }
+
+}
diff --git a/Sources/ComposedLayouts/CollectionFlowLayoutHandler.swift b/Sources/ComposedLayouts/CollectionFlowLayoutHandler.swift
new file mode 100644
index 0000000..3f06580
--- /dev/null
+++ b/Sources/ComposedLayouts/CollectionFlowLayoutHandler.swift
@@ -0,0 +1,82 @@
+import UIKit
+import ComposedUI
+
+/// Encapsulates the metrics for a section
+public struct CollectionFlowLayoutMetrics {
+ /// Represents the insets applied to the section
+ public var contentInsets: UIEdgeInsets = .zero
+ /// Represents the spacing between lines in a section
+ public var minimumLineSpacing: CGFloat = 0
+ /// Represets the spacing between items in a section
+ public var minimumInteritemSpacing: CGFloat = 0
+
+ public init() { }
+}
+
+/// Represents the current environment for a `UICollectionView`
+public struct CollectionFlowLayoutEnvironment {
+ /// Returns the current size of the collectionView
+ public let contentSize: CGSize
+ /// Returns the current traits of the collectionView
+ public let traitCollection: UITraitCollection
+
+ /// 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
+ }
+}
+
+/// Conform your section to this protocol to override sizing and metric values for a `UICollectionViewFlowLayout`
+public protocol CollectionFlowLayoutHandler: UICollectionViewSection {
+
+ /// Return the size for the element at the specified index
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - suggested: A suggested value which is generally inherited from the layout itself
+ /// - metrics: The current metrics for the elements in this section
+ /// - environment: The current environment for this layout
+ func sizeForItem(at index: Int, suggested: CGSize, metrics: CollectionFlowLayoutMetrics, environment: CollectionFlowLayoutEnvironment) -> CGSize
+
+ /// Return a sizing strategy for the specified index
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - metrics: The current metrics for the elements in this section
+ /// - environment: The current environment for this layout
+ func sizingStrategy(at index: Int, metrics: CollectionFlowLayoutMetrics, environment: CollectionFlowLayoutEnvironment) -> CollectionFlowLayoutSizingStrategy?
+
+ /// Return the size for the header in this section
+ /// - Parameters:
+ /// - suggested: A suggested value which is generally inherited from the layout itself
+ /// - environment: The current environment for this layout
+ func referenceHeaderSize(suggested: CGSize, environment: CollectionFlowLayoutEnvironment) -> CGSize
+
+ /// Return the size for the footer in this section
+ /// - Parameters:
+ /// - suggested: A suggested value which is generally inherited from the layout itself
+ /// - environment: The current environment for this layout
+ func referenceFooterSize(suggested: CGSize, environment: CollectionFlowLayoutEnvironment) -> CGSize
+
+ /// Return the metrics to be applied to this section
+ /// - Parameters:
+ /// - suggested: A suggested value which is generally inherited from the layout itself
+ /// - environment: The current environment for this layout
+ func layoutMetrics(suggested: CollectionFlowLayoutMetrics, environment: CollectionFlowLayoutEnvironment) -> CollectionFlowLayoutMetrics
+
+}
+
+// Default implementations
+public extension CollectionFlowLayoutHandler {
+ func sizeForItem(at index: Int, suggested: CGSize, metrics: CollectionFlowLayoutMetrics, environment: CollectionFlowLayoutEnvironment) -> CGSize {
+ return sizingStrategy(at: index, metrics: metrics, environment: environment)?
+ .size(forElementAt: index, environment: environment)
+ ?? suggested
+ }
+ func sizingStrategy(at index: Int, metrics: CollectionFlowLayoutMetrics, environment: CollectionFlowLayoutEnvironment) -> CollectionFlowLayoutSizingStrategy? { return nil }
+ func referenceHeaderSize(suggested: CGSize, environment: CollectionFlowLayoutEnvironment) -> CGSize { return suggested }
+ func referenceFooterSize(suggested: CGSize, environment: CollectionFlowLayoutEnvironment) -> CGSize { return suggested }
+ func layoutMetrics(suggested: CollectionFlowLayoutMetrics, environment: CollectionFlowLayoutEnvironment) -> CollectionFlowLayoutMetrics { return suggested }
+}
diff --git a/Sources/ComposedLayouts/CollectionFlowLayoutSizingStrategy.swift b/Sources/ComposedLayouts/CollectionFlowLayoutSizingStrategy.swift
new file mode 100644
index 0000000..aef1e4e
--- /dev/null
+++ b/Sources/ComposedLayouts/CollectionFlowLayoutSizingStrategy.swift
@@ -0,0 +1,99 @@
+import UIKit
+import ComposedUI
+
+/// A helper class that encapsulates the sizing logic for building column/table style layouts with `UICollectionViewFlowLayout`
+open class CollectionFlowLayoutSizingStrategy {
+
+ /// The sizing mode to apply for all cells
+ public enum SizingMode {
+ /// Cells will have a fixed height
+ case fixed(height: CGFloat)
+ /// Cells should be calculated automatically using AutoLayout.
+ /// - Parameters
+ /// - isUniform: If `true` only the first cell will be sized, which will then be used for all subsequent cells.
+ /// - prototype: The prototype cell to use for sizing.
+ case automatic(isUniform: Bool, prototype: UICollectionReusableView)
+ /// Cells will be sized using an aspect ratio, using the `width` value as a reference. E.g. `height == width * ratio`
+ case aspect(ratio: CGFloat)
+ }
+
+ /// The total number of columns
+ public let columnCount: Int
+
+ /// The sizing mode to use for sizing cells
+ public let sizingMode: SizingMode
+
+ /// The metrics used for calculating column widths
+ public let metrics: CollectionFlowLayoutMetrics
+
+ /// Makes a new strategy
+ /// - Parameters:
+ /// - columnCount: The number of columns
+ /// - sizingMode: The sizing mode to use for sizing
+ /// - metrics: The metrics to use for calculating column widths
+ public init(columnCount: Int, sizingMode: SizingMode, metrics: CollectionFlowLayoutMetrics) {
+ self.columnCount = columnCount
+ self.sizingMode = sizingMode
+ self.metrics = metrics
+ }
+
+ private var cachedSizes: [Int: CGSize] = [:]
+ private func cachedSize(forElementAt index: Int) -> CGSize? {
+ switch sizingMode {
+ case .aspect:
+ return cachedSizes[index]
+ case .fixed:
+ return cachedSizes.values.first
+ case let .automatic(isUniform, _):
+ return isUniform ? cachedSizes.values.first : cachedSizes[index]
+ }
+ }
+
+ /// Returns the size required for the cell at the specified index
+ /// - Parameters:
+ /// - index: The index of the cell
+ /// - environment: The current environent
+ /// - Returns: The required size for the cell at the specified index
+ open func size(forElementAt index: Int, environment: CollectionFlowLayoutEnvironment) -> CGSize {
+ if let size = cachedSize(forElementAt: index) { return size }
+
+ var width: CGFloat {
+ let interitemSpacing = CGFloat(columnCount - 1) * metrics.minimumInteritemSpacing
+ let availableWidth = environment.contentSize.width
+ - metrics.contentInsets.left - metrics.contentInsets.right
+ - interitemSpacing
+ return (availableWidth / CGFloat(columnCount)).rounded(.down)
+ }
+
+ switch sizingMode {
+ case let .aspect(ratio):
+ let size = CGSize(width: width, height: width * ratio)
+ cachedSizes[index] = size
+ return size
+ case let .fixed(height):
+ let size = CGSize(width: width, height: height)
+ cachedSizes[index] = size
+ return size
+ case .automatic(_, let prototype):
+ let targetView: UIView?
+ let targetSize = CGSize(width: width, height: 0)
+
+ if let cell = prototype as? UICollectionViewCell {
+ targetView = cell.contentView
+ } else {
+ targetView = prototype
+ }
+
+ 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
new file mode 100644
index 0000000..9dd9225
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift
@@ -0,0 +1,876 @@
+import UIKit
+import Composed
+import os.log
+
+/// Conform to this protocol to receive `CollectionCoordinator` events
+public protocol CollectionCoordinatorDelegate: class {
+
+ /// Return a background view to be shown in the `UICollectionView` when its content is empty. Defaults to nil
+ /// - Parameters:
+ /// - coordinator: The coordinator that manages this collection view
+ /// - collectionView: The collection view that will show this background view
+ func coordinator(_ coordinator: CollectionCoordinator, backgroundViewInCollectionView collectionView: UICollectionView) -> UIView?
+
+ /// Called whenever the coordinator's content updates
+ /// - Parameter coordinator: The coordinator that manages the updates
+ func coordinatorDidUpdate(_ coordinator: CollectionCoordinator)
+}
+
+public extension CollectionCoordinatorDelegate {
+ func coordinator(_ coordinator: CollectionCoordinator, backgroundViewInCollectionView collectionView: UICollectionView) -> UIView? { return nil }
+ func coordinatorDidUpdate(_ coordinator: CollectionCoordinator) { }
+}
+
+/// The coordinator that provides the 'glue' between a section provider and a `UICollectionView`
+open class CollectionCoordinator: NSObject {
+ private struct NIBRegistration: Hashable {
+ let nibName: String
+ let bundle: Bundle
+ let reuseIdentifier: String
+ }
+
+ /// Get/set the delegate for this coordinator
+ public weak var delegate: CollectionCoordinatorDelegate? {
+ didSet { collectionView.backgroundView = delegate?.coordinator(self, backgroundViewInCollectionView: collectionView) }
+ }
+
+ /// Returns the root section provider associated with this coordinator
+ public var sectionProvider: SectionProvider {
+ return mapper.provider
+ }
+
+ internal var changesReducer = ChangesReducer()
+
+ private var mapper: SectionProviderMapping
+
+ private var isPerformingBatchedUpdates: Bool {
+ changesReducer.hasActiveUpdates
+ }
+
+ private let collectionView: UICollectionView
+
+ private weak var originalDelegate: UICollectionViewDelegate?
+ private var delegateObserver: NSKeyValueObservation?
+
+ private weak var originalDataSource: UICollectionViewDataSource?
+ private var dataSourceObserver: NSKeyValueObservation?
+
+ private weak var originalDragDelegate: UICollectionViewDragDelegate?
+ private var dragDelegateObserver: NSKeyValueObservation?
+
+ private weak var originalDropDelegate: UICollectionViewDropDelegate?
+ private var dropDelegateObserver: NSKeyValueObservation?
+
+ private var cachedElementsProviders: [UICollectionViewSectionElementsProvider] = []
+ private var cellSectionMap = [UICollectionViewCell: (CollectionCellElement, Section)]()
+ private var nibRegistrations = Set()
+
+ /// Make a new coordinator with the specified collectionView and sectionProvider
+ /// - Parameters:
+ /// - collectionView: The collectionView to associate with this coordinator
+ /// - sectionProvider: The sectionProvider to associate with this coordinator
+ public init(collectionView: UICollectionView, sectionProvider: SectionProvider) {
+ self.collectionView = collectionView
+ mapper = SectionProviderMapping(provider: sectionProvider)
+
+ super.init()
+ prepareSections()
+
+ delegateObserver = collectionView.observe(\.delegate, options: [.initial, .new]) { [weak self] collectionView, _ in
+ guard collectionView.delegate !== self else { return }
+ self?.originalDelegate = collectionView.delegate
+ collectionView.delegate = self
+ }
+
+ dataSourceObserver = collectionView.observe(\.dataSource, options: [.initial, .new]) { [weak self] collectionView, _ in
+ guard collectionView.dataSource !== self else { return }
+ self?.originalDataSource = collectionView.dataSource
+ collectionView.dataSource = self
+ }
+
+ dragDelegateObserver = collectionView.observe(\.dragDelegate, options: [.initial, .new]) { [weak self] collectionView, _ in
+ guard collectionView.dragDelegate !== self else { return }
+ self?.originalDragDelegate = collectionView.dragDelegate
+ collectionView.dragDelegate = self
+ }
+
+ dropDelegateObserver = collectionView.observe(\.dropDelegate, options: [.initial, .new]) { [weak self] collectionView, _ in
+ guard collectionView.dropDelegate !== self else { return }
+ self?.originalDropDelegate = collectionView.dropDelegate
+ collectionView.dropDelegate = self
+ }
+
+ collectionView.register(PlaceholderSupplementaryView.self,
+ forSupplementaryViewOfKind: PlaceholderSupplementaryView.kind,
+ withReuseIdentifier: PlaceholderSupplementaryView.reuseIdentifier)
+ }
+
+ /// Replaces the current sectionProvider with the specified provider
+ /// - Parameter sectionProvider: The new sectionProvider
+ open func replace(sectionProvider: SectionProvider) {
+ mapper = SectionProviderMapping(provider: sectionProvider)
+ prepareSections()
+ collectionView.reloadData()
+ }
+
+ /// Enables / disables editing on this coordinator
+ /// - Parameters:
+ /// - editing: True if editing should be enabled, false otherwise
+ /// - animated: If true, the change should be animated
+ public func setEditing(_ editing: Bool, animated: Bool) {
+ collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: animated) }
+
+ for (index, section) in sectionProvider.sections.enumerated() {
+ guard let handler = section as? EditingHandler else { continue }
+ handler.didSetEditing(editing)
+
+ for item in 0.. [Int] {
+ assert(Thread.isMainThread)
+ let indexPaths = collectionView.indexPathsForSelectedItems ?? []
+ return indexPaths.filter { $0.section == section }.map { $0.item }
+ }
+
+ public func mapping(_ mapping: SectionProviderMapping, select indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
+ }
+
+ public func mapping(_ mapping: SectionProviderMapping, deselect indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ collectionView.deselectItem(at: indexPath, animated: true)
+ }
+
+ public func mapping(_ mapping: SectionProviderMapping, move sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
+ // TODO: Check `isPerformingBatchedUpdates`
+ self.mapping(mapping, didMoveElementsAt: [(sourceIndexPath, destinationIndexPath)])
+ }
+
+ public func mappingDidInvalidateHeader(at sectionIndex: Int) {
+ let elementsProvider = self.elementsProvider(for: sectionIndex)
+
+ if let header = elementsProvider.header {
+ switch header.dequeueMethod.method {
+ case let .fromNib(type):
+ let nib = UINib(nibName: String(describing: type), bundle: Bundle(for: type))
+ collectionView.register(nib, forSupplementaryViewOfKind: header.kind.rawValue, withReuseIdentifier: header.reuseIdentifier)
+ case let .fromClass(type):
+ collectionView.register(type, forSupplementaryViewOfKind: header.kind.rawValue, withReuseIdentifier: header.reuseIdentifier)
+ case .fromStoryboard:
+ break
+ }
+ }
+
+ let context = UICollectionViewFlowLayoutInvalidationContext()
+ context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader, at: [IndexPath(item: 0, section: sectionIndex)])
+ invalidateLayout(with: context)
+
+ guard let headerView = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: sectionIndex)) else { return }
+
+ let section = mapper.provider.sections[sectionIndex]
+
+ if let header = elementsProvider.header, header.kind.rawValue == UICollectionView.elementKindSectionHeader {
+ header.configure(headerView, sectionIndex, section)
+ }
+ }
+
+}
+
+// MARK: - UICollectionViewDataSource
+
+extension CollectionCoordinator: UICollectionViewDataSource {
+
+ public func numberOfSections(in collectionView: UICollectionView) -> Int {
+ return mapper.numberOfSections
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+ return elementsProvider(for: section).numberOfElements
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.collectionView?(collectionView, willDisplay: cell, forItemAt: indexPath)
+ }
+
+ let elements = elementsProvider(for: indexPath.section)
+ let section = mapper.provider.sections[indexPath.section]
+ elements.cell(for: indexPath.item).willAppear?(cell, indexPath.item, section)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.collectionView?(collectionView, didEndDisplaying: cell, forItemAt: indexPath)
+ }
+ 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 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 {
+ handler.didSetEditing(collectionView.isEditing, at: indexPath.item, cell: cell, animated: false)
+ } else {
+ handler.didSetEditing(collectionView.isEditing, at: indexPath.item)
+ }
+ }
+
+ let section = mapper.provider.sections[indexPath.section]
+ cellSectionMap[cell] = (cellElement, section)
+ cellElement.configure(cell, indexPath.item, section)
+ return cell
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.collectionView?(collectionView, willDisplaySupplementaryView: view, forElementKind: elementKind, at: indexPath)
+ }
+
+ 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 {
+ header.willAppear?(view, indexPath.section, section)
+ header.configure(view, indexPath.section, section)
+ } else if let footer = elements.footer, footer.kind.rawValue == elementKind {
+ footer.willAppear?(view, indexPath.section, section)
+ footer.configure(view, indexPath.section, section)
+ } else {
+ // the original delegate can handle this
+ }
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
+ assert(Thread.isMainThread)
+ let elements = elementsProvider(for: indexPath.section)
+ let section = mapper.provider.sections[indexPath.section]
+
+ if let header = elements.header, header.kind.rawValue == kind {
+ let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: header.reuseIdentifier, for: indexPath)
+ header.configure(view, indexPath.section, section)
+ return view
+ } else if let footer = elements.footer, footer.kind.rawValue == kind {
+ let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: footer.reuseIdentifier, for: indexPath)
+ footer.configure(view, indexPath.section, section)
+ return view
+ } 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?")
+ return collectionView.dequeue(supplementary: PlaceholderSupplementaryView.self, ofKind: PlaceholderSupplementaryView.kind, for: indexPath)
+ }
+
+ return view
+ }
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.collectionView?(collectionView, didEndDisplayingSupplementaryView: view, forElementOfKind: elementKind, at: indexPath)
+ }
+
+ 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?.didDisappear?(view, indexPath.section, section)
+ } else if let footer = elements.footer, footer.kind.rawValue == elementKind {
+ elements.footer?.didDisappear?(view, indexPath.section, section)
+ } else {
+ // the original delegate can handle this
+ }
+ }
+
+ private func elementsProvider(for section: Int) -> UICollectionViewSectionElementsProvider {
+ guard cachedElementsProviders.indices.contains(section) else {
+ fatalError("No UI configuration available for section \(section)")
+ }
+ return cachedElementsProviders[section]
+ }
+
+}
+
+@available(iOS 13.0, *)
+extension CollectionCoordinator {
+
+ // MARK: - Context Menus
+
+ 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 }
+ 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)
+ }
+ }
+
+ 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 }
+ 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 }
+ 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 }
+ provider.contextMenu(willPerformPreviewActionForElementAt: indexPath.item, cell: cell, animator: animator)
+ }
+
+}
+
+extension CollectionCoordinator: UICollectionViewDelegate {
+
+ // MARK: - Selection
+
+ open func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else {
+ return originalDelegate?.collectionView?(collectionView, shouldHighlightItemAt: indexPath) ?? true
+ }
+
+ return handler.shouldHighlight(at: indexPath.item)
+ }
+
+ open func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else {
+ return originalDelegate?.collectionView?(collectionView, shouldSelectItemAt: indexPath) ?? false
+ }
+
+ return handler.shouldSelect(at: indexPath.item)
+ }
+
+ open func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+ defer {
+ originalDelegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
+ }
+
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { return }
+ if let handler = handler as? CollectionSelectionHandler, let cell = collectionView.cellForItem(at: indexPath) {
+ handler.didSelect(at: indexPath.item, cell: cell)
+ } else {
+ handler.didSelect(at: indexPath.item)
+ }
+
+ guard collectionView.allowsMultipleSelection, !handler.allowsMultipleSelection else { return }
+
+ let indexPaths = mapping(mapper, selectedIndexesIn: indexPath.section)
+ .map { IndexPath(item: $0, section: indexPath.section ) }
+ .filter { $0 != indexPath }
+ indexPaths.forEach { collectionView.deselectItem(at: $0, animated: true) }
+ }
+
+ open func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ originalDelegate?.scrollViewDidScroll?(scrollView)
+ }
+
+ open func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool {
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else {
+ return originalDelegate?.collectionView?(collectionView, shouldDeselectItemAt: indexPath) ?? true
+ }
+
+ return handler.shouldDeselect(at: indexPath.item)
+ }
+
+ open func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
+ defer {
+ originalDelegate?.collectionView?(collectionView, didDeselectItemAt: indexPath)
+ }
+
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { return }
+ if let collectionHandler = handler as? CollectionSelectionHandler, let cell = collectionView.cellForItem(at: indexPath) {
+ collectionHandler.didDeselect(at: indexPath.item, cell: cell)
+ } else {
+ handler.didDeselect(at: indexPath.item)
+ }
+ }
+
+ // MARK: - Forwarding
+
+ open override func responds(to aSelector: Selector!) -> Bool {
+ if super.responds(to: aSelector) { return true }
+ if originalDelegate?.responds(to: aSelector) ?? false { return true }
+ return false
+ }
+
+ open override func forwardingTarget(for aSelector: Selector!) -> Any? {
+ if super.responds(to: aSelector) { return self }
+ return originalDelegate
+ }
+
+}
+
+// MARK: - UICollectionViewDragDelegate
+
+extension CollectionCoordinator: UICollectionViewDragDelegate {
+
+ public func collectionView(_ collectionView: UICollectionView, dragSessionWillBegin session: UIDragSession) {
+ sectionProvider.sections
+ .compactMap { $0 as? CollectionDragHandler }
+ .forEach { $0.dragSessionWillBegin(session) }
+
+ originalDragDelegate?.collectionView?(collectionView, dragSessionWillBegin: session)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) {
+ sectionProvider.sections
+ .compactMap { $0 as? CollectionDragHandler }
+ .forEach { $0.dragSessionDidEnd(session) }
+
+ originalDragDelegate?.collectionView?(collectionView, dragSessionDidEnd: session)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool {
+ return originalDragDelegate?.collectionView?(collectionView, dragSessionIsRestrictedToDraggingApplication: session) ?? false
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
+ guard let provider = sectionProvider.sections[indexPath.section] as? CollectionDragHandler else {
+ return originalDragDelegate?.collectionView(collectionView, itemsForBeginning: session, at: indexPath) ?? []
+ }
+
+ session.localContext = indexPath.section
+ return provider.dragSession(session, dragItemsForBeginning: indexPath.item)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
+ guard let provider = sectionProvider.sections[indexPath.section] as? CollectionDragHandler else {
+ return originalDragDelegate?.collectionView(collectionView, itemsForBeginning: session, at: indexPath) ?? []
+ }
+
+ return provider.dragSession(session, dragItemsForAdding: indexPath.item)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dragSessionAllowsMoveOperation session: UIDragSession) -> Bool {
+ let sections = sectionProvider.sections.compactMap { $0 as? MoveHandler }
+ return originalDragDelegate?.collectionView?(collectionView, dragSessionAllowsMoveOperation: session) ?? !sections.isEmpty
+ }
+
+}
+
+// MARK: - UICollectionViewDropDelegate
+
+extension CollectionCoordinator: UICollectionViewDropDelegate {
+
+ public func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool {
+ if collectionView.hasActiveDrag { return true }
+ return originalDropDelegate?.collectionView?(collectionView, canHandle: session) ?? false
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dropSessionDidEnter session: UIDropSession) {
+ if !collectionView.hasActiveDrag {
+ sectionProvider.sections
+ .compactMap { $0 as? CollectionDropHandler }
+ .forEach { $0.dropSessionWillBegin(session) }
+ }
+
+ originalDropDelegate?.collectionView?(collectionView, dropSessionDidEnter: session)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dropSessionDidExit session: UIDropSession) {
+ originalDropDelegate?.collectionView?(collectionView, dropSessionDidExit: session)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dropSessionDidEnd session: UIDropSession) {
+ sectionProvider.sections
+ .compactMap { $0 as? CollectionDropHandler }
+ .forEach { $0.dropSessionDidEnd(session) }
+
+ originalDropDelegate?.collectionView?(collectionView, dropSessionDidEnd: session)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dragPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters? {
+ // this seems to happen sometimes when iOS gets interrupted
+ 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)
+ }
+
+ return section.dragSession(previewParametersForElementAt: indexPath.item, cell: cell)
+ }
+
+ 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 {
+ return originalDropDelegate?
+ .collectionView?(collectionView, dropPreviewParametersForItemAt: indexPath)
+ }
+
+ return section.dropSesion(previewParametersForElementAt: indexPath.item, cell: cell)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
+ if let section = session.localDragSession?.localContext as? Int, section != destinationIndexPath?.section {
+ return UICollectionViewDropProposal(operation: .forbidden)
+ }
+
+ if collectionView.hasActiveDrag || session.localDragSession != nil {
+ return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
+ }
+
+ if destinationIndexPath == nil {
+ return originalDropDelegate?.collectionView?(collectionView, dropSessionDidUpdate: session, withDestinationIndexPath: destinationIndexPath) ?? UICollectionViewDropProposal(operation: .forbidden)
+ }
+
+ guard let indexPath = destinationIndexPath, let section = sectionProvider.sections[indexPath.section] as? CollectionDropHandler else {
+ return originalDropDelegate?
+ .collectionView?(collectionView, dropSessionDidUpdate: session, withDestinationIndexPath: destinationIndexPath)
+ ?? UICollectionViewDropProposal(operation: .forbidden)
+ }
+
+ return section.dropSessionDidUpdate(session, destinationIndex: indexPath.item)
+ }
+
+ public func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
+ defer {
+ originalDropDelegate?.collectionView(collectionView, performDropWith: coordinator)
+ }
+
+ 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 item = coordinator.items.lazy
+ .filter { $0.sourceIndexPath != nil }
+ .filter { $0.sourceIndexPath?.section == destinationIndexPath.section }
+ .compactMap { ($0, $0.sourceIndexPath!) }
+ .first!
+
+ collectionView.performBatchUpdates({
+ let indexes = IndexSet(integer: item.1.item)
+ section.didMove(sourceIndexes: indexes, to: destinationIndexPath.item)
+
+ collectionView.deleteItems(at: [item.1])
+ collectionView.insertItems(at: [destinationIndexPath])
+ }, completion: nil)
+
+ coordinator.drop(item.0.dragItem, toItemAt: destinationIndexPath)
+ }
+
+}
+
+private final class PlaceholderSupplementaryView: UICollectionReusableView {
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true
+ heightAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+public extension CollectionCoordinator {
+
+ /// A convenience initializer that allows creation without a provider
+ /// - Parameters:
+ /// - collectionView: The collectionView associated with this coordinator
+ /// - sections: The sections associated with this coordinator
+ convenience init(collectionView: UICollectionView, sections: Section...) {
+ let provider = ComposedSectionProvider()
+ sections.forEach(provider.append(_:))
+ self.init(collectionView: collectionView, sectionProvider: provider)
+ }
+
+}
diff --git a/Sources/ComposedUI/CollectionView/CollectionElement.swift b/Sources/ComposedUI/CollectionView/CollectionElement.swift
new file mode 100644
index 0000000..fbcd116
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/CollectionElement.swift
@@ -0,0 +1,346 @@
+import UIKit
+import Composed
+
+/// A `UICollectionView` supports different elementKind's for supplementary view, this provides a solution
+/// A collection view can provide headers and footers via custom elementKind's or it using built-in definitions, this provides a solution for specifying which option to use
+public enum CollectionElementKind {
+ /// Either `elementKindSectionHeader` or `elementKindSectionFooter` will be used
+ case automatic
+ /// The custom `kind` value will be used
+ case custom(kind: String)
+
+ internal var rawValue: String {
+ switch self {
+ case .automatic: return "automatic"
+ case let .custom(kind): return kind
+ }
+ }
+}
+
+/// Defines an element used by a `CollectionSection` to provide configurations for a cell, header and/or footer.
+public protocol CollectionElement {
+
+ /// The method to use for registering and dequeueing a view for this element
+ 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 }
+
+ /// 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
+open class CollectionCellElement: CollectionElement {
+
+ 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)?
+ /// The closure that will be called after the elements view disappears
+ public let didDisappear: ((UICollectionReusableView, Int, Section) -> Void)?
+
+ /// 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: DequeueMethod,
+ reuseIdentifier: String? = nil,
+ configure: @escaping (View, Int, Section) -> Void)
+ where Section: Composed.Section {
+ self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier
+ self.dequeueMethod = dequeueMethod.erasedAsAnyDequeueMethod
+
+ // swiftlint:disable force_cast
+
+ 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 = 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
+ /// - 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: 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.erasedAsAnyDequeueMethod
+
+ // 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:
+ /// - 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, _ in
+ willAppear?(view as! View, index)
+ }
+
+ 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 {
+
+ public let dequeueMethod: AnyDequeueMethod
+ public let configure: (UICollectionReusableView, Int, Section) -> Void
+ public let reuseIdentifier: String
+
+ /// The `elementKind` this element represents
+ public let kind: CollectionElementKind
+
+ /// A closure that will be called before the elements view is appeared
+ public let willAppear: ((UICollectionReusableView, Int, Section) -> Void)?
+ /// A closure that will be called after the elements view has disappeared
+ public let didDisappear: ((UICollectionReusableView, Int, Section) -> Void)?
+
+ /// 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: 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
+ /// - 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(
+ 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.erasedAsAnyDequeueMethod
+
+ // 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 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
new file mode 100644
index 0000000..9718f5c
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/CollectionSection.swift
@@ -0,0 +1,83 @@
+import UIKit
+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: SingleUICollectionViewSectionElementsProvider {
+
+ /// The cell configuration element
+ public let cell: CollectionCellElement
+
+ /// The header configuration element
+ public let header: CollectionSupplementaryElement?
+
+ /// The footer configuration element
+ public let footer: CollectionSupplementaryElement?
+
+ /// The number of elements in this section
+ open var numberOfElements: Int {
+ return section?.numberOfElements ?? 0
+ }
+
+ // The underlying section associated with this section
+ private weak var section: Section?
+
+ /// Makes a new configuration with the specified cell, header and/or footer elements
+ /// - Parameters:
+ /// - section: The section this will be associated with
+ /// - 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? = nil
+ ) where Section: Composed.Section {
+ self.section = section
+
+ // The code below copies the relevent elements to erase type-safety
+
+ self.cell = CollectionCellElement(section: section,
+ dequeueMethod: cell.dequeueMethod,
+ reuseIdentifier: cell.reuseIdentifier,
+ configure: cell.configure,
+ willAppear: cell.willAppear,
+ didDisappear: cell.didDisappear)
+
+ if let header = header {
+ let kind: CollectionElementKind
+ if case .automatic = header.kind {
+ kind = .custom(kind: UICollectionView.elementKindSectionHeader)
+ } else {
+ kind = header.kind
+ }
+
+ self.header = CollectionSupplementaryElement(section: section,
+ dequeueMethod: header.dequeueMethod,
+ reuseIdentifier: header.reuseIdentifier,
+ kind: kind,
+ configure: header.configure)
+ } else {
+ self.header = nil
+ }
+
+ if let footer = footer {
+ let kind: CollectionElementKind
+ if case .automatic = footer.kind {
+ kind = .custom(kind: UICollectionView.elementKindSectionFooter)
+ } else {
+ kind = footer.kind
+ }
+
+ self.footer = CollectionSupplementaryElement(section: section,
+ dequeueMethod: footer.dequeueMethod,
+ reuseIdentifier: footer.reuseIdentifier,
+ kind: kind,
+ configure: footer.configure)
+ } else {
+ self.footer = nil
+ }
+ }
+
+}
diff --git a/Sources/ComposedUI/CollectionView/FlatUICollectionViewSection.swift b/Sources/ComposedUI/CollectionView/FlatUICollectionViewSection.swift
new file mode 100644
index 0000000..d1c783b
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/FlatUICollectionViewSection.swift
@@ -0,0 +1,54 @@
+import Composed
+import UIKit
+
+/// A `Composed.FlatSection` conforming to `UICollectionViewSection`
+open class FlatUICollectionViewSection: FlatSection, UICollectionViewSection {
+ public var header: CollectionSupplementaryElement? {
+ didSet {
+ updateDelegate?.sectionDidInvalidateHeader(self)
+ }
+ }
+
+ public var footer: CollectionSupplementaryElement? {
+ didSet {
+ // TODO: Notify delegate
+ }
+ }
+
+ public init(header: CollectionSupplementaryElement? = nil, footer: CollectionSupplementaryElement? = nil) {
+ self.header = header
+ self.footer = footer
+
+ super.init()
+ }
+
+ open func collectionViewElementsProvider(with traitCollection: UITraitCollection) -> UICollectionViewSectionElementsProvider {
+ FlatUICollectionViewSectionElementsProvider(section: self, traitCollection: traitCollection)
+ }
+}
+
+extension FlatUICollectionViewSection: CollectionSelectionHandler {
+ open func didSelect(at index: Int, cell: UICollectionViewCell) {
+ guard let sectionMeta = self.sectionForElementIndex(index) else { return }
+
+ let sectionIndex = index - sectionMeta.offset
+
+ if let section = sectionMeta.section as? CollectionSelectionHandler {
+ section.didSelect(at: sectionIndex, cell: cell)
+ } else if let section = sectionMeta.section as? SelectionHandler {
+ section.didSelect(at: sectionIndex)
+ }
+ }
+
+ open func didDeselect(at index: Int, cell: UICollectionViewCell) {
+ guard let sectionMeta = self.sectionForElementIndex(index) else { return }
+
+ let sectionIndex = index - sectionMeta.offset
+
+ if let section = sectionMeta.section as? CollectionSelectionHandler {
+ section.didDeselect(at: sectionIndex, cell: cell)
+ } else if let section = sectionMeta.section as? SelectionHandler {
+ section.didDeselect(at: sectionIndex)
+ }
+ }
+}
diff --git a/Sources/ComposedUI/CollectionView/FlatUICollectionViewSectionElementsProvider.swift b/Sources/ComposedUI/CollectionView/FlatUICollectionViewSectionElementsProvider.swift
new file mode 100644
index 0000000..02f25d6
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/FlatUICollectionViewSectionElementsProvider.swift
@@ -0,0 +1,110 @@
+import UIKit
+import Composed
+
+open class FlatUICollectionViewSectionElementsProvider: UICollectionViewSectionElementsProvider {
+ /// The header configuration element
+ public var header: CollectionSupplementaryElement? {
+ guard let flatSection = flatSection else { return nil }
+ guard let header = flatSection.header else { return nil }
+ let kind: CollectionElementKind
+ if case .automatic = header.kind {
+ kind = .custom(kind: UICollectionView.elementKindSectionHeader)
+ } else {
+ kind = header.kind
+ }
+
+ return CollectionSupplementaryElement(
+ section: flatSection,
+ dequeueMethod: header.dequeueMethod,
+ reuseIdentifier: header.reuseIdentifier,
+ kind: kind,
+ configure: header.configure
+ )
+ }
+
+ /// The footer configuration element
+ public var footer: CollectionSupplementaryElement? {
+ guard let flatSection = flatSection else { return nil }
+ guard let footer = flatSection.footer else { return nil }
+ let kind: CollectionElementKind
+ if case .automatic = footer.kind {
+ kind = .custom(kind: UICollectionView.elementKindSectionFooter)
+ } else {
+ kind = footer.kind
+ }
+
+ return CollectionSupplementaryElement(
+ section: flatSection,
+ dequeueMethod: footer.dequeueMethod,
+ reuseIdentifier: footer.reuseIdentifier,
+ kind: kind,
+ configure: footer.configure
+ )
+ }
+
+ /// The number of elements in this section
+ open var numberOfElements: Int {
+ return flatSection?.numberOfElements ?? 0
+ }
+
+ // The underlying section associated with this section
+ private weak var flatSection: FlatUICollectionViewSection?
+
+ private let traitCollection: UITraitCollection
+
+ /// Makes a new configuration with the specified cell, header and/or footer elements
+ /// - Parameters:
+ /// - section: The section this will be associated with
+ /// - cell: The cell configuration element
+ /// - header: The header configuration element
+ /// - footer: The footer configuration element
+ public init(
+ section: FlatUICollectionViewSection,
+ traitCollection: UITraitCollection
+ ) {
+ self.flatSection = section
+ self.traitCollection = traitCollection
+ }
+
+ open func cell(for index: Int) -> CollectionCellElement {
+ guard let (section, offset) = flatSection?.sectionForElementIndex(index) else {
+ fatalError("No section for index \(index) exists")
+ }
+
+ guard let collectionSectionProvider = section as? UICollectionViewSection else {
+ fatalError("Child must conform to `CollectionSectionProvider`")
+ }
+
+ let indexInSection = index - offset
+ let collectionSection = collectionSectionProvider.collectionViewElementsProvider(with: traitCollection)
+ let collectionCellElement = collectionSection.cell(for: indexInSection)
+ return FlattenedCollectionCellElement(collectionElement: collectionCellElement, section: section, sectionOffset: offset)
+ }
+}
+
+private final class FlattenedCollectionCellElement: CollectionCellElement {
+ fileprivate init(collectionElement element: CollectionCellElement, section: Section, sectionOffset: Int) {
+ super.init(
+ dequeueMethod: element.dequeueMethod,
+ reuseIdentifier: element.reuseIdentifier,
+ configure: { [weak section] cell, index in
+ guard let section = section else { return }
+
+ let sectionIndex = index - sectionOffset
+ element.configure(cell, sectionIndex, section)
+ },
+ willAppear: { [weak section] cell, index in
+ guard let section = section else { return }
+
+ let sectionIndex = index - sectionOffset
+ element.willAppear?(cell, sectionIndex, section)
+ },
+ didDisappear: { [weak section] cell, index in
+ guard let section = section else { return }
+
+ let sectionIndex = index - sectionOffset
+ element.didDisappear?(cell, sectionIndex, section)
+ }
+ )
+ }
+}
diff --git a/Sources/ComposedUI/CollectionView/Handlers/CollectionContextMenuHandler.swift b/Sources/ComposedUI/CollectionView/Handlers/CollectionContextMenuHandler.swift
new file mode 100644
index 0000000..2f46b5c
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/Handlers/CollectionContextMenuHandler.swift
@@ -0,0 +1,54 @@
+import UIKit
+import Composed
+
+/// Provides context menu handling for `UICollectionView`'s
+@available(iOS 13.0, *)
+public protocol CollectionContextMenuHandler: UICollectionViewSection {
+
+ /// Specifies whether or not a specific element allows showing a context menu. If you want UIKit to indicate that a menu is usually provided for this element, true `true` and simply return `nil` for `contextMenu(forElementAt:cell:suggestedActions:)`
+ /// - Parameter index: The index of the element
+ func allowsContextMenu(forElementAt index: Int) -> Bool
+
+ /// Return a `UIMenu` representing the actions that should be shown for the specified cell.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu will be shown for
+ /// - suggestedActions: The suggested actions for this menu
+ func contextMenu(forElementAt index: Int, cell: UICollectionViewCell, suggestedActions: [UIMenuElement]) -> UIMenu?
+
+ /// Return a closure that takes no arguments and returns a `UIViewController` that will be used for the preview.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: Th cell the context menu will be shown for
+ func contextMenu(previewForElementAt index: Int, cell: UICollectionViewCell) -> UIContextMenuContentPreviewProvider?
+
+ /// Called when the interaction begins. Return a `UITargetedPreview` describing the desired highlight preview.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu will be shown for
+ func contextMenu(previewForHighlightingElementAt index: Int, cell: UICollectionViewCell) -> UITargetedPreview?
+
+ /// Called when the interaction is about to dismiss. Return a `UITargetedPreview` describing the desired dismissal target. The interaction will animate the presented menu to the target. Use this to customize the dismissal animation.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu was shown for
+ func contextMenu(previewForDismissingElementAt index: Int, cell: UICollectionViewCell) -> UITargetedPreview?
+
+ /// Called when the interaction is about to "commit" in response to the user tapping the preview.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu is being shown for
+ /// - animator: Commit animator. Add animations to this object to run them alongside the commit transition.
+ func contextMenu(willPerformPreviewActionForElementAt index: Int, cell: UICollectionViewCell, animator: UIContextMenuInteractionCommitAnimating)
+
+}
+
+@available(iOS 13.0, *)
+public extension CollectionContextMenuHandler {
+ func allowsContextMenu(forElementAt index: Int) -> Bool { return true }
+ func contextMenu(forElementAt index: Int, cell: UICollectionViewCell, suggestedActions: [UIMenuElement]) -> UIMenu? { return nil }
+ func contextMenu(previewForElementAt index: Int, cell: UICollectionViewCell) -> UIContextMenuContentPreviewProvider? { return nil }
+ func contextMenu(previewForHighlightingElementAt index: Int, cell: UICollectionViewCell) -> UITargetedPreview? { return nil }
+ func contextMenu(previewForDismissingElementAt index: Int, cell: UICollectionViewCell) -> UITargetedPreview? { return nil }
+ func contextMenu(willPerformPreviewActionForElementAt index: Int, cell: UICollectionViewCell, animator: UIContextMenuInteractionCommitAnimating) { }
+}
diff --git a/Sources/ComposedUI/CollectionView/Handlers/CollectionDragHandler.swift b/Sources/ComposedUI/CollectionView/Handlers/CollectionDragHandler.swift
new file mode 100644
index 0000000..56030ee
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/Handlers/CollectionDragHandler.swift
@@ -0,0 +1,30 @@
+import UIKit
+import CoreData
+import Composed
+
+public protocol CollectionDragHandler: UICollectionViewSection {
+
+ func dragSessionWillBegin(_ session: UIDragSession)
+ func dragSessionDidEnd(_ session: UIDragSession)
+
+ func dragSession(_ session: UIDragSession, dragItemsForBeginning index: Int) -> [UIDragItem]
+ func dragSession(_ session: UIDragSession, dragItemsForAdding index: Int) -> [UIDragItem]
+
+ /// Return custom information about how to display the item at the specified location during the drag.
+ /// - Parameter
+ /// - index: The index of the element being dragged
+ /// - cell: The cell associated with this drag
+ func dragSession(previewParametersForElementAt index: Int, cell: UICollectionViewCell) -> UIDragPreviewParameters?
+
+}
+
+public extension CollectionDragHandler {
+
+ func dragSessionWillBegin(_ session: UIDragSession) { }
+ func dragSessionDidEnd(_ session: UIDragSession) { }
+ func dragSession(_ session: UIDragSession, dragItemsForAdding index: Int) -> [UIDragItem] {
+ return dragSession(session, dragItemsForBeginning: index)
+ }
+ func dragSession(previewParametersForElementAt index: Int, cell: UICollectionViewCell) -> UIDragPreviewParameters? { return nil }
+
+}
diff --git a/Sources/ComposedUI/CollectionView/Handlers/CollectionDropHandler.swift b/Sources/ComposedUI/CollectionView/Handlers/CollectionDropHandler.swift
new file mode 100644
index 0000000..2ce4c47
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/Handlers/CollectionDropHandler.swift
@@ -0,0 +1,32 @@
+import UIKit
+import CoreData
+import Composed
+
+/// Provides drag and drop handling for `UICollectionView`'s
+public protocol CollectionDropHandler: UICollectionViewSection {
+
+ func dropSessionWillBegin(_ session: UIDropSession)
+ func dropSessionDidEnd(_ session: UIDropSession)
+
+ /// Called when the position of the dragged data over the view has changed. While the user is dragging content, the view calls this method repeatedly to determine how you would handle the drop if it occurred at the specified location. The view provides visual feedback to the user based on your proposal.
+ /// - Parameters:
+ /// - session: The drop session object containing information about the type of data being dragged.
+ /// - destinationIndex: The index at which the content would be dropped.
+ func dropSessionDidUpdate(_ session: UIDropSession, destinationIndex: Int?) -> UICollectionViewDropProposal
+
+ /// Return custom information about how to display the item at the specified location during the drop.
+ /// - Parameter
+ /// - index: The index where the element should be inserted
+ /// - cell: The cell associated with this drop
+ func dropSesion(previewParametersForElementAt index: Int, cell: UICollectionViewCell) -> UIDragPreviewParameters?
+
+}
+
+public extension CollectionDropHandler {
+ func dropSessionWillBegin(_ session: UIDropSession) { }
+ func dropSessionDidEnd(_ session: UIDropSession) { }
+ func dropSessionDidUpdate(_ session: UIDropSession, destinationIndex: Int?) -> UICollectionViewDropProposal {
+ return UICollectionViewDropProposal(operation: .copy)
+ }
+ func dropSesion(previewParametersForElementAt index: Int, cell: UICollectionViewCell) -> UIDragPreviewParameters? { return nil }
+}
diff --git a/Sources/ComposedUI/CollectionView/Handlers/CollectionEditingHandler.swift b/Sources/ComposedUI/CollectionView/Handlers/CollectionEditingHandler.swift
new file mode 100644
index 0000000..f5378f1
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/Handlers/CollectionEditingHandler.swift
@@ -0,0 +1,22 @@
+import UIKit
+import Composed
+
+/// Provides edit handling for `UICollectionView`'s
+public protocol CollectionEditingHandler: EditingHandler, UICollectionViewSection {
+
+ /// When editing is toggled, this method will be called to notify the section
+ /// - Parameters:
+ /// - editing: True if editing is being enabled, false otherwise
+ /// - index: The element index
+ /// - cell: The cell at the specified index
+ /// - animated: Specifies whether the change is being animated
+ func didSetEditing(_ editing: Bool, at index: Int, cell: UICollectionViewCell, animated: Bool)
+
+}
+
+public extension CollectionEditingHandler {
+ func didSetEditing(_ editing: Bool) { }
+ func didSetEditing(_ editing: Bool, at index: Int, cell: UICollectionViewCell, animated: Bool) {
+ didSetEditing(editing, at: index)
+ }
+}
diff --git a/Sources/ComposedUI/CollectionView/Handlers/CollectionSelectionHandler.swift b/Sources/ComposedUI/CollectionView/Handlers/CollectionSelectionHandler.swift
new file mode 100644
index 0000000..863ffae
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/Handlers/CollectionSelectionHandler.swift
@@ -0,0 +1,29 @@
+import UIKit
+import Composed
+
+/// Provides selection handling for `UICollectionView`'s
+public protocol CollectionSelectionHandler: SelectionHandler, UICollectionViewSection {
+
+ /// When a selection occurs, this method will be called to notify the section
+ /// - Parameters:
+ /// - index: The element index
+ /// - cell: The cell that was selected
+ func didSelect(at index: Int, cell: UICollectionViewCell)
+
+ /// When a deselection occurs, this method will be called to notify the section
+ /// - Parameters:
+ /// - index: The element index
+ /// - cell: The cell that was deselected
+ func didDeselect(at index: Int, cell: UICollectionViewCell)
+
+}
+
+public extension CollectionSelectionHandler {
+ func didSelect(at index: Int, cell: UICollectionViewCell) {
+ didSelect(at: index)
+ }
+
+ func didDeselect(at index: Int, cell: UICollectionViewCell) {
+ didSelect(at: index)
+ }
+}
diff --git a/Sources/ComposedUI/CollectionView/Handlers/CollectionUpdateHandler.swift b/Sources/ComposedUI/CollectionView/Handlers/CollectionUpdateHandler.swift
new file mode 100644
index 0000000..a425d78
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/Handlers/CollectionUpdateHandler.swift
@@ -0,0 +1,10 @@
+import UIKit
+
+/// Conform your section to this to signal to the Coordinator that you want to handle updates manually for the cells in this section. Your cell configuration closure will be called instead of `reloadItems`
+public protocol CollectionUpdateHandler: UICollectionViewSection {
+
+ /// Return true to signal to the view that it should reload the cell vs re-calling the cell configuration handler on `CollectionElement`
+ /// - Parameter index: The element index
+ func prefersReload(forElementAt index: Int) -> Bool
+
+}
diff --git a/Sources/ComposedUI/CollectionView/UICollectionViewSection.swift b/Sources/ComposedUI/CollectionView/UICollectionViewSection.swift
new file mode 100644
index 0000000..3426f36
--- /dev/null
+++ b/Sources/ComposedUI/CollectionView/UICollectionViewSection.swift
@@ -0,0 +1,45 @@
+import UIKit
+import Composed
+
+@available(*, deprecated, renamed: "SingleUICollectionViewSection")
+public typealias CollectionSectionProvider = SingleUICollectionViewSection
+
+/// A section in a `UICollectionView`.
+public protocol UICollectionViewSection: Section {
+ func collectionViewElementsProvider(with traitCollection: UITraitCollection) -> UICollectionViewSectionElementsProvider
+}
+
+public protocol SingleUICollectionViewSection: UICollectionViewSection {
+ func section(with traitCollection: UITraitCollection) -> CollectionSection
+}
+
+extension SingleUICollectionViewSection {
+ public func collectionViewElementsProvider(with traitCollection: UITraitCollection) -> UICollectionViewSectionElementsProvider {
+ return section(with: traitCollection)
+ }
+}
+
+@available(*, deprecated, renamed: "UICollectionViewSectionElementsProvider")
+public typealias CollectionElementsProvider = UICollectionViewSectionElementsProvider
+
+public protocol UICollectionViewSectionElementsProvider {
+ var header: CollectionSupplementaryElement? { get }
+ var footer: CollectionSupplementaryElement? { get }
+ var numberOfElements: Int { get }
+
+ func cell(for index: Int) -> CollectionCellElement
+}
+
+extension UICollectionViewSectionElementsProvider {
+ public var isEmpty: Bool { return numberOfElements == 0 }
+}
+
+public protocol SingleUICollectionViewSectionElementsProvider: UICollectionViewSectionElementsProvider {
+ var cell: CollectionCellElement { get }
+}
+
+extension SingleUICollectionViewSectionElementsProvider {
+ public func cell(for index: Int) -> CollectionCellElement {
+ return cell
+ }
+}
diff --git a/Sources/ComposedUI/Common/ChangesReducer.swift b/Sources/ComposedUI/Common/ChangesReducer.swift
new file mode 100644
index 0000000..b18c160
--- /dev/null
+++ b/Sources/ComposedUI/Common/ChangesReducer.swift
@@ -0,0 +1,297 @@
+import Foundation
+
+/**
+ A value that collects and reduces changes to allow them to allow multiple changes
+ to be applied at once.
+
+ The logic of how to reduce the changes is designed to match that of `UICollectionView`
+ and `UITableView`, allowing for reuse between both.
+
+ `ChangesReducer` uses the generalised terms "group" and "element", which can be mapped directly
+ to "section" and "row" for `UITableView`s and "section" and "item" for `UICollectionView`.
+ */
+internal struct ChangesReducer {
+ internal var hasActiveUpdates: Bool {
+ return activeUpdates > 0
+ }
+
+ private var activeUpdates = 0
+
+ private var changeset: Changeset = Changeset()
+
+ /// Begin performing updates. This must be called prior to making updates.
+ ///
+ /// It is possible to call this function multiple times to build up a batch of changes.
+ ///
+ /// All calls to this must be balanced with a call to `endUpdating`.
+ internal mutating func beginUpdating() {
+ activeUpdates += 1
+ }
+
+ /// End the current collection of updates.
+ ///
+ /// - Returns: The completed changeset, if this ends the last update in the batch.
+ internal mutating func endUpdating() -> Changeset? {
+ activeUpdates -= 1
+
+ guard activeUpdates == 0 else {
+ assert(activeUpdates > 0, "`endUpdating` calls must be balanced with `beginUpdating`")
+ return nil
+ }
+
+ let changeset = self.changeset
+ self.changeset = Changeset()
+ return changeset
+ }
+
+ internal mutating func updateGroups(_ groups: IndexSet) {
+ changeset.groupsUpdated.formUnion(groups)
+ }
+
+ internal mutating func insertGroups(_ groups: [Int]) {
+ insertGroups(IndexSet(groups))
+ }
+
+ internal mutating func insertGroups(_ groups: IndexSet) {
+ groups.forEach { insertedGroup in
+ if changeset.groupsRemoved.remove(insertedGroup) != nil {
+ changeset.groupsUpdated.insert(insertedGroup)
+ } else {
+ changeset.groupsRemoved = Set(changeset.groupsRemoved.map { removedGroup in
+ if removedGroup > insertedGroup {
+ return removedGroup + 1
+ } else {
+ return removedGroup
+ }
+ })
+ changeset.groupsInserted.insert(insertedGroup)
+ }
+
+ changeset.groupsInserted = Set(changeset.groupsInserted.map { insertedGroup in
+ if insertedGroup > insertedGroup {
+ return insertedGroup + 1
+ } else {
+ return insertedGroup
+ }
+ })
+
+ changeset.groupsUpdated = Set(changeset.groupsUpdated.map { updatedGroup in
+ if updatedGroup > insertedGroup {
+ return updatedGroup + 1
+ } else {
+ return updatedGroup
+ }
+ })
+
+ changeset.elementsRemoved = Set(changeset.elementsRemoved.map { removedIndexPath in
+ var removedIndexPath = removedIndexPath
+
+ if removedIndexPath.section > insertedGroup {
+ removedIndexPath.section += 1
+ }
+
+ return removedIndexPath
+ })
+
+ changeset.elementsInserted = Set(changeset.elementsInserted.map { insertedIndexPath in
+ var insertedIndexPath = insertedIndexPath
+
+ if insertedIndexPath.section > insertedGroup {
+ insertedIndexPath.section += 1
+ }
+
+ return insertedIndexPath
+ })
+
+ changeset.elementsUpdated = Set(changeset.elementsUpdated.map { updatedIndexPath in
+ var updatedIndexPath = updatedIndexPath
+
+ if updatedIndexPath.section > insertedGroup {
+ updatedIndexPath.section += 1
+ }
+
+ return updatedIndexPath
+ })
+
+ changeset.elementsMoved = Set(changeset.elementsMoved.map { move in
+ var move = move
+
+ if move.from.section > insertedGroup {
+ move.from.section += 1
+ }
+
+ if move.to.section > insertedGroup {
+ move.to.section += 1
+ }
+
+ return move
+ })
+ }
+ }
+
+ internal mutating func removeGroups(_ groups: [Int]) {
+ removeGroups(IndexSet(groups))
+ }
+
+ internal mutating func removeGroups(_ groups: IndexSet) {
+ groups.forEach { removedGroup in
+ if changeset.groupsInserted.remove(removedGroup) == nil {
+ changeset.groupsRemoved = Set(changeset.groupsRemoved
+ .sorted(by: <)
+ .reduce(into: (previous: Int?.none, groupsRemoved: [Int]()), { (result, groupsRemoved) in
+ if groupsRemoved == removedGroup {
+ result.groupsRemoved.append(groupsRemoved)
+ result.groupsRemoved.append(groupsRemoved + 1)
+ result.previous = groupsRemoved + 1
+ } else if let previous = result.previous, groupsRemoved == previous {
+ result.groupsRemoved.append(groupsRemoved + 1)
+ result.previous = groupsRemoved + 1
+ } else {
+ result.groupsRemoved.append(groupsRemoved)
+ result.previous = groupsRemoved
+ }
+ })
+ .groupsRemoved
+ )
+
+ if !changeset.groupsRemoved.contains(removedGroup) {
+ changeset.groupsRemoved.insert(removedGroup)
+ }
+ }
+
+ changeset.groupsInserted = Set(changeset.groupsInserted.map { insertedGroup in
+ if insertedGroup > removedGroup {
+ return insertedGroup - 1
+ } else {
+ return insertedGroup
+ }
+ })
+
+ changeset.groupsUpdated = Set(changeset.groupsUpdated.map { updatedGroup in
+ if updatedGroup > removedGroup {
+ return updatedGroup - 1
+ } else {
+ return updatedGroup
+ }
+ })
+
+ changeset.elementsInserted = Set(changeset.elementsInserted.compactMap { insertedIndexPath in
+ guard insertedIndexPath.section != removedGroup else { return nil }
+
+ var batchedRowInsert = insertedIndexPath
+
+ if batchedRowInsert.section > removedGroup {
+ batchedRowInsert.section -= 1
+ }
+
+ return batchedRowInsert
+ })
+
+ changeset.elementsUpdated = Set(changeset.elementsUpdated.compactMap { updatedIndexPath in
+ guard updatedIndexPath.section != removedGroup else { return nil }
+
+ var batchedRowUpdate = updatedIndexPath
+
+ if batchedRowUpdate.section > removedGroup {
+ batchedRowUpdate.section -= 1
+ }
+
+ return batchedRowUpdate
+ })
+
+ changeset.elementsRemoved = Set(changeset.elementsRemoved.compactMap { removedIndexPath in
+ guard removedIndexPath.section != removedGroup else { return nil }
+
+ var batchedRowRemoval = removedIndexPath
+
+ if batchedRowRemoval.section > removedGroup {
+ batchedRowRemoval.section -= 1
+ }
+
+ return batchedRowRemoval
+ })
+
+ changeset.elementsMoved = Set(changeset.elementsMoved.compactMap { move in
+ guard move.to.section != removedGroup else { return nil }
+
+ var move = move
+
+ if move.from.section > removedGroup {
+ move.from.section -= 1
+ }
+
+ if move.to.section > removedGroup {
+ move.to.section -= 1
+ }
+
+ return move
+ })
+ }
+ }
+
+ internal mutating func insertElements(at indexPaths: [IndexPath]) {
+ changeset.elementsInserted.formUnion(indexPaths)
+ }
+
+ internal mutating func removeElements(at indexPaths: [IndexPath]) {
+ indexPaths.forEach { removedIndexPath in
+ if changeset.elementsUpdated.remove(removedIndexPath) == nil, changeset.elementsInserted.remove(removedIndexPath) == nil {
+ changeset.elementsRemoved.insert(removedIndexPath)
+ }
+
+ changeset.elementsUpdated = Set(changeset.elementsUpdated.compactMap { updatedIndexPath in
+ guard updatedIndexPath.section == removedIndexPath.section else { return updatedIndexPath }
+
+ if updatedIndexPath.item > removedIndexPath.item {
+ if updatedIndexPath.item == removedIndexPath.item + 1 {
+ // Triggering an update to row with the same index path as one that's been removed
+ // will trigger "attempt to delete and reload the same index path"
+ changeset.elementsRemoved.insert(updatedIndexPath)
+ changeset.elementsInserted.insert(updatedIndexPath)
+ return nil
+ }
+
+ return IndexPath(item: updatedIndexPath.item - 1, section: updatedIndexPath.section)
+ } else {
+ return updatedIndexPath
+ }
+ })
+
+ changeset.elementsInserted = Set(changeset.elementsInserted.map { insertedIndexPath in
+ guard insertedIndexPath.section == removedIndexPath.section else { return insertedIndexPath }
+
+ if insertedIndexPath.item > removedIndexPath.item {
+ return IndexPath(item: insertedIndexPath.item - 1, section: insertedIndexPath.section)
+ } else {
+ return insertedIndexPath
+ }
+ })
+
+ changeset.elementsMoved = Set(changeset.elementsMoved.map { move in
+ var move = move
+
+ if move.from.section == removedIndexPath.section, move.from.item > removedIndexPath.item {
+ move.from.item -= 1
+ }
+
+ if move.to.section == removedIndexPath.section, move.to.item > removedIndexPath.item {
+ move.to.item -= 1
+ }
+
+ return move
+ })
+ }
+ }
+
+ internal mutating func updateElements(at indexPaths: [IndexPath]) {
+ changeset.elementsUpdated.formUnion(indexPaths)
+ }
+
+ internal mutating func moveElements(_ moves: [Changeset.Move]) {
+ changeset.elementsMoved.formUnion(moves)
+ }
+
+ internal mutating func moveElements(_ moves: [(from: IndexPath, to: IndexPath)]) {
+ changeset.elementsMoved.formUnion(moves.map { Changeset.Move(from: $0.from, to: $0.to) })
+ }
+}
diff --git a/Sources/ComposedUI/Common/Changeset.swift b/Sources/ComposedUI/Common/Changeset.swift
new file mode 100644
index 0000000..97aeab3
--- /dev/null
+++ b/Sources/ComposedUI/Common/Changeset.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/**
+ A collection of changes to be applied in batch.
+ */
+internal struct Changeset {
+ internal struct Move: Hashable {
+ internal var from: IndexPath
+ internal var to: IndexPath
+ }
+
+ internal var groupsInserted: Set = []
+ internal var groupsRemoved: Set = []
+ internal var groupsUpdated: Set = []
+ internal var elementsRemoved: Set = []
+ internal var elementsInserted: Set = []
+ internal var elementsUpdated: Set = []
+ internal var elementsMoved: Set = []
+}
diff --git a/Sources/ComposedUI/Common/IndexPath+Identifier.swift b/Sources/ComposedUI/Common/IndexPath+Identifier.swift
new file mode 100644
index 0000000..ef71fcd
--- /dev/null
+++ b/Sources/ComposedUI/Common/IndexPath+Identifier.swift
@@ -0,0 +1,13 @@
+import UIKit
+
+internal extension IndexPath {
+ init?(string: String) {
+ let components = string.components(separatedBy: ".").compactMap { Int($0) }
+ guard components.count == 2 else { return nil }
+ self.init(item: components[1], section: components[0])
+ }
+
+ var string: NSString {
+ return "\(section).\(item)" as NSString
+ }
+}
diff --git a/Sources/ComposedUI/Common/Reuse.swift b/Sources/ComposedUI/Common/Reuse.swift
new file mode 100644
index 0000000..dae74cb
--- /dev/null
+++ b/Sources/ComposedUI/Common/Reuse.swift
@@ -0,0 +1,174 @@
+import UIKit
+
+/// Represents any type that can be loaded via a XIB/NIB
+public protocol NibLoadable {
+
+ static var nib: UINib { get }
+
+ static var nibName: String { get }
+
+ /// Returns an instance of `Self` from a nib. Uses String(describing: self) to determine the name of the nib.
+ static var fromNib: Self { get }
+
+ /// Returns an instance of `Self` from a nib.
+ ///
+ /// - Parameter name: The name of the nib
+ /// - Returns: A new instance of this type
+ static func fromNib(named name: String, withOwner ownerOrNil: Any?, options optionsOrNil: [UINib.OptionsKey: Any]?) -> Self
+
+}
+
+extension NibLoadable where Self: UIView {
+
+ public static var nibName: String {
+ return String(describing: self)
+ }
+
+ public static var nib: UINib {
+ return UINib(nibName: nibName, bundle: nil)
+ }
+
+ /// Returns an instance of `Self` from a nib. Uses String(describing: self) to determine the name of the nib.
+ public static var fromNib: Self {
+ return fromNib(named: nibName)
+ }
+
+ /// Returns an instance of `Self` from a nib.
+ ///
+ /// - Parameter name: The name of the nib
+ /// - Returns: A new instance of this type
+ public static func fromNib(named name: String, withOwner ownerOrNil: Any? = nil, options optionsOrNil: [UINib.OptionsKey: Any]? = nil) -> Self {
+ let bundle = Bundle(for: self)
+
+ guard let view = UINib(nibName: name, bundle: bundle)
+ .instantiate(withOwner: ownerOrNil, options: optionsOrNil).first as? Self else {
+ fatalError("Instantiation of \(nibName) from a nib failed")
+ }
+
+ return view
+ }
+
+}
+
+public protocol ReusableCell: ReuseableView {
+ static var reuseIdentifier: String { get }
+}
+
+extension ReusableCell {
+ public static var reuseIdentifier: String {
+ return String(describing: self)
+ }
+}
+
+public protocol ReuseableView: class {
+ static var reuseIdentifier: String { get }
+ static var kind: String { get }
+ var reuseIdentifier: String? { get }
+}
+
+public extension ReuseableView {
+ static var kind: String {
+ return String(describing: self)
+ }
+}
+
+extension UITableViewCell: ReusableCell { }
+extension UICollectionReusableView: ReusableCell { }
+extension UITableViewHeaderFooterView: ReusableCell { }
+extension UICollectionReusableView: ReuseableView { }
+
+public extension UITableView {
+
+ // Cells
+
+ func register(nib: C.Type, bundle: Bundle? = nil) where C: UITableViewCell {
+ let nib = UINib(nibName: String(describing: C.self), bundle: bundle ?? Bundle(for: C.self))
+ register(nib, forCellReuseIdentifier: C.reuseIdentifier)
+ }
+
+ func dequeue(cell: C.Type, reuseIdentifier: String? = nil, for indexPath: IndexPath) -> C where C: UITableViewCell {
+ // swiftlint:disable force_cast
+ return dequeueReusableCell(withIdentifier: reuseIdentifier ?? cell.reuseIdentifier, for: indexPath) as! C
+ }
+
+ // Header/Footer Views
+
+ func registerHeaderFooterNib(class: C.Type, bundle: Bundle? = nil) where C: UITableViewHeaderFooterView {
+ register(UINib(nibName: String(describing: C.self), bundle: bundle ?? Bundle(for: C.self)),
+ forHeaderFooterViewReuseIdentifier: C.reuseIdentifier)
+ }
+
+ func dequeue(headerFooter: C.Type, reuseIdentifier: String? = nil, for indexPath: IndexPath) -> C where C: UITableViewHeaderFooterView {
+ // swiftlint:disable force_cast
+ return dequeueReusableHeaderFooterView(withIdentifier: reuseIdentifier ?? headerFooter.reuseIdentifier) as! C
+ }
+
+}
+
+public extension UICollectionView {
+
+ // Editing
+
+ var isEditing: Bool {
+ return owningViewController?.isEditing ?? false
+ }
+
+ // Cells
+
+ func register(cell: C.Type) where C: UICollectionViewCell {
+ self.register(cell, forCellWithReuseIdentifier: cell.reuseIdentifier)
+ }
+
+ func register(nib: C.Type, bundle: Bundle? = nil) where C: UICollectionViewCell {
+ self.register(UINib(nibName: String(describing: C.self), bundle: bundle ?? Bundle(for: C.self)),
+ forCellWithReuseIdentifier: nib.reuseIdentifier)
+ }
+
+ func dequeue(cell: C.Type, reuseIdentifier: String? = nil, for indexPath: IndexPath) -> C where C: UICollectionViewCell {
+ return dequeueReusableCell(withReuseIdentifier: reuseIdentifier ?? cell.reuseIdentifier, for: indexPath) as! C
+ }
+
+ // Supplementary views
+
+ func register(class: C.Type, ofKind kind: String) where C: UICollectionReusableView {
+ self.register(`class`, forSupplementaryViewOfKind: kind, withReuseIdentifier: `class`.reuseIdentifier)
+ }
+
+ func register(nib: C.Type, ofKind kind: String, bundle: Bundle? = nil) where C: UICollectionReusableView {
+ self.register(UINib(nibName: String(describing: C.self), bundle: bundle ?? Bundle(for: C.self)),
+ forSupplementaryViewOfKind: kind, withReuseIdentifier: C.reuseIdentifier)
+ }
+
+ func dequeue(supplementary: C.Type, ofKind kind: String, reuseIdentifier: String? = nil, for indexPath: IndexPath) -> C where C: UICollectionReusableView {
+ return dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier ?? supplementary.reuseIdentifier, for: indexPath) as! C
+ }
+}
+
+public extension UICollectionViewLayout {
+
+ func register(nib cellClass: C.Type, bundle: Bundle? = nil, ofKind kind: String) where C: UICollectionReusableView {
+ let nib = UINib(nibName: String(describing: cellClass), bundle: bundle ?? Bundle(for: C.self))
+ register(nib, forDecorationViewOfKind: kind)
+ }
+
+ func register(class cellClass: C.Type, bundle: Bundle? = nil, ofKind kind: String) where C: UICollectionReusableView {
+ register(cellClass, forDecorationViewOfKind: kind)
+ }
+
+}
+
+private extension UIView {
+
+ @objc var owningViewController: UIViewController? {
+ var responder: UIResponder? = self
+
+ while !(responder is UIViewController) && superview != nil {
+ if let next = responder?.next {
+ responder = next
+ }
+ }
+
+ return responder as? UIViewController
+ }
+
+}
diff --git a/Sources/ComposedUI/Common/Types.swift b/Sources/ComposedUI/Common/Types.swift
new file mode 100644
index 0000000..29f7ff8
--- /dev/null
+++ b/Sources/ComposedUI/Common/Types.swift
@@ -0,0 +1,38 @@
+import UIKit
+
+/// The method to use when dequeuing a view from a UICollectionView
+///
+/// - nib: Load from a XIB
+/// - `class`: Load from a class
+public enum DequeueMethod {
+ /// Load from a nib
+ case fromNib(View.Type)
+ // Load from a class
+ case fromClass(View.Type)
+ /// Load from a storyboard
+ case fromStoryboard(View.Type)
+
+ internal var erasedAsAnyDequeueMethod: AnyDequeueMethod {
+ switch self {
+ case .fromNib(let viewType):
+ return AnyDequeueMethod(method: .fromNib(viewType))
+ case .fromClass(let viewType):
+ return AnyDequeueMethod(method: .fromClass(viewType))
+ case .fromStoryboard(let viewType):
+ return AnyDequeueMethod(method: .fromStoryboard(viewType))
+ }
+ }
+}
+
+public struct AnyDequeueMethod {
+ internal enum Method {
+ /// Load from a nib
+ case fromNib(UIView.Type)
+ // Load from a class
+ case fromClass(UIView.Type)
+ /// Load from a storyboard
+ case fromStoryboard(UIView.Type)
+ }
+
+ internal let method: Method
+}
diff --git a/Sources/ComposedUI/StackView/ComposedSectionView.swift b/Sources/ComposedUI/StackView/ComposedSectionView.swift
new file mode 100644
index 0000000..bccc56e
--- /dev/null
+++ b/Sources/ComposedUI/StackView/ComposedSectionView.swift
@@ -0,0 +1,79 @@
+import UIKit
+
+public final class ComposedSectionView: UIView {
+
+ private lazy var stackView = ComposedStackView()
+
+ public var visibleCells: [ComposedViewCell] {
+ return stackView.arrangedSubviews
+ .compactMap { $0 as? ComposedViewCell }
+ .filter { !$0.isHidden }
+ }
+
+ public var separatorInsets: UIEdgeInsets = .zero
+ public var separatorColor: UIColor?
+
+ public init() {
+ super.init(frame: .zero)
+ prepare()
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func prepare() {
+ preservesSuperviewLayoutMargins = true
+
+ stackView.preservesSuperviewLayoutMargins = true
+ stackView.axis = .vertical
+ stackView.distribution = .equalSpacing
+ stackView.alignment = .fill
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+
+ super.addSubview(stackView)
+
+ NSLayoutConstraint.activate([
+ stackView.topAnchor.constraint(equalTo: topAnchor),
+ stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+ }
+
+ public func addItemView(_ view: ComposedViewCell, with animation: ComposedStackView.Animation = .slide) {
+ stackView.addArrangedSubview(view, with: animation)
+ updateSeparators()
+ }
+
+ public func insertItem(_ view: ComposedViewCell, at index: Int, with animation: ComposedStackView.Animation = .slide) {
+ stackView.insertArrangedSubview(view, at: index, with: animation)
+ updateSeparators()
+ }
+
+ public func removeItemView(_ view: ComposedViewCell, with animation: ComposedStackView.Animation = .slide) {
+ stackView.removeArrangedSubview(view, with: animation)
+ updateSeparators()
+ }
+
+ public func deleteItem(at index: Int, with animation: ComposedStackView.Animation = .slide) {
+ stackView.removeArrangedSubvew(at: index, with: animation)
+ updateSeparators()
+ }
+
+ private func updateSeparators() {
+ for (index, cell) in visibleCells.enumerated() {
+ if index == visibleCells.count - 1 {
+ cell.separatorInsets = .zero
+ } else {
+ cell.separatorInsets = separatorInsets
+ }
+
+ cell.separatorColor = separatorColor
+ cell.topSeparator.isHidden = index > 0
+ cell.bottomSeparator.isHidden = false
+ }
+ }
+
+}
diff --git a/Sources/ComposedUI/StackView/ComposedStackView.swift b/Sources/ComposedUI/StackView/ComposedStackView.swift
new file mode 100644
index 0000000..889df1e
--- /dev/null
+++ b/Sources/ComposedUI/StackView/ComposedStackView.swift
@@ -0,0 +1,185 @@
+import UIKit
+
+/// A stack view that supports animating showing/hiding arranged subviews,
+/// and has the option of dynamically creating separators when arranged subviews are added.
+open class ComposedStackView: UIStackView {
+
+ /// Types of animations that are applied to the stack view.
+ ///
+ /// - fade: Animate opacity.
+ /// - hidden: Animate the isHidden property.
+ enum AnimationType {
+ case fade, hidden
+ }
+
+ public enum Animation {
+ case slide
+ case fade
+ case none
+ }
+
+ /// The style of the stack view.
+ public var animationDuration: TimeInterval = 0.2
+
+ // MARK: Life Cycle
+
+ /// Create the stack view with a style. A plain style is a typical stack view. A separated
+ /// style will automatically create separators between arranged subviews whenever they're
+ /// added.
+ ///
+ /// - Parameter style: The style for the stack view.
+ public init() {
+ super.init(frame: .zero)
+ axis = .vertical
+ preservesSuperviewLayoutMargins = true
+ }
+
+ public required init(coder: NSCoder) {
+ super.init(coder: coder)
+ }
+
+ // MARK: Methods
+
+ /// Add a subview to the end of the stack view. If the style is separated,
+ /// separators will automatically be added. Provides the option to animate
+ /// showing the new view.
+ ///
+ /// - Parameters:
+ /// - view: The view to add.
+ /// - animated: Flag that determines if the view should animated on screen.
+ open func addArrangedSubview(_ view: UIView, with animation: Animation) {
+ insertArrangedSubview(view, at: arrangedSubviews.count, with: animation)
+ }
+
+ /// Insert an arranged subview at a particular index in the stack view. If the style is
+ /// separated, separators will be automatically added. Provides the option to animate
+ /// showing the new view.
+ ///
+ /// - Parameters:
+ /// - view: The view to add.
+ /// - stackIndex: Index in the stack view to add the view.
+ /// - animated: Flag that determines if the view should animated on screen.
+ open func insertArrangedSubview(_ view: UIView, at stackIndex: Int, with animation: Animation) {
+ guard animation != .none else {
+ insertArrangedSubview(view, at: stackIndex)
+ return
+ }
+
+ view.isHidden = true
+ view.alpha = animation == .fade ? 0 : 1
+ super.insertArrangedSubview(view, at: stackIndex)
+ toggleViews([view], shouldShow: true, animation: animation)
+ }
+
+ /// Remove an arranged subview from the stack view. If the style is separated,
+ /// the separators will be automatically removed. Option to animated the removal of the
+ /// view.
+ ///
+ /// - Parameters:
+ /// - view: The view to remove.
+ /// - animated: Flag that determines if the view removal should be animated.
+ open func removeArrangedSubview(_ view: UIView, with animation: Animation) {
+ let viewsToRemove = [view]
+
+ let removeBlock = {
+ viewsToRemove.forEach {
+ $0.removeFromSuperview()
+ $0.isHidden = false
+ $0.alpha = 1
+ }
+ }
+
+ guard UIView.areAnimationsEnabled && animation != .none else {
+ removeBlock()
+ return
+ }
+
+ toggleViews(viewsToRemove, shouldShow: false, animation: animation) { complete in
+ guard complete else { return }
+ removeBlock()
+ }
+ }
+
+ open func removeArrangedSubvew(at stackIndex: Int, with animation: Animation) {
+ guard arrangedSubviews.indices.contains(stackIndex) else { return }
+ removeArrangedSubview(arrangedSubviews[stackIndex], with: animation)
+ }
+
+ /// Clear the views in the stack view.
+ ///
+ /// - Parameter animated: Flag to animate the removal of the views.
+ private func clear(animation: Animation) {
+ let removeViewsBlock = { [weak self] in
+ self?.subviews.forEach { $0.removeFromSuperview() }
+ }
+
+ guard UIView.areAnimationsEnabled && animation != .none else {
+ removeViewsBlock()
+ return
+ }
+
+ toggleViews(subviews, shouldShow: false, animation: animation) { complete in
+ guard complete else { return }
+ removeViewsBlock()
+ }
+ }
+
+ /// Hide or show the specified views in the stack view.
+ ///
+ /// - Parameters:
+ /// - views: The views to hide or show.
+ /// - shouldShow: True if the views should be shown, false to hide them.
+ /// - animated: Animate the visibility of the views.
+ /// - animations: The particular animations to use when toggling the visibility.
+ /// - completion: Block to run when the visibility toggling and any animations are complete.
+ private func toggleViews(
+ _ views: [UIView],
+ shouldShow: Bool,
+ animation: Animation = .slide,
+ completion: ((Bool) -> Void)? = nil) {
+ views.forEach { guard $0.superview == self else { return } }
+
+ let animations: [AnimationType]
+
+ switch animation {
+ case .fade: animations = [.fade, .hidden]
+ case .slide: animations = [.hidden]
+ case .none: animations = []
+ }
+
+ // skip animation
+ guard animation != .none else {
+ views.forEach { $0.isHidden = !shouldShow }
+ return
+ }
+
+ var completionWillBeCalled = false
+ let options: UIView.AnimationOptions = shouldShow
+ ? [.curveEaseOut, .allowUserInteraction]
+ : [.curveEaseOut]
+
+ if animations.contains(.hidden) {
+ let filteredViews = views.filter { $0.isHidden == shouldShow } // only animated views that are not yet animated
+ filteredViews.forEach { $0.isHidden = shouldShow }
+
+ UIView.animate(withDuration: animationDuration, delay: 0, options: options, animations: {
+ filteredViews.forEach { $0.isHidden = !shouldShow }
+ }, completion: { complete in
+ if !completionWillBeCalled { completion?(complete) }
+ completionWillBeCalled = true
+ })
+ }
+
+ if animations.contains(.fade) {
+ let filteredViews = views.filter { $0.alpha == (shouldShow ? 0 : 1) } // only animated views that are not yet animated
+ filteredViews.forEach { $0.alpha = shouldShow ? 0 : 1 }
+
+ UIView.animate(withDuration: animationDuration, delay: 0, options: options, animations: {
+ filteredViews.forEach { $0.alpha = shouldShow ? 1 : 0 }
+ }, completion: { complete in
+ if !completionWillBeCalled { completion?(complete) }
+ completionWillBeCalled = true
+ })
+ }
+ }
+}
diff --git a/Sources/ComposedUI/StackView/ComposedView.swift b/Sources/ComposedUI/StackView/ComposedView.swift
new file mode 100644
index 0000000..49659e1
--- /dev/null
+++ b/Sources/ComposedUI/StackView/ComposedView.swift
@@ -0,0 +1,105 @@
+import UIKit
+
+open class ComposedView: UIView {
+
+ open override var backgroundColor: UIColor? {
+ didSet {
+ contentView.backgroundColor = backgroundColor
+ scrollView.backgroundColor = backgroundColor
+ }
+ }
+
+ private let stackView = ComposedStackView()
+ private let scrollView = UIScrollView()
+ private let contentView = ComposedContentView()
+
+ @IBInspectable
+ open var interSectionSpacing: CGFloat = 20 {
+ didSet { stackView.spacing = interSectionSpacing }
+ }
+
+ open var contentInsets: UIEdgeInsets = .zero {
+ didSet { scrollView.contentInset = contentInsets }
+ }
+
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+ prepare()
+ }
+
+ public required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ prepare()
+ }
+
+ private func prepare() {
+ scrollView.alwaysBounceVertical = true
+
+ if backgroundColor == nil {
+ if #available(iOS 13.0, *) {
+ backgroundColor = .systemBackground
+ } else {
+ backgroundColor = .white
+ }
+ }
+
+ stackView.axis = .vertical
+ stackView.alignment = .fill
+ stackView.distribution = .equalSpacing
+ stackView.spacing = interSectionSpacing
+
+ addSubview(scrollView)
+ scrollView.addSubview(contentView)
+ contentView.addSubview(stackView)
+
+ [scrollView, contentView, stackView].forEach { $0?.translatesAutoresizingMaskIntoConstraints = false }
+
+ NSLayoutConstraint.activate([
+ stackView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor),
+
+ scrollView.topAnchor.constraint(equalTo: topAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+ contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
+ contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
+ contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
+ contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
+
+ contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
+ ])
+ }
+
+ open var sections: [ComposedSectionView] {
+ return stackView.arrangedSubviews.compactMap { $0 as? ComposedSectionView }
+ }
+
+ public func appendSection(_ section: ComposedSectionView, with animation: ComposedStackView.Animation = .fade) {
+ insertSection(section, at: stackView.arrangedSubviews.count, with: animation)
+ }
+
+ public func insertSection(_ section: ComposedSectionView, at index: Int, with animation: ComposedStackView.Animation = .fade) {
+ stackView.insertArrangedSubview(section, at: index, with: animation)
+ }
+
+ public func removeSection(_ section: ComposedSectionView, animation: ComposedStackView.Animation = .fade) {
+ guard let index = stackView.arrangedSubviews.firstIndex(of: section) else { return }
+ removeSection(at: index, animation: animation)
+ }
+
+ public func removeSection(at index: Int, animation: ComposedStackView.Animation = .fade) {
+ guard stackView.arrangedSubviews.indices.contains(index) else { return }
+ stackView.removeArrangedSubview(stackView.arrangedSubviews[index], with: animation)
+ }
+
+ public func removeAllSections() {
+ fatalError("Unimplemented")
+ }
+
+}
+
+private final class ComposedContentView: UIView { }
diff --git a/Sources/ComposedUI/StackView/ComposedViewCell.swift b/Sources/ComposedUI/StackView/ComposedViewCell.swift
new file mode 100644
index 0000000..2741187
--- /dev/null
+++ b/Sources/ComposedUI/StackView/ComposedViewCell.swift
@@ -0,0 +1,98 @@
+import UIKit
+
+open class ComposedViewCell: UIView {
+
+ private(set) lazy var topSeparator: UIView = {
+ return SeparatorView()
+ }()
+
+ private(set) lazy var bottomSeparator: UIView = {
+ return SeparatorView()
+ }()
+
+ private var leadingTopSeparatorConstraint: NSLayoutConstraint?
+ private var trailingTopSeparatorConstraint: NSLayoutConstraint?
+
+ private var leadingBottomSeparatorConstraint: NSLayoutConstraint?
+ private var trailingBottomSeparatorConstraint: NSLayoutConstraint?
+
+ internal var separatorColor: UIColor? {
+ didSet { updateSeparators() }
+ }
+
+ internal var separatorInsets: UIEdgeInsets = .zero {
+ didSet { updateSeparators() }
+ }
+
+ public required init() {
+ super.init(frame: .zero)
+ prepare()
+ }
+
+ public required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ prepare()
+ }
+
+ private func prepare() {
+ [topSeparator, bottomSeparator].forEach {
+ $0.isHidden = true
+ $0.translatesAutoresizingMaskIntoConstraints = false
+ addSubview($0)
+ }
+
+ leadingTopSeparatorConstraint = topSeparator.leadingAnchor.constraint(equalTo: leadingAnchor)
+ trailingTopSeparatorConstraint = topSeparator.trailingAnchor.constraint(equalTo: trailingAnchor)
+ leadingBottomSeparatorConstraint = bottomSeparator.leadingAnchor.constraint(equalTo: leadingAnchor)
+ trailingBottomSeparatorConstraint = bottomSeparator.trailingAnchor.constraint(equalTo: trailingAnchor)
+
+ NSLayoutConstraint.activate([
+ topSeparator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale),
+ topSeparator.topAnchor.constraint(equalTo: topAnchor),
+ leadingTopSeparatorConstraint,
+ trailingTopSeparatorConstraint,
+
+ bottomSeparator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale),
+ bottomSeparator.bottomAnchor.constraint(equalTo: bottomAnchor),
+ leadingBottomSeparatorConstraint,
+ trailingBottomSeparatorConstraint,
+ ].compactMap { $0 })
+
+ updateSeparators()
+ }
+
+ private func updateSeparators() {
+ leadingBottomSeparatorConstraint?.constant = separatorInsets.left > 0
+ ? safeAreaInsets.left + separatorInsets.left
+ : separatorInsets.left
+ trailingBottomSeparatorConstraint?.constant = separatorInsets.right
+
+ [topSeparator, bottomSeparator].forEach {
+ if #available(iOS 13.0, *) {
+ $0.backgroundColor = separatorColor ?? .separator
+ } else {
+ $0.backgroundColor = separatorColor ?? UIColor(displayP3Red: 60 / 255, green: 60 / 255, blue: 67 / 255, alpha: 0.29)
+ }
+ }
+ }
+
+ open override func addSubview(_ view: UIView) {
+ super.addSubview(view)
+ bringSubviewToFront(topSeparator)
+ bringSubviewToFront(bottomSeparator)
+ }
+
+ open override func insertSubview(_ view: UIView, at index: Int) {
+ super.insertSubview(view, at: index)
+ bringSubviewToFront(topSeparator)
+ bringSubviewToFront(bottomSeparator)
+ }
+
+ open override func safeAreaInsetsDidChange() {
+ super.safeAreaInsetsDidChange()
+ updateSeparators()
+ }
+
+}
+
+private final class SeparatorView: UIView { }
diff --git a/Sources/ComposedUI/StackView/StackCoordinator.swift b/Sources/ComposedUI/StackView/StackCoordinator.swift
new file mode 100644
index 0000000..6846f63
--- /dev/null
+++ b/Sources/ComposedUI/StackView/StackCoordinator.swift
@@ -0,0 +1,154 @@
+import UIKit
+import Composed
+
+open class StackCoordinator: NSObject {
+
+ public var sectionProvider: SectionProvider {
+ return mapper.provider
+ }
+
+ private var mapper: SectionProviderMapping
+ private let composedView: ComposedView
+
+ private var cachedProviders: [StackElementsProvider] = []
+
+ public init(composedView: ComposedView, sectionProvider: SectionProvider) {
+ if !composedView.sections.isEmpty {
+ assertionFailure("The stackView must not contain any existing arranged subviews")
+ composedView.removeAllSections()
+ }
+
+ self.composedView = composedView
+ self.mapper = SectionProviderMapping(provider: sectionProvider)
+
+ super.init()
+
+ prepareSections()
+ reloadComposedView()
+ }
+
+ open func replace(sectionProvider: SectionProvider) {
+ mapper = SectionProviderMapping(provider: sectionProvider)
+ prepareSections()
+ }
+
+ private func prepareSections() {
+ mapper.delegate = self
+ cachedProviders.removeAll()
+
+ for index in 0.. [Int] { return [] }
+ public func mapping(_ mapping: SectionProviderMapping, select indexPath: IndexPath) { }
+ public func mapping(_ mapping: SectionProviderMapping, deselect indexPath: IndexPath) { }
+ public func mapping(_ mapping: SectionProviderMapping, move sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { }
+ public func mappingDidInvalidateHeader(at sectionIndex: Int) {}
+
+}
+
+public extension StackCoordinator {
+
+ /// A convenience initializer that allows creation without a provider
+ /// - Parameters:
+ /// - composedView: The `ComposedView` associated with this coordinator
+ /// - sections: The sections associated with this coordinator
+ convenience init(composedView: ComposedView, sections: Section...) {
+ let provider = ComposedSectionProvider()
+ sections.forEach(provider.append(_:))
+ self.init(composedView: composedView, sectionProvider: provider)
+ }
+
+}
diff --git a/Sources/ComposedUI/StackView/StackElement.swift b/Sources/ComposedUI/StackView/StackElement.swift
new file mode 100644
index 0000000..4ef7773
--- /dev/null
+++ b/Sources/ComposedUI/StackView/StackElement.swift
@@ -0,0 +1,21 @@
+import UIKit
+import Composed
+
+public enum StackLoadingMethod {
+ case fromNib(Cell.Type)
+ case fromClass(Cell.Type)
+}
+
+public final class StackCellElement where Cell: ComposedViewCell {
+
+ internal let loadingMethod: StackLoadingMethod
+ internal let configure: (ComposedViewCell, Int, Section) -> Void
+
+ public init(section: Section, loadingMethod: StackLoadingMethod, configure: @escaping (Cell, Int, Section) -> Void) where Section: Composed.Section {
+ self.loadingMethod = loadingMethod
+ self.configure = { cell, index, section in
+ configure(cell as! Cell, index, section as! Section)
+ }
+ }
+
+}
diff --git a/Sources/ComposedUI/StackView/StackElementsProvider.swift b/Sources/ComposedUI/StackView/StackElementsProvider.swift
new file mode 100644
index 0000000..d0446a7
--- /dev/null
+++ b/Sources/ComposedUI/StackView/StackElementsProvider.swift
@@ -0,0 +1,24 @@
+import UIKit
+
+public protocol StackElementsProvider {
+ var cell: StackCellElement { get }
+ var numberOfElements: Int { get }
+}
+
+public extension StackElementsProvider {
+ var isEmpty: Bool { return numberOfElements == 0 }
+}
+
+public protocol StackSectionProvider {
+ func section(with traitCollection: UITraitCollection) -> StackSection
+}
+
+public protocol StackSectionAppearanceHandler: StackSectionProvider {
+ func separatorInsets(suggested: UIEdgeInsets, traitCollection: UITraitCollection) -> UIEdgeInsets
+ func separatorColor(suggested: UIColor?, traitCollection: UITraitCollection) -> UIColor?
+}
+
+public extension StackSectionAppearanceHandler {
+ func separatorInsets(suggested: UIEdgeInsets, traitCollection: UITraitCollection) -> UIEdgeInsets { return suggested }
+ func separatorColor(suggested: UIColor?, traitCollection: UITraitCollection) -> UIColor? { return suggested }
+}
diff --git a/Sources/ComposedUI/StackView/StackSection.swift b/Sources/ComposedUI/StackView/StackSection.swift
new file mode 100644
index 0000000..a89000f
--- /dev/null
+++ b/Sources/ComposedUI/StackView/StackSection.swift
@@ -0,0 +1,26 @@
+import UIKit
+import Composed
+
+open class StackSection: StackElementsProvider {
+
+ public var cell: StackCellElement
+
+ public var numberOfElements: Int {
+ return section?.numberOfElements ?? 0
+ }
+
+ private weak var section: Section?
+
+ public init(section: Section, cell: StackCellElement) where Section: Composed.Section, Cell: ComposedViewCell {
+ self.section = section
+
+ let loadingMethod: StackLoadingMethod
+ switch cell.loadingMethod {
+ case .fromClass: loadingMethod = .fromClass(Cell.self)
+ case .fromNib: loadingMethod = .fromNib(Cell.self)
+ }
+
+ self.cell = StackCellElement(section: section, loadingMethod: loadingMethod, configure: cell.configure)
+ }
+
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableAccessoryHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableAccessoryHandler.swift
new file mode 100644
index 0000000..e4b9932
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableAccessoryHandler.swift
@@ -0,0 +1,11 @@
+import UIKit
+import Composed
+
+/// Provides cell accessory handling for `UITableView`'s
+public protocol TableAccessoryHandler: TableSectionProvider {
+
+ /// Called when the accessory view is tapped for the cell at the specified index
+ /// - Parameter index: The element index
+ func didSelectAccessory(at index: Int)
+
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableActionsHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableActionsHandler.swift
new file mode 100644
index 0000000..8d9c90b
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableActionsHandler.swift
@@ -0,0 +1,20 @@
+import UIKit
+import Composed
+
+/// Provides cell action handling for `UITableView`'s
+public protocol TableActionsHandler: TableSectionProvider {
+
+ /// Return leading actions for the cell at the specified index
+ /// - Parameter index: The element index
+ func leadingSwipeActions(at index: Int) -> UISwipeActionsConfiguration?
+
+ /// Return trailing actions for the cell at the specified index
+ /// - Parameter index: The element index
+ func trailingSwipeActions(at index: Int) -> UISwipeActionsConfiguration?
+
+}
+
+public extension TableActionsHandler {
+ func leadingSwipeActions(at index: Int) -> UISwipeActionsConfiguration? { return nil }
+ func trailingSwipeActions(at index: Int) -> UISwipeActionsConfiguration? { return nil }
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableContextMenuHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableContextMenuHandler.swift
new file mode 100644
index 0000000..0492cc1
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableContextMenuHandler.swift
@@ -0,0 +1,54 @@
+import UIKit
+import Composed
+
+/// Provides context menu handling for `UITableView`'s
+@available(iOS 13.0, *)
+public protocol TableContextMenuHandler: TableSectionProvider {
+
+ /// Specifies whether or not a specific element allows showing a context menu. If you want UIKit to indicate that a menu is usually provided for this element, true `true` and simply return `nil` for `contextMenu(forElementAt:cell:suggestedActions:)`
+ /// - Parameter index: The index of the element
+ func allowsContextMenu(forElementAt index: Int) -> Bool
+
+ /// Return a `UIMenu` representing the actions that should be shown for the specified cell.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu will be shown for
+ /// - suggestedActions: The suggested actions for this menu
+ func contextMenu(forElementAt index: Int, cell: UITableViewCell, suggestedActions: [UIMenuElement]) -> UIMenu?
+
+ /// Return a closure that takes no arguments and returns a `UIViewController` that will be used for the preview.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: Th cell the context menu will be shown for
+ func contextMenu(previewForElementAt index: Int, cell: UITableViewCell) -> UIContextMenuContentPreviewProvider?
+
+ /// Called when the interaction begins. Return a `UITargetedPreview` describing the desired highlight preview.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu will be shown for
+ func contextMenu(previewForHighlightingElementAt index: Int, cell: UITableViewCell) -> UITargetedPreview?
+
+ /// Called when the interaction is about to dismiss. Return a `UITargetedPreview` describing the desired dismissal target. The interaction will animate the presented menu to the target. Use this to customize the dismissal animation.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu was shown for
+ func contextMenu(previewForDismissingElementAt index: Int, cell: UITableViewCell) -> UITargetedPreview?
+
+ /// Called when the interaction is about to "commit" in response to the user tapping the preview.
+ /// - Parameters:
+ /// - index: The index of the element
+ /// - cell: The cell the context menu is being shown for
+ /// - animator: Commit animator. Add animations to this object to run them alongside the commit transition.
+ func contextMenu(willPerformPreviewActionForElementAt index: Int, cell: UITableViewCell, animator: UIContextMenuInteractionCommitAnimating)
+
+}
+
+@available(iOS 13.0, *)
+public extension TableContextMenuHandler {
+ func allowsContextMenu(forElementAt index: Int) -> Bool { return true }
+ func contextMenu(forElementAt index: Int, cell: UITableViewCell, suggestedActions: [UIMenuElement]) -> UIMenu? { return nil }
+ func contextMenu(previewForElementAt index: Int, cell: UITableViewCell) -> UIContextMenuContentPreviewProvider? { return nil }
+ func contextMenu(previewForHighlightingElementAt index: Int, cell: UITableViewCell) -> UITargetedPreview? { return nil }
+ func contextMenu(previewForDismissingElementAt index: Int, cell: UITableViewCell) -> UITargetedPreview? { return nil }
+ func contextMenu(willPerformPreviewActionForElementAt index: Int, cell: UITableViewCell, animator: UIContextMenuInteractionCommitAnimating) { }
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableDropHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableDropHandler.swift
new file mode 100644
index 0000000..fa70653
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableDropHandler.swift
@@ -0,0 +1,20 @@
+import UIKit
+
+/// Provides drag and drop handling for `UITableView`'s
+public protocol TableDropHandler: TableSectionProvider {
+
+ /// Called when the position of the dragged data over the view has changed. While the user is dragging content, the view calls this method repeatedly to determine how you would handle the drop if it occurred at the specified location. The view provides visual feedback to the user based on your proposal.
+ /// - Parameters:
+ /// - session: The drop session object containing information about the type of data being dragged.
+ /// - destinationIndex: The index at which the content would be dropped.
+ func dropSessionDidUpdate(_ session: UIDropSession, destinationIndex: Int?) -> UITableViewDropProposal
+
+ /// Return custom information about how to display the item at the specified location during the drop.
+ /// - Parameter index: The index where the element should be inserted
+ func dropSesion(previewParametersForItemAt index: Int) -> UIDragPreviewParameters?
+
+}
+
+extension TableDropHandler {
+ func dropSesion(_ session: UIDropSession, previewParametersForItemAt index: Int) -> UIDragPreviewParameters? { return nil }
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableEditingHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableEditingHandler.swift
new file mode 100644
index 0000000..13254e5
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableEditingHandler.swift
@@ -0,0 +1,46 @@
+import UIKit
+import Composed
+
+/// Provides edit handling for `UITableView`'s
+public protocol TableEditingHandler: TableSectionProvider, EditingHandler {
+
+ /// Return true if the cell should indent while editing
+ /// - Parameter index: The element index
+ func shouldIndentWhileEditing(at index: Int) -> Bool
+
+ /// Return true if the cell should allow selection while editing
+ /// - Parameter index: The element index
+ func allowsSelectionDuringEditing(at index: Int) -> Bool
+
+ /// Return the editing style for the cell
+ /// - Parameter index: The element index
+ func editingStyle(at index: Int) -> UITableViewCell.EditingStyle
+
+ /// Called when the edit has been comitted
+ /// - Parameters:
+ /// - index: The element index
+ /// - editingStyle: The cell's editing style
+ func commitEditing(at index: Int, editingStyle: UITableViewCell.EditingStyle)
+
+ /// When editing is toggled, this method will be called to notify the section
+ /// - Parameters:
+ /// - editing: True if editing is being enabled, false otherwise
+ /// - index: The element index
+ /// - cell: The cell at the specified index
+ /// - animated: Specifies whether the change is being animated
+ func didSetEditing(_ editing: Bool, at index: Int, cell: UITableViewCell, animated: Bool)
+
+}
+
+public extension TableEditingHandler {
+ func shouldIndentWhileEditing(at index: Int) -> Bool { return true }
+ func allowsSelectionDuringEditing(at index: Int) -> Bool { return false }
+
+ func editingStyle(at index: Int) -> UITableViewCell.EditingStyle { return .none }
+ func commitEditing(at index: Int, editingStyle: UITableViewCell.EditingStyle) { }
+
+ func didSetEditing(_ editing: Bool) { }
+ func didSetEditing(_ editing: Bool, at index: Int, cell: UITableViewCell, animated: Bool) {
+ didSetEditing(editing, at: index)
+ }
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableLayoutHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableLayoutHandler.swift
new file mode 100644
index 0000000..739a5d5
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableLayoutHandler.swift
@@ -0,0 +1,63 @@
+import UIKit
+
+/// Conform to this protocol to provide layout specific details for a `UITableView` and its cells, headers and footers.
+public protocol TableSectionLayoutHandler: TableSectionProvider {
+
+ /// Provide the view with an estimated height for its header.
+ ///
+ /// Providing an estimate can improve the user experience when loading the content. If the view contains variable height headers, it might be expensive to calculate all their heights and so lead to a longer load time. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
+ ///
+ /// - Parameters:
+ /// - suggested: The suggested height will generally be the UIKit default
+ /// - traitCollection: The current trait collection applied to the view
+ func estimatedHeightForHeader(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat
+
+ /// Provide the view with an estimated height for its footer.
+ ///
+ /// Providing an estimate can improve the user experience when loading the content. If the view contains variable height footers, it might be expensive to calculate all their heights and so lead to a longer load time. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
+ ///
+ /// - Parameters:
+ /// - suggested: The suggested height will generally be the UIKit default
+ /// - traitCollection: The current trait collection applied to the view
+ func estimatedHeightForFooter(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat
+
+ /// Provide the view with an estimated height for its cell.
+ ///
+ /// Providing an estimate can improve the user experience when loading the content. If the view contains variable height cells, it might be expensive to calculate all their heights and so lead to a longer load time. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
+ ///
+ /// - Parameters:
+ /// - index: The element index
+ /// - suggested: The suggested height will generally be the UIKit default
+ /// - traitCollection: The current trait collection applied to the view
+ func estimatedHeightForItem(at index: Int, suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat
+
+ /// Provide the view with the height of your header.
+ /// - Parameters:
+ /// - suggested: The suggested height will generally be the UIKit default or an inherited value
+ /// - traitCollection: The current trait collection applied to the view
+ func heightForHeader(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat
+
+ /// Provide the view with the height of your footer.
+ /// - Parameters:
+ /// - suggested: The suggested height will generally be the UIKit default or an inherited value
+ /// - traitCollection: The current trait collection applied to the view
+ func heightForFooter(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat
+
+ /// Provide the view with the height of your footer.
+ /// - Parameters:
+ /// - index: The element index
+ /// - suggested: The suggested height will generally be the UIKit default or an inherited value
+ /// - traitCollection: The current trait collection applied to the view
+ func heightForItem(at index: Int, suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat
+
+}
+
+public extension TableSectionLayoutHandler {
+ func estimatedHeightForHeader(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat { return suggested }
+ func estimatedHeightForFooter(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat { return suggested }
+ func estimatedHeightForItem(at index: Int, suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat { return suggested }
+
+ func heightForHeader(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat { return suggested }
+ func heightForFooter(suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat { return suggested }
+ func heightForItem(at index: Int, suggested: CGFloat, traitCollection: UITraitCollection) -> CGFloat { return suggested }
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableMoveHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableMoveHandler.swift
new file mode 100644
index 0000000..b39cc06
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableMoveHandler.swift
@@ -0,0 +1,27 @@
+import UIKit
+import Composed
+
+// MARK: - Deprecations
+
+@available(*, deprecated, renamed: "MoveHandler")
+public protocol TableMovingHandler: TableSectionProvider {
+
+ /// Return true to allow a move at the specified index
+ /// - Parameter index: The element index
+ @available(*, deprecated, renamed: "canMove(at:)")
+ func allowsMove(at index: Int) -> Bool
+
+ /// When a move occurs, this method will be called to notifiy the section
+ /// - Parameters:
+ /// - source: The source element index
+ /// - destination: The destination element index
+ func didMove(from sourceIndex: Int, to destinationIndex: Int)
+
+ /// When a move is about to occur, this method will be called to give the caller an opportunity to return an alternate target element index
+ /// - Parameters:
+ /// - source: The source element index
+ /// - proposed: The proposed destination element index
+ @available(*, deprecated, renamed: "targetIndex(for:)")
+ func targetIndex(forMoveFrom source: Int, to proposed: Int) -> Int
+
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableSelectionHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableSelectionHandler.swift
new file mode 100644
index 0000000..5f26f0c
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableSelectionHandler.swift
@@ -0,0 +1,29 @@
+import UIKit
+import Composed
+
+/// Provides selection handling for `UITableView`'s
+public protocol TableSelectionHandler: SelectionHandler {
+
+ /// When a selection occurs, this method will be called to notify the section
+ /// - Parameters:
+ /// - index: The element index
+ /// - cell: The cell that was selected
+ func didSelect(at index: Int, cell: UITableViewCell)
+
+ /// When a deselection occurs, this method will be called to notify the section
+ /// - Parameters:
+ /// - index: The element index
+ /// - cell: The cell that was deselected
+ func didDeselect(at index: Int, cell: UITableViewCell)
+
+}
+
+public extension TableSelectionHandler {
+ func didSelect(at index: Int, cell: UITableViewCell) {
+ didSelect(at: index)
+ }
+
+ func didDeselect(at index: Int, cell: UITableViewCell) {
+ didSelect(at: index)
+ }
+}
diff --git a/Sources/ComposedUI/TableView/Handlers/TableUpdateHandler.swift b/Sources/ComposedUI/TableView/Handlers/TableUpdateHandler.swift
new file mode 100644
index 0000000..0ea9c4f
--- /dev/null
+++ b/Sources/ComposedUI/TableView/Handlers/TableUpdateHandler.swift
@@ -0,0 +1,11 @@
+import UIKit
+
+/// Conform your section to this to signal to the Coordinator that you want to handle updates manually for the cells in this section. Your cell configuration closure will be called instead of `reloadItems`
+public protocol TableUpdateHandler: TableSectionProvider {
+
+ /// Return true to signal to the view that it should reload the cell vs re-calling the cell configuration handler on `TableElement`
+ /// - Parameter index: The element index
+ func prefersReload(forElementAt index: Int) -> Bool
+
+}
+
diff --git a/Sources/ComposedUI/TableView/TableCoordinator.swift b/Sources/ComposedUI/TableView/TableCoordinator.swift
new file mode 100644
index 0000000..f238e67
--- /dev/null
+++ b/Sources/ComposedUI/TableView/TableCoordinator.swift
@@ -0,0 +1,741 @@
+import UIKit
+import Composed
+
+/// Conform to this protocol to receive `TableCoordinator` events
+public protocol TableCoordinatorDelegate: class {
+
+ /// Return a background view to be shown in the `UITableView` when its content is empty. Defaults to nil
+ /// - Parameters:
+ /// - coordinator: The coordinator that manages this table view
+ /// - tableView: The table view that will show this background view
+ func coordinator(_ coordinator: TableCoordinator, backgroundViewInTableView tableView: UITableView) -> UIView?
+
+ /// Called whenever the coordinator's content updates
+ /// - Parameter coordinator: The coordinator that manages the updates
+ func coordinatorDidUpdate(_ coordinator: TableCoordinator)
+
+}
+
+/// The coordinator that provides the 'glue' between a section provider and a `UITableView`
+public extension TableCoordinatorDelegate {
+ func coordinator(_ coordinator: TableCoordinator, backgroundViewInTableView tableView: UITableView) -> UIView? { return nil }
+ func coordinatorDidUpdate(_ coordinator: TableCoordinator) { }
+}
+
+open class TableCoordinator: NSObject {
+
+ /// Get/set the delegate for this coordinator
+ public weak var delegate: TableCoordinatorDelegate? {
+ didSet { tableView.backgroundView = delegate?.coordinator(self, backgroundViewInTableView: tableView) }
+ }
+
+ public var sectionProvider: SectionProvider {
+ return mapper.provider
+ }
+
+ private var mapper: SectionProviderMapping
+
+ private var defersUpdate: Bool = false
+ private var sectionRemoves: [() -> Void] = []
+ private var sectionInserts: [() -> Void] = []
+ private var sectionUpdates: [() -> Void] = []
+
+ private var removes: [() -> Void] = []
+ private var inserts: [() -> Void] = []
+ private var changes: [() -> Void] = []
+ private var moves: [() -> Void] = []
+
+ private let tableView: UITableView
+
+ private weak var originalDelegate: UITableViewDelegate?
+ private var delegateObserver: NSKeyValueObservation?
+
+ private weak var originalDataSource: UITableViewDataSource?
+ private var dataSourceObserver: NSKeyValueObservation?
+
+ private var cachedProviders: [TableElementsProvider] = []
+
+ /// Make a new coordinator with the specified tableView and sectionProvider
+ /// - Parameters:
+ /// - tableView: The tableView to associate with this coordinator
+ /// - sectionProvider: The sectionProvider to associate with this coordinator
+ public init(tableView: UITableView, sectionProvider: SectionProvider) {
+ self.tableView = tableView
+ mapper = SectionProviderMapping(provider: sectionProvider)
+
+ super.init()
+
+ tableView.dataSource = self
+ prepareSections()
+
+ delegateObserver = tableView.observe(\.delegate, options: [.initial, .new]) { [weak self] tableView, _ in
+ guard tableView.delegate !== self else { return }
+ self?.originalDelegate = tableView.delegate
+ tableView.delegate = self
+ }
+
+ dataSourceObserver = tableView.observe(\.dataSource, options: [.initial, .new]) { [weak self] tableView, _ in
+ guard tableView.dataSource !== self else { return }
+ self?.originalDataSource = tableView.dataSource
+ tableView.dataSource = self
+ }
+ }
+
+ open func replace(sectionProvider: SectionProvider) {
+ mapper = SectionProviderMapping(provider: sectionProvider)
+ prepareSections()
+ tableView.reloadData()
+ }
+
+ public func setEditing(_ editing: Bool, animated: Bool) {
+ tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: animated) }
+
+ for (index, section) in sectionProvider.sections.enumerated() {
+ guard let handler = section as? EditingHandler else { continue }
+ handler.didSetEditing(editing)
+
+ for item in 0.. [Int] {
+ let indexPaths = tableView.indexPathsForSelectedRows ?? []
+ return indexPaths.filter { $0.section == section }.map { $0.item }
+ }
+
+ public func mapping(_ mapping: SectionProviderMapping, select indexPath: IndexPath) {
+ tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none)
+ }
+
+ public func mapping(_ mapping: SectionProviderMapping, deselect indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: true)
+ }
+
+ public func mapping(_ mapping: SectionProviderMapping, move sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
+ tableView.moveRow(at: sourceIndexPath, to: destinationIndexPath)
+ }
+
+ public func mappingDidInvalidateHeader(at sectionIndex: Int) {
+ // TODO: Reload header
+ }
+
+}
+
+// MARK: - UITableViewDataSource
+
+extension TableCoordinator: UITableViewDataSource {
+
+ public func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.tableView?(tableView, willDisplayHeaderView: view, forSection: section)
+ }
+
+ let elements = elementsProvider(for: section)
+ let s = mapper.provider.sections[section]
+ elements.header?.willAppear(view, section, s)
+ }
+
+ public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
+ guard let header = elementsProvider(for: section).header else {
+ return originalDelegate?.tableView?(tableView, viewForHeaderInSection: section) ?? nil
+ }
+ guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: header.reuseIdentifier) else { return nil }
+ header.configure(view, section, mapper.provider.sections[section])
+ return view
+ }
+
+ public func tableView(_ tableView: UITableView, didEndDisplayingHeaderView view: UIView, forSection section: Int) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.tableView?(tableView, didEndDisplayingHeaderView: view, forSection: section)
+ }
+
+ guard section < sectionProvider.numberOfSections else { return }
+ let elements = elementsProvider(for: section)
+ let s = mapper.provider.sections[section]
+ elements.header?.didDisappear(view, section, s)
+ }
+
+ public func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.tableView?(tableView, willDisplayFooterView: view, forSection: section)
+ }
+
+ let elements = elementsProvider(for: section)
+ let s = mapper.provider.sections[section]
+ elements.footer?.willAppear(view, section, s)
+ }
+
+ public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
+ guard let footer = elementsProvider(for: section).footer else {
+ return originalDelegate?.tableView?(tableView, viewForFooterInSection: section) ?? nil
+ }
+
+ guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: footer.reuseIdentifier) else { return nil }
+ footer.configure(view, section, mapper.provider.sections[section])
+ return view
+ }
+
+ public func tableView(_ tableView: UITableView, didEndDisplayingFooterView view: UIView, forSection section: Int) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.tableView?(tableView, didEndDisplayingFooterView: view, forSection: section)
+ }
+
+ let elements = elementsProvider(for: section)
+ let s = mapper.provider.sections[section]
+ elements.footer?.didDisappear(view, section, s)
+ }
+
+ public func numberOfSections(in tableView: UITableView) -> Int {
+ return mapper.numberOfSections
+ }
+
+ public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ return elementsProvider(for: section).numberOfElements
+ }
+
+ public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.tableView?(tableView, willDisplay: cell, forRowAt: indexPath)
+ }
+
+ let elements = elementsProvider(for: indexPath.section)
+ let section = mapper.provider.sections[indexPath.section]
+ elements.cell.willAppear(cell, indexPath.item, section)
+ }
+
+ public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ let elements = elementsProvider(for: indexPath.section)
+ let cell = tableView.dequeueReusableCell(withIdentifier: elements.cell.reuseIdentifier, for: indexPath)
+
+ if let handler = sectionProvider.sections[indexPath.section] as? EditingHandler {
+ if let handler = sectionProvider.sections[indexPath.section] as? TableEditingHandler {
+ handler.didSetEditing(tableView.isEditing, at: indexPath.item, cell: cell, animated: false)
+ } else {
+ handler.didSetEditing(tableView.isEditing, at: indexPath.item)
+ }
+ }
+
+ elements.cell.configure(cell, indexPath.row, mapper.provider.sections[indexPath.section])
+ return cell
+ }
+
+ public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ assert(Thread.isMainThread)
+ defer {
+ originalDelegate?.tableView?(tableView, didEndDisplaying: cell, forRowAt: 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)
+ }
+
+ private func elementsProvider(for section: Int) -> TableElementsProvider {
+ guard cachedProviders.indices.contains(section) else {
+ fatalError("No UI configuration available for section \(section)")
+ }
+ return cachedProviders[section]
+ }
+
+}
+
+@available(iOS 13.0, *)
+extension TableCoordinator {
+
+ // MARK: - Context Menus
+
+ open func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
+ guard let provider = mapper.provider.sections[indexPath.section] as? TableContextMenuHandler,
+ provider.allowsContextMenu(forElementAt: indexPath.item),
+ let cell = tableView.cellForRow(at: indexPath) else {
+ return originalDelegate?.tableView?(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) ?? 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)
+ }
+ }
+
+ open func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
+ guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else {
+ return originalDelegate?.tableView?(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) ?? nil
+ }
+
+ guard let cell = tableView.cellForRow(at: indexPath),
+ let provider = mapper.provider.sections[indexPath.section] as? TableContextMenuHandler else { return nil }
+ return provider.contextMenu(previewForHighlightingElementAt: indexPath.item, cell: cell)
+ }
+
+ open func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
+ guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else {
+ return originalDelegate?.tableView?(tableView, previewForDismissingContextMenuWithConfiguration: configuration) ?? nil
+ }
+
+ guard let cell = tableView.cellForRow(at: indexPath),
+ let provider = mapper.provider.sections[indexPath.section] as? TableContextMenuHandler else { return nil }
+ return provider.contextMenu(previewForDismissingElementAt: indexPath.item, cell: cell)
+ }
+
+ open func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
+ defer {
+ originalDelegate?.tableView?(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
+ }
+
+ guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return }
+ guard let cell = tableView.cellForRow(at: indexPath),
+ let provider = mapper.provider.sections[indexPath.section] as? TableContextMenuHandler else { return }
+
+ provider.contextMenu(willPerformPreviewActionForElementAt: indexPath.item, cell: cell, animator: animator)
+ }
+
+}
+
+extension TableCoordinator: UITableViewDelegate {
+
+ // MARK: - Editing
+
+ open func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
+ guard let handler = mapper.provider.sections[indexPath.section] as? TableEditingHandler else {
+ return originalDataSource?.tableView?(tableView, canEditRowAt: indexPath) ?? false
+ }
+ return handler.allowsEditing(at: indexPath.item)
+ }
+
+ open func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
+ guard let handler = mapper.provider.sections[indexPath.section] as? TableEditingHandler else {
+ return originalDelegate?.tableView?(tableView, editingStyleForRowAt: indexPath) ?? .none
+ }
+ return handler.editingStyle(at: indexPath.item)
+ }
+
+ open func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
+ defer {
+ originalDataSource?.tableView?(tableView, commit: editingStyle, forRowAt: indexPath)
+ }
+
+ guard let handler = mapper.provider.sections[indexPath.section] as? TableEditingHandler else { return }
+ handler.commitEditing(at: indexPath.item, editingStyle: editingStyle)
+ }
+
+ open func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
+ defer {
+ originalDelegate?.tableView?(tableView, willBeginEditingRowAt: indexPath)
+ }
+
+ guard let handler = mapper.provider.sections[indexPath.section] as? EditingHandler else { return }
+
+ if let handler = handler as? TableEditingHandler, let cell = tableView.cellForRow(at: indexPath) {
+ handler.didSetEditing(true, at: indexPath.item, cell: cell, animated: true)
+ } else {
+ handler.didSetEditing(true, at: indexPath.item)
+ }
+ }
+
+ open func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
+ defer {
+ originalDelegate?.tableView?(tableView, didEndEditingRowAt: indexPath)
+ }
+
+ guard let indexPath = indexPath, let handler = mapper.provider.sections[indexPath.section] as? EditingHandler else { return }
+
+ if let handler = handler as? TableEditingHandler, let cell = tableView.cellForRow(at: indexPath) {
+ handler.didSetEditing(false, at: indexPath.item, cell: cell, animated: true)
+ } else {
+ handler.didSetEditing(true, at: indexPath.item)
+ }
+ }
+
+ // MARK: - Moving
+
+ public func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
+ guard sourceIndexPath.section == proposedDestinationIndexPath.section else { return sourceIndexPath }
+ let item = (mapper.provider.sections[sourceIndexPath.section] as? MoveHandler)?
+ .targetIndex(sourceIndex: sourceIndexPath.item, proposedIndex: proposedDestinationIndexPath.item)
+ ?? proposedDestinationIndexPath.item
+ return IndexPath(item: item, section: sourceIndexPath.section)
+ }
+
+ public func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
+ return (mapper.provider.sections[indexPath.section] as? MoveHandler)?.canMove(index: indexPath.item) ?? false
+ }
+
+ open func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
+ guard let handler = mapper.provider.sections[indexPath.section] as? TableEditingHandler else {
+ return originalDelegate?.tableView?(tableView, shouldIndentWhileEditingRowAt: indexPath) ?? true
+ }
+
+ return handler.editingStyle(at: indexPath.item) == .none ? false : handler.shouldIndentWhileEditing(at: indexPath.item)
+ }
+
+ open func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
+ defer {
+ originalDataSource?.tableView?(tableView, moveRowAt: sourceIndexPath, to: destinationIndexPath)
+ }
+
+ guard sourceIndexPath.section == destinationIndexPath.section,
+ let handler = mapper.provider.sections[sourceIndexPath.section] as? MoveHandler else {
+ return
+ }
+
+ handler.didMove(sourceIndexes: IndexSet(integer: sourceIndexPath.item), to: destinationIndexPath.item)
+ }
+
+ // MARK: - Selection
+
+ open func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
+ if tableView.isEditing,
+ let handler = mapper.provider.sections[indexPath.section] as? TableEditingHandler,
+ !handler.allowsSelectionDuringEditing(at: indexPath.item) {
+ return originalDelegate?.tableView?(tableView, shouldHighlightRowAt: indexPath) ?? false
+ }
+
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { return false }
+ return handler.shouldHighlight(at: indexPath.item)
+ }
+
+ open func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else {
+ return originalDelegate?.tableView?(tableView, willSelectRowAt: indexPath) ?? nil
+ }
+ return handler.shouldSelect(at: indexPath.item) ? indexPath : nil
+ }
+
+ open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ defer {
+ originalDelegate?.tableView?(tableView, didSelectRowAt: indexPath)
+ }
+
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { return }
+
+ if let tableHandler = handler as? TableSelectionHandler, let cell = tableView.cellForRow(at: indexPath) {
+ tableHandler.didSelect(at: indexPath.item, cell: cell)
+ } else {
+ handler.didSelect(at: indexPath.item)
+ }
+
+ guard tableView.allowsMultipleSelection, !handler.allowsMultipleSelection else { return }
+
+ let indexPaths = mapping(mapper, selectedIndexesIn: indexPath.section)
+ .map { IndexPath(item: $0, section: indexPath.section ) }
+ .filter { $0 != indexPath }
+ indexPaths.forEach { tableView.deselectRow(at: $0, animated: true) }
+ }
+
+ open func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) {
+ guard let handler = mapper.provider.sections[indexPath.section] as? TableAccessoryHandler else { return }
+ handler.didSelectAccessory(at: indexPath.item)
+ }
+
+ open func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? {
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else {
+ return originalDelegate?.tableView?(tableView, willDeselectRowAt: indexPath) ?? nil
+ }
+ return handler.shouldDeselect(at: indexPath.item) ? indexPath : nil
+ }
+
+ open func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
+ defer {
+ originalDelegate?.tableView?(tableView, didDeselectRowAt: indexPath)
+ }
+
+ guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { return }
+ if let tableHandler = handler as? TableSelectionHandler, let cell = tableView.cellForRow(at: indexPath) {
+ tableHandler.didDeselect(at: indexPath.item, cell: cell)
+ } else {
+ handler.didDeselect(at: indexPath.item)
+ }
+ }
+
+ // MARK: - Actions
+
+ public func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
+ guard let handler = mapper.provider.sections[indexPath.section] as? TableActionsHandler else {
+ return originalDelegate?.tableView?(tableView, leadingSwipeActionsConfigurationForRowAt: indexPath) ?? nil
+ }
+ return handler.leadingSwipeActions(at: indexPath.item)
+ }
+
+ public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
+ guard let handler = mapper.provider.sections[indexPath.section] as? TableActionsHandler else {
+ return originalDelegate?.tableView?(tableView, trailingSwipeActionsConfigurationForRowAt: indexPath) ?? nil
+ }
+ return handler.trailingSwipeActions(at: indexPath.item)
+ }
+
+ // MARK: - Metrics
+
+ open func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
+ let suggested = tableView.estimatedSectionHeaderHeight == .zero ? tableView.sectionHeaderHeight : tableView.estimatedSectionHeaderHeight
+ guard let section = sectionProvider.sections[section] as? TableSectionLayoutHandler else { return suggested }
+ let height = section.estimatedHeightForHeader(suggested: suggested, traitCollection: tableView.traitCollection)
+ return height < 0 ? section.heightForHeader(suggested: suggested, traitCollection: tableView.traitCollection) : height
+ }
+
+ open func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
+ let suggested = tableView.estimatedSectionFooterHeight == .zero ? tableView.sectionFooterHeight : tableView.estimatedSectionFooterHeight
+ guard let section = sectionProvider.sections[section] as? TableSectionLayoutHandler else { return suggested }
+ let height = section.estimatedHeightForFooter(suggested: suggested, traitCollection: tableView.traitCollection)
+ return height < 0 ? section.heightForFooter(suggested: suggested, traitCollection: tableView.traitCollection) : height
+ }
+
+ open func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
+ let suggested = tableView.estimatedRowHeight == .zero ? tableView.rowHeight : tableView.estimatedRowHeight
+ guard let section = sectionProvider.sections[indexPath.section] as? TableSectionLayoutHandler else { return suggested }
+ let height = section.estimatedHeightForItem(at: indexPath.item, suggested: suggested, traitCollection: tableView.traitCollection)
+ return height < 0 ? section.heightForItem(at: indexPath.item, suggested: suggested, traitCollection: tableView.traitCollection) : height
+ }
+
+ // MARK: - Forwarding
+
+ open override func responds(to aSelector: Selector!) -> Bool {
+ if super.responds(to: aSelector) { return true }
+ if originalDelegate?.responds(to: aSelector) ?? false { return true }
+ return false
+ }
+
+ open override func forwardingTarget(for aSelector: Selector!) -> Any? {
+ if super.responds(to: aSelector) { return self }
+ return originalDelegate
+ }
+
+}
+
+extension TableCoordinator: UITableViewDropDelegate {
+
+ public func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
+ if destinationIndexPath == nil {
+ return (originalDelegate as? UITableViewDropDelegate)?
+ .tableView?(tableView, dropSessionDidUpdate: session, withDestinationIndexPath: destinationIndexPath)
+ ?? UITableViewDropProposal(operation: .forbidden)
+ }
+
+ guard let indexPath = destinationIndexPath, let section = sectionProvider.sections[indexPath.section] as? TableDropHandler else {
+ return (originalDelegate as? UITableViewDropDelegate)?
+ .tableView?(tableView, dropSessionDidUpdate: session, withDestinationIndexPath: destinationIndexPath)
+ ?? UITableViewDropProposal(operation: .forbidden)
+ }
+
+ return section.dropSessionDidUpdate(session, destinationIndex: indexPath.item)
+ }
+
+ public func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
+ (originalDelegate as? UITableViewDropDelegate)?.tableView(tableView, performDropWith: coordinator)
+ }
+
+ public func tableView(_ tableView: UITableView, dropPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
+ guard let section = sectionProvider.sections[indexPath.section] as? TableDropHandler else {
+ return (originalDelegate as? UITableViewDropDelegate)?.tableView?(tableView, dropPreviewParametersForRowAt: indexPath)
+ }
+ return section.dropSesion(previewParametersForItemAt: indexPath.item)
+ }
+
+}
+
+public extension TableCoordinator {
+
+ /// A convenience initializer that allows creation without a provider
+ /// - Parameters:
+ /// - collectionView: The collectionView associated with this coordinator
+ /// - sections: The sections associated with this coordinator
+ convenience init(tableView: UITableView, sections: Section...) {
+ let provider = ComposedSectionProvider()
+ sections.forEach(provider.append(_:))
+ self.init(tableView: tableView, sectionProvider: provider)
+ }
+
+}
diff --git a/Sources/ComposedUI/TableView/TableElement.swift b/Sources/ComposedUI/TableView/TableElement.swift
new file mode 100644
index 0000000..43a6409
--- /dev/null
+++ b/Sources/ComposedUI/TableView/TableElement.swift
@@ -0,0 +1,73 @@
+import UIKit
+import Composed
+
+public final class TableElement where View: UIView & ReusableCell {
+
+ /// The method to use for registering and dequeueing a view for this element
+ public let dequeueMethod: DequeueMethod
+
+ /// A closure that will be called whenever the elements view needs to be configured
+ public let configure: (UIView, Int, Section) -> Void
+
+ /// The reuseIdentifier to use for this element
+ public let reuseIdentifier: String
+
+ /// The closure that will be called before the elements view appears
+ public let willAppear: (UIView, Int, Section) -> Void
+ /// The closure that will be called after the elements view disappears
+ public let didDisappear: (UIView, Int, Section) -> Void
+
+ /// Makes a new element for representing a 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
+ /// - configure: A closure that will be called whenever the elements view needs to be configured
+ 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
+
+ // swiftlint:disable force_cast
+
+ self.configure = { view, index, section in
+ configure(view as! View, index, section as! Section)
+ }
+
+ willAppear = { _, _, _ in }
+ didDisappear = { _, _, _ in }
+ }
+
+ /// Makes a new element for representing a 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
+ /// - 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: 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
+
+ // 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)
+ }
+ }
+
+}
diff --git a/Sources/ComposedUI/TableView/TableSection.swift b/Sources/ComposedUI/TableView/TableSection.swift
new file mode 100644
index 0000000..dcc555f
--- /dev/null
+++ b/Sources/ComposedUI/TableView/TableSection.swift
@@ -0,0 +1,101 @@
+import UIKit
+import Composed
+
+/// A table view can provide headers and footers via custom views or a simple string, this provides a solution for specifying which option to use
+public enum TableHeaderFooter where View: UITableViewHeaderFooterView {
+ /// A title will be usef for this element
+ case title(String)
+ /// A custom view will be used for this element
+ case element(TableElement)
+}
+
+/// Defines a configuration for a section in a `UITableView`.
+/// The section must contain a cell element, but can also optionally include a header and/or footer element.
+open class TableSection: TableElementsProvider {
+
+ /// The cell configuration element
+ public let cell: TableElement
+
+ /// The header configuration element
+ public let header: TableElement?
+
+ /// The footer configuration element
+ public let footer: TableElement?
+
+ /// The number of elements in this section
+ open var numberOfElements: Int {
+ return section?.numberOfElements ?? 0
+ }
+
+ // The underlying section associated with this section
+ private weak var section: Section?
+
+ /// Makes a new configuration with the specified cell, header and/or footer elements
+ /// - Parameters:
+ /// - section: The section this will be associated with
+ /// - cell: The cell configuration element
+ /// - header: The header configuration element
+ /// - footer: The footer configuration element
+ public init(section: Section,
+ cell: TableElement| ,
+ header: TableHeaderFooter? = nil,
+ footer: TableHeaderFooter | | | | |