├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ └── proposal.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .jazzy.yaml ├── .ruby-version ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── ComposedUI.xcscheme ├── CIDependencies ├── Package.resolved ├── Package.swift └── Sources │ └── CIDependencies │ └── Empty.swift ├── Gemfile ├── Gemfile.lock ├── License.md ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── ComposedUI │ ├── CollectionView │ ├── CollectionCoordinator.swift │ ├── CollectionElement.swift │ ├── CollectionSection.swift │ ├── CollectionSectionProvider.swift │ └── Handlers │ │ ├── CollectionContextMenuHandler.swift │ │ ├── CollectionDragHandler.swift │ │ ├── CollectionDropHandler.swift │ │ ├── CollectionEditingHandler.swift │ │ ├── CollectionSelectionHandler.swift │ │ └── CollectionUpdateHandler.swift │ ├── Common │ ├── IndexPath+Identifier.swift │ ├── Reuse.swift │ └── Types.swift │ ├── StackView │ ├── ComposedSectionView.swift │ ├── ComposedStackView.swift │ ├── ComposedView.swift │ ├── ComposedViewCell.swift │ ├── StackCoordinator.swift │ ├── StackElement.swift │ ├── StackElementsProvider.swift │ └── StackSection.swift │ └── TableView │ ├── Handlers │ ├── TableAccessoryHandler.swift │ ├── TableActionsHandler.swift │ ├── TableContextMenuHandler.swift │ ├── TableDropHandler.swift │ ├── TableEditingHandler.swift │ ├── TableLayoutHandler.swift │ ├── TableMoveHandler.swift │ ├── TableSelectionHandler.swift │ └── TableUpdateHandler.swift │ ├── TableCoordinator.swift │ ├── TableElement.swift │ ├── TableSection.swift │ └── TableSectionProvider.swift ├── Tests └── ComposedUITests │ └── CollectionCoordinator.swift └── composed.png /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Report a bug to help us improve the library 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Describe the bug 11 | 12 | [A clear and concise description of what the bug is] 13 | 14 | # To Reproduce 15 | 16 | [Steps to reproduce the issue] 17 | 18 | # Expected behavior 19 | 20 | [A clear and concise description of what you expected to happen] 21 | 22 | # Environment 23 | 24 | - OS Version: [e.g. iOS 12.4] 25 | - Library Version: [e.g. 1.0.3 26 | - Device: [e.g. iPhone XS] 27 | 28 | # Additional context 29 | 30 | [Add any other context about the problem here] 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/proposal.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Proposal 3 | about: A proposal for an enhancement or change to the library. 4 | title: '' 5 | labels: proposal 6 | assignees: '' 7 | 8 | --- 9 | 10 | # [short title] 11 | 12 | * Author: @[your user name] 13 | * Status: **Proposal ([1.1](https://github.com/composed-swift/ComposedUI/milestone/1.1))** 14 | 15 | ## Introduction 16 | 17 | [introduce the desired outcome] 18 | 19 | ## Motivation 20 | 21 | [explain the motivation and reasoning behind this proposal] 22 | 23 | ## Source compatibility 24 | 25 | [will this require a major version or is it additive] 26 | 27 | ## Alternatives considered 28 | 29 | [what alternative could be considered] 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: "*" 6 | 7 | jobs: 8 | create_release: 9 | name: Create Release 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Fetch tag 16 | run: git fetch --depth=1 origin +${{ github.ref }}:${{ github.ref }} 17 | 18 | - name: Get the release version 19 | id: release_version 20 | run: echo "::set-output name=version::${GITHUB_REF/refs\/tags\//}" 21 | 22 | - name: Get release description 23 | run: | 24 | description="$(git tag -ln --format=$'%(contents:subject)\n\n%(contents:body)' ${{ steps.release_version.outputs.version }})" 25 | # Fix set-output for multiline strings: https://github.community/t/set-output-truncates-multiline-strings/16852 26 | description="${description//'%'/'%25'}" 27 | description="${description//$'\n'/'%0A'}" 28 | description="${description//$'\r'/'%0D'}" 29 | echo "$description" 30 | echo "::set-output name=description::$description" 31 | id: release_description 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | tag_name: ${{ steps.release_version.outputs.version }} 40 | release_name: ${{ steps.release_version.outputs.version }} 41 | body: ${{ steps.release_description.outputs.description }} 42 | prerelease: ${{ startsWith(steps.release_version.outputs.version, '0.') || contains(steps.release_version.outputs.version, '-') }} 43 | 44 | build_docs: 45 | name: Build Docs 46 | runs-on: macos-latest 47 | strategy: 48 | fail-fast: false 49 | matrix: 50 | xcode: ["11.7"] 51 | 52 | steps: 53 | - uses: actions/checkout@v2 54 | 55 | - name: Select Xcode ${{ matrix.xcode }} 56 | run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app 57 | 58 | - name: Setup Ruby 59 | uses: ruby/setup-ruby@v1 60 | 61 | - uses: actions/cache@v2 62 | with: 63 | path: vendor/bundle 64 | key: ${{ runner.os }}-gems-${{ hashFiles('.ruby-version') }}-${{ hashFiles('**/Gemfile.lock') }} 65 | restore-keys: | 66 | ${{ runner.os }}-gems-${{ hashFiles('.ruby-version') }}- 67 | 68 | - name: Bundle install 69 | run: | 70 | bundle config path vendor/bundle 71 | bundle install --jobs 4 --retry 3 72 | 73 | - name: Build docs 74 | run: bundle exec jazzy 75 | 76 | - name: Upload Docs 77 | uses: peaceiris/actions-gh-pages@v3 78 | with: 79 | github_token: ${{ secrets.GITHUB_TOKEN }} 80 | publish_dir: docs 81 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | xcode_tests: 7 | name: ${{ matrix.platform }} Tests (Xcode ${{ matrix.xcode }}) 8 | runs-on: macos-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | xcode: ["11.7", "12"] 13 | platform: ["iOS"] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Select Xcode ${{ matrix.xcode }} 19 | run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app 20 | 21 | - name: Cache SwiftPM 22 | uses: actions/cache@v2 23 | with: 24 | path: CIDependencies/.build 25 | key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-ci-deps-${{ github.workspace }}-${{ hashFiles('CIDependencies/Package.resolved') }} 26 | restore-keys: | 27 | ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-ci-deps-${{ github.workspace }} 28 | 29 | - name: Cache DerivedData 30 | uses: actions/cache@v2 31 | with: 32 | path: ~/Library/Developer/Xcode/DerivedData 33 | key: ${{ runner.os }}-${{ matrix.platform }}_derived_data-xcode_${{ matrix.xcode }} 34 | restore-keys: | 35 | ${{ runner.os }}-${{ matrix.platform }}_derived_data 36 | 37 | - name: Run Tests 38 | run: swift run --configuration release --skip-update --package-path ./CIDependencies/ xcutils test ${{ matrix.platform }} --scheme ComposedUI --enable-code-coverage 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | docs/ 7 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | author: "Composed Swift" 2 | github_url: https://github.com/composed-swift/ComposedUI 3 | undocumented_text: "" 4 | swift_build_tool: xcodebuild 5 | xcodebuild_arguments: [-scheme, ComposedUI, -sdk, iphonesimulator] 6 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.1 2 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /CIDependencies/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 7 | "state": { 8 | "branch": null, 9 | "revision": "223d62adc52d51669ae2ee19bdb8b7d9fd6fcd9c", 10 | "version": "0.0.6" 11 | } 12 | }, 13 | { 14 | "package": "Version", 15 | "repositoryURL": "https://github.com/mxcl/Version.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "200046c93f6d5d78a6d72bfd9c0b27a95e9c0a2b", 19 | "version": "1.2.0" 20 | } 21 | }, 22 | { 23 | "package": "xcutils", 24 | "repositoryURL": "https://github.com/JosephDuffy/xcutils.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "6113a82e2910341f4a7e65ac245796136cf6001c", 28 | "version": "0.1.1" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /CIDependencies/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "CIDependencies", 6 | platforms: [ 7 | .macOS(.v10_10), 8 | ], 9 | dependencies: [ 10 | .package(url: "https://github.com/JosephDuffy/xcutils.git", from: "0.1.0"), 11 | ], 12 | targets: [ 13 | .target(name: "CIDependencies") 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /CIDependencies/Sources/CIDependencies/Empty.swift: -------------------------------------------------------------------------------- 1 | // An empty file to make this a valid target 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "jazzy" 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.2) 5 | activesupport (4.2.11.3) 6 | i18n (~> 0.7) 7 | minitest (~> 5.1) 8 | thread_safe (~> 0.3, >= 0.3.4) 9 | tzinfo (~> 1.1) 10 | algoliasearch (1.27.4) 11 | httpclient (~> 2.8, >= 2.8.3) 12 | json (>= 1.5.1) 13 | atomos (0.1.3) 14 | claide (1.0.3) 15 | cocoapods (1.9.3) 16 | activesupport (>= 4.0.2, < 5) 17 | claide (>= 1.0.2, < 2.0) 18 | cocoapods-core (= 1.9.3) 19 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 20 | cocoapods-downloader (>= 1.2.2, < 2.0) 21 | cocoapods-plugins (>= 1.0.0, < 2.0) 22 | cocoapods-search (>= 1.0.0, < 2.0) 23 | cocoapods-stats (>= 1.0.0, < 2.0) 24 | cocoapods-trunk (>= 1.4.0, < 2.0) 25 | cocoapods-try (>= 1.1.0, < 2.0) 26 | colored2 (~> 3.1) 27 | escape (~> 0.0.4) 28 | fourflusher (>= 2.3.0, < 3.0) 29 | gh_inspector (~> 1.0) 30 | molinillo (~> 0.6.6) 31 | nap (~> 1.0) 32 | ruby-macho (~> 1.4) 33 | xcodeproj (>= 1.14.0, < 2.0) 34 | cocoapods-core (1.9.3) 35 | activesupport (>= 4.0.2, < 6) 36 | algoliasearch (~> 1.0) 37 | concurrent-ruby (~> 1.1) 38 | fuzzy_match (~> 2.0.4) 39 | nap (~> 1.0) 40 | netrc (~> 0.11) 41 | typhoeus (~> 1.0) 42 | cocoapods-deintegrate (1.0.4) 43 | cocoapods-downloader (1.4.0) 44 | cocoapods-plugins (1.0.0) 45 | nap 46 | cocoapods-search (1.0.0) 47 | cocoapods-stats (1.1.0) 48 | cocoapods-trunk (1.5.0) 49 | nap (>= 0.8, < 2.0) 50 | netrc (~> 0.11) 51 | cocoapods-try (1.2.0) 52 | colored2 (3.1.2) 53 | concurrent-ruby (1.1.7) 54 | escape (0.0.4) 55 | ethon (0.12.0) 56 | ffi (>= 1.3.0) 57 | ffi (1.13.1) 58 | fourflusher (2.3.1) 59 | fuzzy_match (2.0.4) 60 | gh_inspector (1.1.3) 61 | httpclient (2.8.3) 62 | i18n (0.9.5) 63 | concurrent-ruby (~> 1.0) 64 | jazzy (0.13.5) 65 | cocoapods (~> 1.5) 66 | mustache (~> 1.1) 67 | open4 68 | redcarpet (~> 3.4) 69 | rouge (>= 2.0.6, < 4.0) 70 | sassc (~> 2.1) 71 | sqlite3 (~> 1.3) 72 | xcinvoke (~> 0.3.0) 73 | json (2.3.1) 74 | liferaft (0.0.6) 75 | minitest (5.14.2) 76 | molinillo (0.6.6) 77 | mustache (1.1.1) 78 | nanaimo (0.3.0) 79 | nap (1.1.0) 80 | netrc (0.11.0) 81 | open4 (1.3.4) 82 | redcarpet (3.5.0) 83 | rouge (3.23.0) 84 | ruby-macho (1.4.0) 85 | sassc (2.4.0) 86 | ffi (~> 1.9) 87 | sqlite3 (1.4.2) 88 | thread_safe (0.3.6) 89 | typhoeus (1.4.0) 90 | ethon (>= 0.9.0) 91 | tzinfo (1.2.7) 92 | thread_safe (~> 0.1) 93 | xcinvoke (0.3.0) 94 | liferaft (~> 0.0.6) 95 | xcodeproj (1.18.0) 96 | CFPropertyList (>= 2.3.3, < 4.0) 97 | atomos (~> 0.1.3) 98 | claide (>= 1.0.2, < 2.0) 99 | colored2 (~> 3.1) 100 | nanaimo (~> 0.3.0) 101 | 102 | PLATFORMS 103 | ruby 104 | 105 | DEPENDENCIES 106 | jazzy 107 | 108 | BUNDLED WITH 109 | 2.1.4 110 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | License 2 | 3 | This package is released under the MIT License, which is copied below. 4 | 5 | Copyright (c) 2020 Shaps Benkau 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Composed", 6 | "repositoryURL": "https://github.com/composed-swift/composed", 7 | "state": { 8 | "branch": null, 9 | "revision": "8dcf1fbbc1a23f7e2b9e0e09f6126f12469e8d80", 10 | "version": "1.0.4" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ComposedUI", 7 | platforms: [ 8 | .iOS(.v11) 9 | ], 10 | products: [ 11 | .library( 12 | name: "ComposedUI", 13 | targets: ["ComposedUI"]), 14 | ], 15 | dependencies: [ 16 | .package(name: "Composed", url: "https://github.com/composed-swift/composed", from: "1.0.0"), 17 | ], 18 | targets: [ 19 | .target( 20 | name: "ComposedUI", 21 | dependencies: ["Composed"]), 22 | .testTarget( 23 | name: "ComposedUITests", 24 | dependencies: ["Composed", "ComposedUI"]), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **ComposedUI** builds upon [`Composed`](http://github.com/composed-swift/composed) by adding user interface features that allow you to power the screens in an application. 4 | 5 | > If you prefer to look at code, there's a demo project here: [ComposedDemo](http://github.com/composed-swift/composed-demo) 6 | 7 | The library is comprised of 4 key types for each view type, as well as various protocols for providing optional functionality. 8 | 9 | `UICollectionView` implementations are prefixed with `Collection` 10 | `UITableView` implementations are prefixed with `Table` 11 | `UIStackView` implementations are prefixed with `Stack` 12 | 13 | For example, a `UICollectionView`'s types are defined as follows: 14 | 15 | **CollectionSectionProvider** 16 | In order for your section to be used in a `UICollectionView`, your section needs to conform to this protocol. It has only 1 requirement, a function that returns a `CollectionSection` 17 | 18 | **CollectionSection** 19 | This type encapsulates 3 `CollectionElement` instances. A cell, as well as optional header and footer elements. 20 | 21 | **CollectionElement** 22 | An element defines how a cell or supplementary view should be registered, dequeued and configured for display. 23 | 24 | **CollectionCoordinator** 25 | The coordinator is responsible for coordinating all of the events between a provider (via its mapping) and its view. Its typically both the `delegate` & `dataSource` of the corresponding view as well as the `updateDelegate` for the root provider. 26 | 27 | ## Getting Started 28 | 29 | Lets define a simple section to hold our contacts: 30 | 31 | ```swift 32 | struct Person { 33 | var kind: String // family or friend 34 | } 35 | 36 | final class ContactsSection: ArraySection { } 37 | ``` 38 | 39 | Now we can extend this so we can show it in a collection view: 40 | 41 | ```swift 42 | extension ContactsSection: CollectionSectionProvider { 43 | 44 | func section(with traitCollection: UITraitCollection) -> CollectionSection { 45 | /* 46 | Notes: 47 | The `dequeueMethod` signals to the coordinator how to register and dequeue the cell 48 | The element is generic to that cell type 49 | */ 50 | let cell = CollectionCellElement(section: self, dequeueMethod: .fromNib(PersonCell.self)) { cell, index, section in 51 | // Since everything is generic, we know both the cell and the element types 52 | cell.prepare(with: element(at: index)) 53 | } 54 | 55 | return CollectionSection(section: self, cell: cell, header: header) 56 | } 57 | 58 | } 59 | ``` 60 | 61 | Finally we need to retain a coordinator on our view controller: 62 | 63 | ```swift 64 | final class ContactsViewController: UICollectionViewController { 65 | 66 | private var coordinator: CollectionCoordinator? 67 | 68 | override func viewDidLoad() { 69 | super.viewDidLoad() 70 | 71 | let contacts = ContactsSection() 72 | // contacts.append(...) 73 | 74 | // this single line is all that's needed to connect a collection view to our provider 75 | coordinator = CollectionCoordinator(collectionView: collectionView, sections: contacts) 76 | } 77 | 78 | } 79 | ``` 80 | 81 | Now if we build and run, our collection view should be populated with our contacts as expected. Simple! 82 | 83 | ## Protocols 84 | 85 | ComposedUI also includes various protocols for enabling opt-in behaviour for your sections. Lets add support for selection events to our section above: 86 | 87 | ```swift 88 | extension ContactsSection: CollectionSelectionHandler { 89 | 90 | func didSelect(at index: Int, cell: UICollectionViewCell) { 91 | print(element(at: index)) 92 | deselect(at: index) 93 | } 94 | 95 | } 96 | ``` 97 | 98 | That's it! Our coordinator already handles selection, so when a selection occurs it uses the indexPath to determine which section the selection occured in, it then attempts to cast that section to the protocol and on success, calls the associated method for us. As you can see this is an extremel powerful approach, yet extremely simple and elegant API that has 2 major benefits: 99 | 100 | 1. You opt-in to the features you want rather than inherit them by default 101 | 2. You can provide your own protocols and use the same infrastructure provided by Composed 102 | 103 | ## Advanced Usage 104 | 105 | So far we've built a relativel simple example that shows a single section. Lets update our view controller above to use a SectionProvider – and make things more interesting. 106 | 107 | ```swift 108 | override func viewDidLoad() { 109 | super.viewDidLoad() 110 | 111 | // ... create our contacts (family and friends) 112 | 113 | let provider = ComposedSectionProvider() 114 | provider.append(family) 115 | provider.append(friends) 116 | 117 | // this single line is all that's needed to connect a collection view to our provider 118 | coordinator = CollectionCoordinator(collectionView: collectionView, provider: provider) 119 | } 120 | ``` 121 | 122 | If we now run our example again, we'll see everything works as it did before, except we now have 2 sections. 123 | 124 | This has a number of benefits already: 125 | 126 | 1. We didn't need to manage indexPaths or section indexes 127 | 2. We were able to reuse our existing section 128 | 3. Our section has no knowledge that its now inside of a larger structure 129 | 130 | Now lets add some custom behaviour depending on the data: 131 | 132 | ```swift 133 | extension ContactsSection: CollectionSelectionHandler { 134 | var allowsMultipleSelection: Bool { return isFamily } 135 | } 136 | ``` 137 | Lets run the project again and we can see that the Family section now allows multiple selection, whereas the Friend section does not. This is another great benefit of using ComposedUI because the Coordinator is able to perform more advanced logic without needing to understand the underlying structure. 138 | -------------------------------------------------------------------------------- /Sources/ComposedUI/CollectionView/CollectionCoordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Composed 3 | 4 | /// Conform to this protocol to receive `CollectionCoordinator` events 5 | public protocol CollectionCoordinatorDelegate: class { 6 | 7 | /// Return a background view to be shown in the `UICollectionView` when its content is empty. Defaults to nil 8 | /// - Parameters: 9 | /// - coordinator: The coordinator that manages this collection view 10 | /// - collectionView: The collection view that will show this background view 11 | func coordinator(_ coordinator: CollectionCoordinator, backgroundViewInCollectionView collectionView: UICollectionView) -> UIView? 12 | 13 | /// Called whenever the coordinator's content updates 14 | /// - Parameter coordinator: The coordinator that manages the updates 15 | func coordinatorDidUpdate(_ coordinator: CollectionCoordinator) 16 | } 17 | 18 | public extension CollectionCoordinatorDelegate { 19 | func coordinator(_ coordinator: CollectionCoordinator, backgroundViewInCollectionView collectionView: UICollectionView) -> UIView? { return nil } 20 | func coordinatorDidUpdate(_ coordinator: CollectionCoordinator) { } 21 | } 22 | 23 | /// The coordinator that provides the 'glue' between a section provider and a `UICollectionView` 24 | open class CollectionCoordinator: NSObject { 25 | 26 | /// Get/set the delegate for this coordinator 27 | public weak var delegate: CollectionCoordinatorDelegate? { 28 | didSet { collectionView.backgroundView = delegate?.coordinator(self, backgroundViewInCollectionView: collectionView) } 29 | } 30 | 31 | /// Returns the root section provider associated with this coordinator 32 | public var sectionProvider: SectionProvider { 33 | return mapper.provider 34 | } 35 | 36 | private var mapper: SectionProviderMapping 37 | 38 | private var defersUpdate: Bool = false 39 | private var sectionRemoves: [() -> Void] = [] 40 | private var sectionInserts: [() -> Void] = [] 41 | private var sectionUpdates: [() -> Void] = [] 42 | 43 | private var removes: [() -> Void] = [] 44 | private var inserts: [() -> Void] = [] 45 | private var changes: [() -> Void] = [] 46 | private var moves: [() -> Void] = [] 47 | 48 | private let collectionView: UICollectionView 49 | 50 | private weak var originalDelegate: UICollectionViewDelegate? 51 | private var delegateObserver: NSKeyValueObservation? 52 | 53 | private weak var originalDataSource: UICollectionViewDataSource? 54 | private var dataSourceObserver: NSKeyValueObservation? 55 | 56 | private weak var originalDragDelegate: UICollectionViewDragDelegate? 57 | private var dragDelegateObserver: NSKeyValueObservation? 58 | 59 | private weak var originalDropDelegate: UICollectionViewDropDelegate? 60 | private var dropDelegateObserver: NSKeyValueObservation? 61 | 62 | private var cachedProviders: [CollectionElementsProvider] = [] 63 | 64 | /// Make a new coordinator with the specified collectionView and sectionProvider 65 | /// - Parameters: 66 | /// - collectionView: The collectionView to associate with this coordinator 67 | /// - sectionProvider: The sectionProvider to associate with this coordinator 68 | public init(collectionView: UICollectionView, sectionProvider: SectionProvider) { 69 | self.collectionView = collectionView 70 | mapper = SectionProviderMapping(provider: sectionProvider) 71 | 72 | super.init() 73 | prepareSections() 74 | 75 | delegateObserver = collectionView.observe(\.delegate, options: [.initial, .new]) { [weak self] collectionView, _ in 76 | guard collectionView.delegate !== self else { return } 77 | self?.originalDelegate = collectionView.delegate 78 | collectionView.delegate = self 79 | } 80 | 81 | dataSourceObserver = collectionView.observe(\.dataSource, options: [.initial, .new]) { [weak self] collectionView, _ in 82 | guard collectionView.dataSource !== self else { return } 83 | self?.originalDataSource = collectionView.dataSource 84 | collectionView.dataSource = self 85 | } 86 | 87 | dragDelegateObserver = collectionView.observe(\.dragDelegate, options: [.initial, .new]) { [weak self] collectionView, _ in 88 | guard collectionView.dragDelegate !== self else { return } 89 | self?.originalDragDelegate = collectionView.dragDelegate 90 | collectionView.dragDelegate = self 91 | } 92 | 93 | dropDelegateObserver = collectionView.observe(\.dropDelegate, options: [.initial, .new]) { [weak self] collectionView, _ in 94 | guard collectionView.dropDelegate !== self else { return } 95 | self?.originalDropDelegate = collectionView.dropDelegate 96 | collectionView.dropDelegate = self 97 | } 98 | 99 | collectionView.register(PlaceholderSupplementaryView.self, 100 | forSupplementaryViewOfKind: PlaceholderSupplementaryView.kind, 101 | withReuseIdentifier: PlaceholderSupplementaryView.reuseIdentifier) 102 | } 103 | 104 | /// Replaces the current sectionProvider with the specified provider 105 | /// - Parameter sectionProvider: The new sectionProvider 106 | open func replace(sectionProvider: SectionProvider) { 107 | mapper = SectionProviderMapping(provider: sectionProvider) 108 | prepareSections() 109 | collectionView.reloadData() 110 | } 111 | 112 | /// Enables / disables editing on this coordinator 113 | /// - Parameters: 114 | /// - editing: True if editing should be enabled, false otherwise 115 | /// - animated: If true, the change should be animated 116 | public func setEditing(_ editing: Bool, animated: Bool) { 117 | collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: animated) } 118 | 119 | for (index, section) in sectionProvider.sections.enumerated() { 120 | guard let handler = section as? EditingHandler else { continue } 121 | handler.didSetEditing(editing) 122 | 123 | for item in 0.. [Int] { 342 | assert(Thread.isMainThread) 343 | let indexPaths = collectionView.indexPathsForSelectedItems ?? [] 344 | return indexPaths.filter { $0.section == section }.map { $0.item } 345 | } 346 | 347 | public func mapping(_ mapping: SectionProviderMapping, select indexPath: IndexPath) { 348 | assert(Thread.isMainThread) 349 | collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) 350 | } 351 | 352 | public func mapping(_ mapping: SectionProviderMapping, deselect indexPath: IndexPath) { 353 | assert(Thread.isMainThread) 354 | collectionView.deselectItem(at: indexPath, animated: true) 355 | } 356 | 357 | public func mapping(_ mapping: SectionProviderMapping, move sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { 358 | collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath) 359 | } 360 | 361 | } 362 | 363 | // MARK: - UICollectionViewDataSource 364 | 365 | extension CollectionCoordinator: UICollectionViewDataSource { 366 | 367 | public func numberOfSections(in collectionView: UICollectionView) -> Int { 368 | return mapper.numberOfSections 369 | } 370 | 371 | public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 372 | return elementsProvider(for: section).numberOfElements 373 | } 374 | 375 | public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 376 | assert(Thread.isMainThread) 377 | defer { 378 | originalDelegate?.collectionView?(collectionView, willDisplay: cell, forItemAt: indexPath) 379 | } 380 | 381 | let elements = elementsProvider(for: indexPath.section) 382 | let section = mapper.provider.sections[indexPath.section] 383 | elements.cell.willAppear(cell, indexPath.item, section) 384 | } 385 | 386 | public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 387 | assert(Thread.isMainThread) 388 | defer { 389 | originalDelegate?.collectionView?(collectionView, didEndDisplaying: cell, forItemAt: indexPath) 390 | } 391 | 392 | guard indexPath.section < sectionProvider.numberOfSections else { return } 393 | let elements = elementsProvider(for: indexPath.section) 394 | let section = mapper.provider.sections[indexPath.section] 395 | elements.cell.didDisappear(cell, indexPath.item, section) 396 | } 397 | 398 | public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 399 | assert(Thread.isMainThread) 400 | let elements = elementsProvider(for: indexPath.section) 401 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: elements.cell.reuseIdentifier, for: indexPath) 402 | 403 | if let handler = sectionProvider.sections[indexPath.section] as? EditingHandler { 404 | if let handler = sectionProvider.sections[indexPath.section] as? CollectionEditingHandler { 405 | handler.didSetEditing(collectionView.isEditing, at: indexPath.item, cell: cell, animated: false) 406 | } else { 407 | handler.didSetEditing(collectionView.isEditing, at: indexPath.item) 408 | } 409 | } 410 | 411 | elements.cell.configure(cell, indexPath.item, mapper.provider.sections[indexPath.section]) 412 | return cell 413 | } 414 | 415 | public func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) { 416 | assert(Thread.isMainThread) 417 | defer { 418 | originalDelegate?.collectionView?(collectionView, willDisplaySupplementaryView: view, forElementKind: elementKind, at: indexPath) 419 | } 420 | 421 | guard indexPath.section > sectionProvider.numberOfSections else { return } 422 | let elements = elementsProvider(for: indexPath.section) 423 | let section = mapper.provider.sections[indexPath.section] 424 | 425 | if let header = elements.header, header.kind.rawValue == elementKind { 426 | elements.header?.willAppear?(view, indexPath.section, section) 427 | } else if let footer = elements.footer, footer.kind.rawValue == elementKind { 428 | elements.footer?.willAppear?(view, indexPath.section, section) 429 | } else { 430 | // the original delegate can handle this 431 | } 432 | } 433 | 434 | public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { 435 | assert(Thread.isMainThread) 436 | let elements = elementsProvider(for: indexPath.section) 437 | let section = mapper.provider.sections[indexPath.section] 438 | 439 | if let header = elements.header, header.kind.rawValue == kind { 440 | let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: header.reuseIdentifier, for: indexPath) 441 | header.configure(view, indexPath.section, section) 442 | return view 443 | } else if let footer = elements.footer, footer.kind.rawValue == kind { 444 | let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: footer.reuseIdentifier, for: indexPath) 445 | footer.configure(view, indexPath.section, section) 446 | return view 447 | } else { 448 | guard let view = originalDataSource?.collectionView?(collectionView, viewForSupplementaryElementOfKind: kind, at: indexPath) else { 449 | // when in production its better to return 'something' to prevent crashing 450 | assertionFailure("Unsupported supplementary kind: \(kind) at indexPath: \(indexPath). Did you forget to register your header or footer?") 451 | return collectionView.dequeue(supplementary: PlaceholderSupplementaryView.self, ofKind: PlaceholderSupplementaryView.kind, for: indexPath) 452 | } 453 | 454 | return view 455 | } 456 | } 457 | 458 | public func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) { 459 | assert(Thread.isMainThread) 460 | defer { 461 | originalDelegate?.collectionView?(collectionView, didEndDisplayingSupplementaryView: view, forElementOfKind: elementKind, at: indexPath) 462 | } 463 | 464 | guard indexPath.section < sectionProvider.numberOfSections else { return } 465 | let elements = elementsProvider(for: indexPath.section) 466 | let section = mapper.provider.sections[indexPath.section] 467 | 468 | if let header = elements.header, header.kind.rawValue == elementKind { 469 | elements.header?.didDisappear?(view, indexPath.section, section) 470 | } else if let footer = elements.footer, footer.kind.rawValue == elementKind { 471 | elements.footer?.didDisappear?(view, indexPath.section, section) 472 | } else { 473 | // the original delegate can handle this 474 | } 475 | } 476 | 477 | private func elementsProvider(for section: Int) -> CollectionElementsProvider { 478 | guard cachedProviders.indices.contains(section) else { 479 | fatalError("No UI configuration available for section \(section)") 480 | } 481 | return cachedProviders[section] 482 | } 483 | 484 | } 485 | 486 | @available(iOS 13.0, *) 487 | extension CollectionCoordinator { 488 | 489 | // MARK: - Context Menus 490 | 491 | public func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { 492 | guard let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler, 493 | provider.allowsContextMenu(forElementAt: indexPath.item), 494 | let cell = collectionView.cellForItem(at: indexPath) else { return nil } 495 | let preview = provider.contextMenu(previewForElementAt: indexPath.item, cell: cell) 496 | return UIContextMenuConfiguration(identifier: indexPath.string, previewProvider: preview) { suggestedElements in 497 | return provider.contextMenu(forElementAt: indexPath.item, cell: cell, suggestedActions: suggestedElements) 498 | } 499 | } 500 | 501 | public func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { 502 | guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return nil } 503 | guard let cell = collectionView.cellForItem(at: indexPath), 504 | let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } 505 | return provider.contextMenu(previewForHighlightingElementAt: indexPath.item, cell: cell) 506 | } 507 | 508 | public func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { 509 | guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return nil } 510 | guard let cell = collectionView.cellForItem(at: indexPath), 511 | let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } 512 | return provider.contextMenu(previewForDismissingElementAt: indexPath.item, cell: cell) 513 | } 514 | 515 | public func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { 516 | guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return } 517 | guard let cell = collectionView.cellForItem(at: indexPath), 518 | let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return } 519 | provider.contextMenu(willPerformPreviewActionForElementAt: indexPath.item, cell: cell, animator: animator) 520 | } 521 | 522 | } 523 | 524 | extension CollectionCoordinator: UICollectionViewDelegate { 525 | 526 | // MARK: - Selection 527 | 528 | open func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { 529 | guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { 530 | return originalDelegate?.collectionView?(collectionView, shouldHighlightItemAt: indexPath) ?? true 531 | } 532 | 533 | return handler.shouldHighlight(at: indexPath.item) 534 | } 535 | 536 | open func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { 537 | guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { 538 | return originalDelegate?.collectionView?(collectionView, shouldSelectItemAt: indexPath) ?? false 539 | } 540 | 541 | return handler.shouldSelect(at: indexPath.item) 542 | } 543 | 544 | open func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 545 | defer { 546 | originalDelegate?.collectionView?(collectionView, didSelectItemAt: indexPath) 547 | } 548 | 549 | guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { return } 550 | if let handler = handler as? CollectionSelectionHandler, let cell = collectionView.cellForItem(at: indexPath) { 551 | handler.didSelect(at: indexPath.item, cell: cell) 552 | } else { 553 | handler.didSelect(at: indexPath.item) 554 | } 555 | 556 | guard collectionView.allowsMultipleSelection, !handler.allowsMultipleSelection else { return } 557 | 558 | let indexPaths = mapping(mapper, selectedIndexesIn: indexPath.section) 559 | .map { IndexPath(item: $0, section: indexPath.section ) } 560 | .filter { $0 != indexPath } 561 | indexPaths.forEach { collectionView.deselectItem(at: $0, animated: true) } 562 | } 563 | 564 | open func scrollViewDidScroll(_ scrollView: UIScrollView) { 565 | originalDelegate?.scrollViewDidScroll?(scrollView) 566 | } 567 | 568 | open func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { 569 | guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { 570 | return originalDelegate?.collectionView?(collectionView, shouldDeselectItemAt: indexPath) ?? true 571 | } 572 | 573 | return handler.shouldDeselect(at: indexPath.item) 574 | } 575 | 576 | open func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { 577 | defer { 578 | originalDelegate?.collectionView?(collectionView, didDeselectItemAt: indexPath) 579 | } 580 | 581 | guard let handler = mapper.provider.sections[indexPath.section] as? SelectionHandler else { return } 582 | if let collectionHandler = handler as? CollectionSelectionHandler, let cell = collectionView.cellForItem(at: indexPath) { 583 | collectionHandler.didDeselect(at: indexPath.item, cell: cell) 584 | } else { 585 | handler.didDeselect(at: indexPath.item) 586 | } 587 | } 588 | 589 | // MARK: - Forwarding 590 | 591 | open override func responds(to aSelector: Selector!) -> Bool { 592 | if super.responds(to: aSelector) { return true } 593 | if originalDelegate?.responds(to: aSelector) ?? false { return true } 594 | return false 595 | } 596 | 597 | open override func forwardingTarget(for aSelector: Selector!) -> Any? { 598 | if super.responds(to: aSelector) { return self } 599 | return originalDelegate 600 | } 601 | 602 | } 603 | 604 | // MARK: - UICollectionViewDragDelegate 605 | 606 | extension CollectionCoordinator: UICollectionViewDragDelegate { 607 | 608 | public func collectionView(_ collectionView: UICollectionView, dragSessionWillBegin session: UIDragSession) { 609 | sectionProvider.sections 610 | .compactMap { $0 as? CollectionDragHandler } 611 | .forEach { $0.dragSessionWillBegin(session) } 612 | 613 | originalDragDelegate?.collectionView?(collectionView, dragSessionWillBegin: session) 614 | } 615 | 616 | public func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) { 617 | sectionProvider.sections 618 | .compactMap { $0 as? CollectionDragHandler } 619 | .forEach { $0.dragSessionDidEnd(session) } 620 | 621 | originalDragDelegate?.collectionView?(collectionView, dragSessionDidEnd: session) 622 | } 623 | 624 | public func collectionView(_ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { 625 | return originalDragDelegate?.collectionView?(collectionView, dragSessionIsRestrictedToDraggingApplication: session) ?? false 626 | } 627 | 628 | public func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { 629 | guard let provider = sectionProvider.sections[indexPath.section] as? CollectionDragHandler else { 630 | return originalDragDelegate?.collectionView(collectionView, itemsForBeginning: session, at: indexPath) ?? [] 631 | } 632 | 633 | session.localContext = indexPath.section 634 | return provider.dragSession(session, dragItemsForBeginning: indexPath.item) 635 | } 636 | 637 | public func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] { 638 | guard let provider = sectionProvider.sections[indexPath.section] as? CollectionDragHandler else { 639 | return originalDragDelegate?.collectionView(collectionView, itemsForBeginning: session, at: indexPath) ?? [] 640 | } 641 | 642 | return provider.dragSession(session, dragItemsForAdding: indexPath.item) 643 | } 644 | 645 | public func collectionView(_ collectionView: UICollectionView, dragSessionAllowsMoveOperation session: UIDragSession) -> Bool { 646 | let sections = sectionProvider.sections.compactMap { $0 as? MoveHandler } 647 | return originalDragDelegate?.collectionView?(collectionView, dragSessionAllowsMoveOperation: session) ?? !sections.isEmpty 648 | } 649 | 650 | } 651 | 652 | // MARK: - UICollectionViewDropDelegate 653 | 654 | extension CollectionCoordinator: UICollectionViewDropDelegate { 655 | 656 | public func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool { 657 | if collectionView.hasActiveDrag { return true } 658 | return originalDropDelegate?.collectionView?(collectionView, canHandle: session) ?? false 659 | } 660 | 661 | public func collectionView(_ collectionView: UICollectionView, dropSessionDidEnter session: UIDropSession) { 662 | if !collectionView.hasActiveDrag { 663 | sectionProvider.sections 664 | .compactMap { $0 as? CollectionDropHandler } 665 | .forEach { $0.dropSessionWillBegin(session) } 666 | } 667 | 668 | originalDropDelegate?.collectionView?(collectionView, dropSessionDidEnter: session) 669 | } 670 | 671 | public func collectionView(_ collectionView: UICollectionView, dropSessionDidExit session: UIDropSession) { 672 | originalDropDelegate?.collectionView?(collectionView, dropSessionDidExit: session) 673 | } 674 | 675 | public func collectionView(_ collectionView: UICollectionView, dropSessionDidEnd session: UIDropSession) { 676 | sectionProvider.sections 677 | .compactMap { $0 as? CollectionDropHandler } 678 | .forEach { $0.dropSessionDidEnd(session) } 679 | 680 | originalDropDelegate?.collectionView?(collectionView, dropSessionDidEnd: session) 681 | } 682 | 683 | public func collectionView(_ collectionView: UICollectionView, dragPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters? { 684 | // this seems to happen sometimes when iOS gets interrupted 685 | guard !indexPath.isEmpty else { return nil } 686 | 687 | guard let section = sectionProvider.sections[indexPath.section] as? CollectionDragHandler, 688 | let cell = collectionView.cellForItem(at: indexPath) else { 689 | return originalDragDelegate?.collectionView?(collectionView, dragPreviewParametersForItemAt: indexPath) 690 | } 691 | 692 | return section.dragSession(previewParametersForElementAt: indexPath.item, cell: cell) 693 | } 694 | 695 | public func collectionView(_ collectionView: UICollectionView, dropPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters? { 696 | guard let section = sectionProvider.sections[indexPath.section] as? CollectionDropHandler, 697 | let cell = collectionView.cellForItem(at: indexPath) else { 698 | return originalDropDelegate? 699 | .collectionView?(collectionView, dropPreviewParametersForItemAt: indexPath) 700 | } 701 | 702 | return section.dropSesion(previewParametersForElementAt: indexPath.item, cell: cell) 703 | } 704 | 705 | public func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { 706 | if let section = session.localDragSession?.localContext as? Int, section != destinationIndexPath?.section { 707 | return UICollectionViewDropProposal(operation: .forbidden) 708 | } 709 | 710 | if collectionView.hasActiveDrag || session.localDragSession != nil { 711 | return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) 712 | } 713 | 714 | if destinationIndexPath == nil { 715 | return originalDropDelegate?.collectionView?(collectionView, dropSessionDidUpdate: session, withDestinationIndexPath: destinationIndexPath) ?? UICollectionViewDropProposal(operation: .forbidden) 716 | } 717 | 718 | guard let indexPath = destinationIndexPath, let section = sectionProvider.sections[indexPath.section] as? CollectionDropHandler else { 719 | return originalDropDelegate? 720 | .collectionView?(collectionView, dropSessionDidUpdate: session, withDestinationIndexPath: destinationIndexPath) 721 | ?? UICollectionViewDropProposal(operation: .forbidden) 722 | } 723 | 724 | return section.dropSessionDidUpdate(session, destinationIndex: indexPath.item) 725 | } 726 | 727 | public func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { 728 | defer { 729 | originalDropDelegate?.collectionView(collectionView, performDropWith: coordinator) 730 | } 731 | 732 | let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0) 733 | 734 | guard coordinator.proposal.operation == .move, 735 | let section = sectionProvider.sections[destinationIndexPath.section] as? MoveHandler else { 736 | return 737 | } 738 | 739 | let item = coordinator.items.lazy 740 | .filter { $0.sourceIndexPath != nil } 741 | .filter { $0.sourceIndexPath?.section == destinationIndexPath.section } 742 | .compactMap { ($0, $0.sourceIndexPath!) } 743 | .first! 744 | 745 | collectionView.performBatchUpdates({ 746 | let indexes = IndexSet(integer: item.1.item) 747 | section.didMove(sourceIndexes: indexes, to: destinationIndexPath.item) 748 | 749 | collectionView.deleteItems(at: [item.1]) 750 | collectionView.insertItems(at: [destinationIndexPath]) 751 | }, completion: nil) 752 | 753 | coordinator.drop(item.0.dragItem, toItemAt: destinationIndexPath) 754 | } 755 | 756 | } 757 | 758 | private final class PlaceholderSupplementaryView: UICollectionReusableView { 759 | override init(frame: CGRect) { 760 | super.init(frame: frame) 761 | widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true 762 | heightAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true 763 | } 764 | 765 | required init?(coder: NSCoder) { 766 | fatalError("init(coder:) has not been implemented") 767 | } 768 | } 769 | 770 | public extension CollectionCoordinator { 771 | 772 | /// A convenience initializer that allows creation without a provider 773 | /// - Parameters: 774 | /// - collectionView: The collectionView associated with this coordinator 775 | /// - sections: The sections associated with this coordinator 776 | convenience init(collectionView: UICollectionView, sections: Section...) { 777 | let provider = ComposedSectionProvider() 778 | sections.forEach(provider.append(_:)) 779 | self.init(collectionView: collectionView, sectionProvider: provider) 780 | } 781 | 782 | } 783 | -------------------------------------------------------------------------------- /Sources/ComposedUI/CollectionView/CollectionElement.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Composed 3 | 4 | /// A `UICollectionView` supports different elementKind's for supplementary view, this provides a solution 5 | /// 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 6 | public enum CollectionElementKind { 7 | /// Either `elementKindSectionHeader` or `elementKindSectionFooter` will be used 8 | case automatic 9 | /// The custom `kind` value will be used 10 | case custom(kind: String) 11 | 12 | internal var rawValue: String { 13 | switch self { 14 | case .automatic: return "automatic" 15 | case let .custom(kind): return kind 16 | } 17 | } 18 | } 19 | 20 | /// Defines an element used by a `CollectionSection` to provide configurations for a cell, header and/or footer. 21 | public protocol CollectionElement { 22 | 23 | /// A typealias for representing a `UICollectionReusableView` 24 | associatedtype View: UICollectionReusableView 25 | 26 | /// The method to use for registering and dequeueing a view for this element 27 | var dequeueMethod: DequeueMethod { get } 28 | 29 | /// A closure that will be called whenever the elements view needs to be configured 30 | var configure: (UICollectionReusableView, Int, Section) -> Void { get } 31 | 32 | /// The reuseIdentifier to use for this element 33 | var reuseIdentifier: String { get } 34 | 35 | } 36 | 37 | /// Defines a cell element to be used by a `CollectionSection` to provide a configuration for a cell 38 | public final class CollectionCellElement: CollectionElement where View: UICollectionViewCell { 39 | 40 | public let dequeueMethod: DequeueMethod 41 | public let configure: (UICollectionReusableView, Int, Section) -> Void 42 | public let reuseIdentifier: String 43 | 44 | /// The closure that will be called before the elements view appears 45 | public let willAppear: (UICollectionReusableView, Int, Section) -> Void 46 | /// The closure that will be called after the elements view disappears 47 | public let didDisappear: (UICollectionReusableView, Int, Section) -> Void 48 | 49 | /// Makes a new element for representing a cell 50 | /// - Parameters: 51 | /// - section: The section where this element's cell will be shown in 52 | /// - dequeueMethod: The method to use for registering and dequeueing a cell for this element 53 | /// - reuseIdentifier: The reuseIdentifier to use for this element 54 | /// - configure: A closure that will be called whenever the elements view needs to be configured 55 | public init
(section: Section, 56 | dequeueMethod: DequeueMethod, 57 | reuseIdentifier: String? = nil, 58 | configure: @escaping (View, Int, Section) -> Void) 59 | where Section: Composed.Section { 60 | self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier 61 | self.dequeueMethod = dequeueMethod 62 | 63 | // swiftlint:disable force_cast 64 | 65 | self.configure = { view, index, section in 66 | configure(view as! View, index, section as! Section) 67 | } 68 | 69 | willAppear = { _, _, _ in } 70 | didDisappear = { _, _, _ in } 71 | } 72 | 73 | /// Makes a new element for representing a cell 74 | /// - Parameters: 75 | /// - section: The section where this element's cell will be shown in 76 | /// - dequeueMethod: The method to use for registering and dequeueing a cell for this element 77 | /// - reuseIdentifier: The reuseIdentifier to use for this element 78 | /// - configure: A closure that will be called whenever the elements view needs to be configured 79 | /// - willAppear: A closure that will be called before the elements view appears 80 | /// - didDisappear: A closure that will be called after the elements view disappears 81 | public init
(section: Section, 82 | dequeueMethod: DequeueMethod, 83 | reuseIdentifier: String? = nil, 84 | configure: @escaping (View, Int, Section) -> Void, 85 | willAppear: ((View, Int, Section) -> Void)? = nil, 86 | didDisappear: ((View, Int, Section) -> Void)? = nil) 87 | where Section: Composed.Section { 88 | self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier 89 | self.dequeueMethod = dequeueMethod 90 | 91 | // swiftlint:disable force_cast 92 | 93 | self.configure = { view, index, section in 94 | configure(view as! View, index, section as! Section) 95 | } 96 | 97 | self.willAppear = { view, index, section in 98 | willAppear?(view as! View, index, section as! Section) 99 | } 100 | 101 | self.didDisappear = { view, index, section in 102 | didDisappear?(view as! View, index, section as! Section) 103 | } 104 | } 105 | 106 | } 107 | 108 | /// Defines a supplementary element to be used by a `CollectionSection` to provide a configuration for a supplementary view 109 | public final class CollectionSupplementaryElement: CollectionElement where View: UICollectionReusableView { 110 | 111 | public let dequeueMethod: DequeueMethod 112 | public let configure: (UICollectionReusableView, Int, Section) -> Void 113 | public let reuseIdentifier: String 114 | 115 | /// The `elementKind` this element represents 116 | public let kind: CollectionElementKind 117 | 118 | /// A closure that will be called before the elements view is appeared 119 | public let willAppear: ((UICollectionReusableView, Int, Section) -> Void)? 120 | /// A closure that will be called after the elements view has disappeared 121 | public let didDisappear: ((UICollectionReusableView, Int, Section) -> Void)? 122 | 123 | /// Makes a new element for representing a supplementary view 124 | /// - Parameters: 125 | /// - section: The section where this element's view will be shown in 126 | /// - dequeueMethod: The method to use for registering and dequeueing a view for this element 127 | /// - reuseIdentifier: The reuseIdentifier to use for this element 128 | /// - kind: The `elementKind` this element represents 129 | /// - configure: A closure that will be called whenever the elements view needs to be configured 130 | public init
(section: Section, 131 | dequeueMethod: DequeueMethod, 132 | reuseIdentifier: String? = nil, 133 | kind: CollectionElementKind = .automatic, 134 | configure: @escaping (View, Int, Section) -> Void) 135 | where Section: Composed.Section { 136 | self.kind = kind 137 | self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier 138 | self.dequeueMethod = dequeueMethod 139 | 140 | self.configure = { view, index, section in 141 | // swiftlint:disable force_cast 142 | configure(view as! View, index, section as! Section) 143 | } 144 | 145 | willAppear = nil 146 | didDisappear = nil 147 | } 148 | 149 | /// Makes a new element for representing a supplementary view 150 | /// - Parameters: 151 | /// - section: The section where this element's view will be shown in 152 | /// - dequeueMethod: The method to use for registering and dequeueing a view for this element 153 | /// - reuseIdentifier: The reuseIdentifier to use for this element 154 | /// - kind: The `elementKind` this element represents 155 | /// - configure: A closure that will be called whenever the elements view needs to be configured 156 | /// - willAppear: A closure that will be called before the elements view appears 157 | /// - didDisappear: A closure that will be called after the elements view disappears 158 | public init
(section: Section, 159 | dequeueMethod: DequeueMethod, 160 | reuseIdentifier: String? = nil, 161 | kind: CollectionElementKind = .automatic, 162 | configure: @escaping (View, Int, Section) -> Void, 163 | willAppear: ((View, Int, Section) -> Void)? = nil, 164 | didDisappear: ((View, Int, Section) -> Void)? = nil) 165 | where Section: Composed.Section { 166 | self.kind = kind 167 | self.reuseIdentifier = reuseIdentifier ?? View.reuseIdentifier 168 | self.dequeueMethod = dequeueMethod 169 | 170 | // swiftlint:disable force_cast 171 | 172 | self.configure = { view, index, section in 173 | configure(view as! View, index, section as! Section) 174 | } 175 | 176 | self.willAppear = { view, index, section in 177 | willAppear?(view as! View, index, section as! Section) 178 | } 179 | 180 | self.didDisappear = { view, index, section in 181 | didDisappear?(view as! View, index, section as! Section) 182 | } 183 | } 184 | 185 | } 186 | -------------------------------------------------------------------------------- /Sources/ComposedUI/CollectionView/CollectionSection.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Composed 3 | 4 | /// Defines a configuration for a section in a `UICollectionView`. 5 | /// The section must contain a cell element, but can also optionally include a header and/or footer element. 6 | open class CollectionSection: CollectionElementsProvider { 7 | 8 | /// The cell configuration element 9 | public let cell: CollectionCellElement 10 | 11 | /// The header configuration element 12 | public let header: CollectionSupplementaryElement? 13 | 14 | /// The footer configuration element 15 | public let footer: CollectionSupplementaryElement? 16 | 17 | /// The number of elements in this section 18 | open var numberOfElements: Int { 19 | return section?.numberOfElements ?? 0 20 | } 21 | 22 | // The underlying section associated with this section 23 | private weak var section: Section? 24 | 25 | /// Makes a new configuration with the specified cell, header and/or footer elements 26 | /// - Parameters: 27 | /// - section: The section this will be associated with 28 | /// - cell: The cell configuration element 29 | /// - header: The header configuration element 30 | /// - footer: The footer configuration element 31 | public init(section: Section, 32 | cell: CollectionCellElement, 33 | header: CollectionSupplementaryElement
? = nil, 34 | footer: CollectionSupplementaryElement