├── .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 |