├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── CollectionView │ ├── CollectionView+Coordinator.swift │ ├── CollectionView+Initializers.swift │ ├── CollectionView+ViewModifiers.swift │ ├── CollectionView.swift │ ├── Extensions │ │ ├── ListLayoutAppearance+DefaultBackgroundColor.swift │ │ └── OrderedDictionary+IndexPath.swift │ ├── ResultBuilders │ │ ├── Builder Inits.swift │ │ └── BuilderTesting.swift │ └── Testing │ │ ├── HostingInitTest.swift │ │ ├── Initializer Testing.swift │ │ ├── ListInitTest.swift │ │ └── StandardInitMultipleSelectTest.swift └── PrivacyInfo.xcprivacy └── Tests └── CollectionViewTests └── CollectionViewTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | Package.resolved 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builder: 3 | configs: 4 | - documentation_targets: [CollectionView] 5 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Edon Valdman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CollectionView", 8 | platforms: [ 9 | .iOS(.v14), 10 | .macCatalyst(.v14), 11 | .tvOS(.v14), 12 | .visionOS(.v1) 13 | ], 14 | products: [ 15 | // Products define the executables and libraries a package produces, making them visible to other packages. 16 | .library( 17 | name: "CollectionView", 18 | targets: ["CollectionView"]) 19 | ], 20 | dependencies: [ 21 | .package( 22 | url: "https://github.com/apple/swift-collections.git", 23 | .upToNextMajor(from: "1.0.0") 24 | ), 25 | .package( 26 | url: "https://github.com/edonv/CompositionalLayoutBuilder.git", 27 | .upToNextMajor(from: "0.1.0") 28 | ) 29 | ], 30 | targets: [ 31 | // Targets are the basic building blocks of a package, defining a module or a test suite. 32 | // Targets can depend on other targets in this package and products from dependencies. 33 | .target( 34 | name: "CollectionView", 35 | dependencies: [ 36 | .product(name: "OrderedCollections", package: "swift-collections"), 37 | .product(name: "CompositionalLayoutBuilder", package: "CompositionalLayoutBuilder") 38 | ], 39 | resources: [.copy("../PrivacyInfo.xcprivacy")] 40 | ), 41 | .testTarget( 42 | name: "CollectionViewTests", 43 | dependencies: ["CollectionView"]), 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CollectionView 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fedonv%2FCollectionView%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/edonv/CollectionView) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fedonv%2FCollectionView%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/edonv/CollectionView) 5 | 6 | `CollectionView` is a SwiftUI wrapper of [`UICollectionView`](https://developer.apple.com/documentation/uikit/uicollectionview). 7 | 8 | Have you ever wanted to make an app solely in SwiftUI, but the processing tradeoff of using [`Grid`](https://developer.apple.com/documentation/swiftui/grid), [`Lazy_Stack`](https://developer.apple.com/documentation/swiftui/grouping-data-with-lazy-stack-views), and [`Lazy_Grid`](https://developer.apple.com/documentation/swiftui/layout-fundamentals#dynamically-arranging-views-in-two-dimensions) are too significant? Wish you could stick with SwiftUI but still get the processing power of [`UICollectionView`](https://developer.apple.com/documentation/uikit/uicollectionview)? Then try out CollectionView! 9 | 10 | It’s a SwiftUI wrapper for `UICollectionView` that exposes all [`UICollectionViewDelegate`](https://developer.apple.com/documentation/uikit/uicollectionviewdelegate)/[`UICollectionViewDataSourcePrefetching`](https://developer.apple.com/documentation/uikit/uicollectionviewdatasourceprefetching) delegate functions (via view modifiers). Plus, on iOS 16+, you can utilize [`UIHostingConfiguration`](https://developer.apple.com/documentation/swiftui/uihostingconfiguration) to use SwiftUI views for the cells. 11 | 12 | Plus, by passing your data source as a `Binding`, it updates changes to the view using [`UICollectionViewDiffableDataSource`](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource). This also means it doesn't fully reload the view on every change, but rather, they're reloaded internally by the `UICollectionView`. 13 | 14 | It’s still a work in progress (especially with testing everything + documentation), but please give it a try and send feedback my way! 15 | 16 | ## Usage 17 | 18 | - Make sure to use `.ignoresSafeArea()` if it's meant to be full-screen. 19 | 20 | ## To-Do's 21 | 22 | - [x] Implement `UICellConfigurationState`. 23 | - [ ] Add support for `NSCollectionView`. 24 | - [ ] Make sure `CollectionView` isn't updating itself more than necessary? or is it not because it's a `RepresentableView`? 25 | - [ ] Condense initializers somehow... 26 | - [ ] Add support for section snapshots. 27 | - [ ] Add support for expandable sections. 28 | - [ ] Add support for section headers/footers. 29 | - [ ] Refactor `cellRegistrationHandler` out of primary initializers and replace with a `UICollectionViewDiffableDataSource.CellProvider` closure to allow for more complicated configurations. 30 | - [ ] Finish documenting view modifiers. 31 | - [ ] Work on more concrete example for README/DocC articles. 32 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionView+Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionView+Coordinator.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 9/26/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension CollectionView { 11 | public class Coordinator: NSObject, UICollectionViewDelegate, UICollectionViewDataSourcePrefetching { 12 | var parent: CollectionView 13 | var dataSource: DataSource! 14 | 15 | init(_ parent: CollectionView) { 16 | self.parent = parent 17 | } 18 | 19 | internal func setUpCollectionView(_ collectionView: UICollectionView) { 20 | let cellRegistration = parent.cellRegistration 21 | self.dataSource = .init(collectionView: collectionView) { collectionView, indexPath, item in 22 | collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) 23 | } 24 | } 25 | 26 | // MARK: - UICollectionViewDelegate 27 | 28 | 29 | 30 | // MARK: Managing the selected cells 31 | 32 | public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { 33 | parent.shouldSelectItemHandler?(collectionView, indexPath) ?? true 34 | } 35 | 36 | public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 37 | parent.didSelectItemHandler?(collectionView, indexPath) 38 | guard let item = dataSource.itemIdentifier(for: indexPath) else { return } 39 | parent.selection.insert(item) 40 | } 41 | 42 | public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { 43 | parent.shouldDeselectItemHandler?(collectionView, indexPath) ?? true 44 | } 45 | 46 | public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { 47 | guard let item = dataSource.itemIdentifier(for: indexPath) else { return } 48 | parent.selection.remove(item) 49 | } 50 | 51 | // MARK: Multiple Selection Interaction 52 | 53 | public func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { 54 | parent.shouldBeginMultipleSelectionInteractionHandler?(collectionView, indexPath) ?? false 55 | } 56 | 57 | public func collectionView(_ collectionView: UICollectionView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) { 58 | parent.didBeginMultipleSelectionInteractionHandler?(collectionView, indexPath) 59 | } 60 | 61 | public func collectionViewDidEndMultipleSelectionInteraction(_ collectionView: UICollectionView) { 62 | parent.didEndMultipleSelectionInteractionHandler?() 63 | } 64 | 65 | // MARK: Managing cell highlighting 66 | 67 | public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { 68 | parent.shouldHighlightItemHandler?(collectionView, indexPath) ?? true 69 | } 70 | 71 | public func collectionView(_ collectionView: UICollectionView, didHighlightItemAt indexPath: IndexPath) { 72 | parent.didHighlightItemHandler?(collectionView, indexPath) 73 | } 74 | 75 | public func collectionView(_ collectionView: UICollectionView, didUnhighlightItemAt indexPath: IndexPath) { 76 | parent.didUnhighlightItemHandler?(collectionView, indexPath) 77 | } 78 | 79 | // MARK: Tracking the addition and removal of views 80 | 81 | public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 82 | parent.willDisplayCellHandler?(cell, indexPath) 83 | } 84 | 85 | // public func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) { 86 | // <#code#> 87 | // } 88 | 89 | public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 90 | parent.didEndDisplayingCellHandler?(cell, indexPath) 91 | } 92 | 93 | // public func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) { 94 | // <#code#> 95 | // } 96 | 97 | // MARK: Managing context menus 98 | 99 | public func collectionView(_ collectionView: UICollectionView, willDisplayContextMenu configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { 100 | parent.willDisplayContextMenu?(configuration, animator) 101 | } 102 | 103 | public func collectionView(_ collectionView: UICollectionView, willEndContextMenuInteraction configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { 104 | parent.willEndContextMenuInteraction?(configuration, animator) 105 | } 106 | 107 | public func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { 108 | parent.willPerformPreviewAction?(configuration, animator) 109 | } 110 | 111 | public func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { 112 | parent.contextMenuConfigHandler?(indexPaths, point) 113 | } 114 | 115 | // TODO: implement collectionView(_:contextMenuConfiguration:highlightPreviewForItemAt:) and collectionView(_:contextMenuConfiguration:dismissalPreviewForItemAt:) 116 | 117 | // MARK: - UICollectionViewDataSourcePrefetching 118 | 119 | public func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { 120 | parent.prefetchItemsHandler?(indexPaths) 121 | } 122 | 123 | public func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { 124 | parent.cancelPrefetchingHandler?(indexPaths) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionView+Initializers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionView+Initializers.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 9/26/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: - UIHostingConfiguration 11 | 12 | @available(iOS 16, macCatalyst 16, tvOS 16, visionOS 1, *) 13 | extension CollectionView where Cell == UICollectionViewCell { 14 | // MARK: - SwiftUI Cell, No Background, Multiple Select 15 | 16 | /// Creates a collection view that computes its cells using a SwiftUI view, also allowing users to select multiple items. 17 | /// 18 | /// - Note: If you'd like to allow multiple selection, but don't need to keep track of the selections, use `.constant([])` as input for `selection`. 19 | /// - Parameters: 20 | /// - data: The data for populating the list. 21 | /// - selection: A binding to a set that represents selected items. 22 | /// - layout: The layout object to use for organizing items. 23 | /// - cellContent: A view builder that creates the view for a single cell in the collection view. If using a list layout, it's possible to use [`.swipeActions`](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) on the content of this closure and it'll be bridged automatically. 24 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 25 | public init( 26 | _ data: Binding, 27 | selection: Binding>, 28 | layout: CollectionLayout, 29 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 30 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil 31 | ) where Content: View { 32 | self.init(data, 33 | selection: selection, 34 | layout: layout) { cell, indexPath, item in 35 | cell.configurationUpdateHandler = { cell, state in 36 | cell.contentConfiguration = UIHostingConfiguration { 37 | cellContent(indexPath, state, item) 38 | } 39 | cellConfigurationHandler?(cell, indexPath, state, item) 40 | } 41 | } 42 | } 43 | 44 | // MARK: - SwiftUI Cell, No Background, Single/No Select 45 | 46 | /// Creates a collection view that computes its cells using a SwiftUI view, optionally allowing users to select a single item. 47 | /// 48 | /// - Note: If you'd like to allow single selection, but don't need to keep track of the selection, use `.constant(nil)` as input for `selection`. 49 | /// - Parameters: 50 | /// - data: The data for populating the list. 51 | /// - selection: A binding to a selected value, if provided. Otherwise, no selection will be allowed. 52 | /// - layout: The layout object to use for organizing items. 53 | /// - cellContent: A view builder that creates the view for a single cell in the collection view. If using a list layout, it's possible to use [`.swipeActions`](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) on the content of this closure and it'll be bridged automatically. 54 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 55 | public init( 56 | _ data: Binding, 57 | selection: Binding? = nil, 58 | layout: CollectionLayout, 59 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 60 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil 61 | ) where Content: View { 62 | self.init(data, 63 | selection: selection, 64 | layout: layout) { cell, indexPath, item in 65 | cell.configurationUpdateHandler = { cell, state in 66 | cell.contentConfiguration = UIHostingConfiguration { 67 | cellContent(indexPath, state, item) 68 | } 69 | cellConfigurationHandler?(cell, indexPath, state, item) 70 | } 71 | } 72 | } 73 | 74 | // MARK: - SwiftUI Cell, Custom View Background, Multiple Select 75 | 76 | /// Creates a collection view that computes its cells and their backgrounds using SwiftUI views, also allowing users to select multiple items. 77 | /// 78 | /// - Note: If you'd like to allow multiple selection, but don't need to keep track of the selections, use `.constant([])` as input for `selection`. 79 | /// - Parameters: 80 | /// - data: The data for populating the list. 81 | /// - selection: A binding to a set that represents selected items. 82 | /// - layout: The layout object to use for organizing items. 83 | /// - cellContent: A view builder that creates the view for a single cell in the collection view. If using a list layout, it's possible to use [`.swipeActions`](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) on the content of this closure and it'll be bridged automatically. 84 | /// - cellBackground: The contents of the SwiftUI hierarchy to be shown inside the background of the cell. 85 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 86 | public init( 87 | _ data: Binding, 88 | selection: Binding>, 89 | layout: CollectionLayout, 90 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 91 | @ViewBuilder cellBackground: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Background, 92 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil 93 | ) where Content: View, Background: View { 94 | self.init(data, 95 | selection: selection, 96 | layout: layout) { cell, indexPath, item in 97 | cell.configurationUpdateHandler = { cell, state in 98 | cell.contentConfiguration = UIHostingConfiguration { 99 | cellContent(indexPath, state, item) 100 | } 101 | .background { 102 | cellBackground(indexPath, state, item) 103 | } 104 | cellConfigurationHandler?(cell, indexPath, state, item) 105 | } 106 | } 107 | } 108 | 109 | // MARK: - SwiftUI Cell, Custom View Background, Single/No Select 110 | 111 | /// Creates a collection view that computes its cells and their backgrounds using SwiftUI views, optionally allowing users to select a single item. 112 | /// 113 | /// - Note: If you'd like to allow single selection, but don't need to keep track of the selection, use `.constant(nil)` as input for `selection`. 114 | /// - Parameters: 115 | /// - data: The data for populating the list. 116 | /// - selection: A binding to a selected value, if provided. Otherwise, no selection will be allowed. 117 | /// - layout: The layout object to use for organizing items. 118 | /// - cellContent: A view builder that creates the view for a single cell in the collection view. If using a list layout, it's possible to use [`.swipeActions`](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) on the content of this closure and it'll be bridged automatically. 119 | /// - cellBackground: The contents of the SwiftUI hierarchy to be shown inside the background of the cell. 120 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 121 | public init( 122 | _ data: Binding, 123 | selection: Binding? = nil, 124 | layout: CollectionLayout, 125 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 126 | @ViewBuilder cellBackground: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Background, 127 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil 128 | ) where Content: View, Background: View { 129 | self.init(data, 130 | selection: selection, 131 | layout: layout) { cell, indexPath, item in 132 | cell.configurationUpdateHandler = { cell, state in 133 | cell.contentConfiguration = UIHostingConfiguration { 134 | cellContent(indexPath, state, item) 135 | } 136 | .background { 137 | cellBackground(indexPath, state, item) 138 | } 139 | cellConfigurationHandler?(cell, indexPath, state, item) 140 | } 141 | } 142 | } 143 | 144 | // MARK: - SwiftUI Cell, ShapeStyle Background, Multiple Select 145 | 146 | /// Creates a collection view that computes its cells using SwiftUI views (and their backgrounds from a shape style), also allowing users to select multiple items. 147 | /// 148 | /// - Note: If you'd like to allow multiple selection, but don't need to keep track of the selections, use `.constant([])` as input for `selection`. 149 | /// - Parameters: 150 | /// - data: The data for populating the list. 151 | /// - selection: A binding to a set that represents selected items. 152 | /// - layout: The layout object to use for organizing items. 153 | /// - cellBackground: The shape style to be used as the background of the cell. 154 | /// - cellContent: A view builder that creates the view for a single cell in the collection view. If using a list layout, it's possible to use [`.swipeActions`](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) on the content of this closure and it'll be bridged automatically. 155 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 156 | public init( 157 | _ data: Binding, 158 | selection: Binding>, 159 | layout: CollectionLayout, 160 | cellBackground: S, 161 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 162 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil 163 | ) where Content: View, S: ShapeStyle { 164 | self.init(data, 165 | selection: selection, 166 | layout: layout) { cell, indexPath, item in 167 | cell.configurationUpdateHandler = { cell, state in 168 | cell.contentConfiguration = UIHostingConfiguration { 169 | cellContent(indexPath, state, item) 170 | } 171 | .background(cellBackground) 172 | cellConfigurationHandler?(cell, indexPath, state, item) 173 | } 174 | } 175 | } 176 | 177 | // MARK: - SwiftUI Cell, ShapeStyle Background, Single/No Select 178 | 179 | /// Creates a collection view that computes its cells using SwiftUI views (and their backgrounds from a shape style), optionally allowing users to select a single item. 180 | /// 181 | /// - Note: If you'd like to allow single selection, but don't need to keep track of the selection, use `.constant(nil)` as input for `selection`. 182 | /// - Parameters: 183 | /// - data: The data for populating the list. 184 | /// - selection: A binding to a selected value, if provided. Otherwise, no selection will be allowed. 185 | /// - layout: The layout object to use for organizing items. 186 | /// - cellBackground: The shape style to be used as the background of the cell. 187 | /// - cellContent: A view builder that creates the view for a single cell in the collection view. If using a list layout, it's possible to use [`.swipeActions`](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) on the content of this closure and it'll be bridged automatically. 188 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 189 | public init( 190 | _ data: Binding, 191 | selection: Binding? = nil, 192 | layout: CollectionLayout, 193 | cellBackground: S, 194 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 195 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil 196 | ) where Content: View, S: ShapeStyle { 197 | self.init(data, 198 | selection: selection, 199 | layout: layout) { cell, indexPath, item in 200 | cell.configurationUpdateHandler = { cell, state in 201 | cell.contentConfiguration = UIHostingConfiguration { 202 | cellContent(indexPath, state, item) 203 | } 204 | .background(cellBackground) 205 | cellConfigurationHandler?(cell, indexPath, state, item) 206 | } 207 | } 208 | } 209 | } 210 | 211 | // MARK: - UICollectionLayoutListConfiguration 212 | 213 | extension CollectionView where CollectionLayout == UICollectionViewCompositionalLayout, Cell == UICollectionViewListCell { 214 | 215 | // MARK: - List Layout, List Cell, Multiple Select 216 | 217 | /// Creates a collection view with a list layout that allows users to select multiple items. 218 | /// 219 | /// - Important: The `state` parameter in the closures only take effect if used on iOS 15+. 220 | /// - Note: If you'd like to allow multiple selection, but don't need to keep track of the selections, use `.constant([])` as input for `selection`. 221 | /// - Parameters: 222 | /// - data: The data for populating the list. 223 | /// - selection: A binding to a set that represents selected items. 224 | /// - listAppearance: The overall appearance of the list. 225 | /// - contentConfiguration: A closure for creating a [`UIContentConfiguration`](https://developer.apple.com/documentation/uikit/uicontentconfiguration) for each item's cell. 226 | /// - backgroundConfiguration: An optional closure for creating a [`UIBackgroundConfiguration`](https://developer.apple.com/documentation/uikit/uibackgroundconfiguration) for each item's cell. 227 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 228 | /// - listConfigurationHandler: A closure for configuring the `UICollectionLayoutListConfiguration` of the layout. 229 | public init( 230 | _ data: Binding, 231 | selection: Binding>, 232 | listAppearance: UICollectionLayoutListConfiguration.Appearance, 233 | contentConfiguration: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> UIListContentConfiguration, 234 | backgroundConfiguration: ((IndexPath, _ state: UICellConfigurationState, _ item: Item) -> UIBackgroundConfiguration)?, 235 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil, 236 | listConfigurationHandler: ((_ config: inout UICollectionLayoutListConfiguration) -> Void)? = nil 237 | ) { 238 | var listConfig = UICollectionLayoutListConfiguration(appearance: listAppearance) 239 | listConfig.backgroundColor = .clear 240 | listConfigurationHandler?(&listConfig) 241 | 242 | self.init(data, 243 | selection: selection, 244 | layout: UICollectionViewCompositionalLayout.list(using: listConfig)) { cell, indexPath, item in 245 | if #available(iOS 15.0, *) { 246 | cell.configurationUpdateHandler = { cell, state in 247 | cell.contentConfiguration = contentConfiguration(indexPath, state, item) 248 | cell.backgroundConfiguration = backgroundConfiguration?(indexPath, state, item) 249 | cellConfigurationHandler?(cell as! Cell, indexPath, state, item) 250 | } 251 | } else { 252 | cell.contentConfiguration = contentConfiguration(indexPath, .init(traitCollection: .current), item) 253 | cell.backgroundConfiguration = backgroundConfiguration?(indexPath, .init(traitCollection: .current), item) 254 | cellConfigurationHandler?(cell, indexPath, .init(traitCollection: .current), item) 255 | } 256 | } 257 | 258 | self.collectionViewBackgroundColor = if #available(iOS 15.0, *) { 259 | Color(uiColor: listAppearance.defaultBackgroundColor) 260 | } else { 261 | Color(listAppearance.defaultBackgroundColor) 262 | } 263 | } 264 | 265 | // MARK: - List Layout, List Cell, Single/No Select 266 | 267 | /// Creates a collection view with a list layout that optionally allows users to select a single item. 268 | /// 269 | /// - Important: The `state` parameter in the closures only take effect if used on iOS 15+. 270 | /// - Note: If you'd like to allow single selection, but don't need to keep track of the selection, use `.constant(nil)` as input for `selection`. 271 | /// - Parameters: 272 | /// - data: The data for populating the list. 273 | /// - selection: A binding to a selected value, if provided. Otherwise, no selection will be allowed. 274 | /// - listAppearance: The overall appearance of the list. 275 | /// - contentConfiguration: A closure for creating a [`UIContentConfiguration`](https://developer.apple.com/documentation/uikit/uicontentconfiguration) for each item's cell. 276 | /// - backgroundConfiguration: An optional closure for creating a [`UIBackgroundConfiguration`](https://developer.apple.com/documentation/uikit/uibackgroundconfiguration) for each item's cell. 277 | /// - cellConfigurationHandler: An optional closure for configuring properties of each item's cell. See more here: ``CollectionView/CollectionView/cellConfigurationHandler``. 278 | /// - listConfigurationHandler: A closure for configuring the `UICollectionLayoutListConfiguration` of the layout. 279 | public init( 280 | _ data: Binding, 281 | selection: Binding? = nil, 282 | listAppearance: UICollectionLayoutListConfiguration.Appearance, 283 | contentConfiguration: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> UIListContentConfiguration, 284 | backgroundConfiguration: ((IndexPath, _ state: UICellConfigurationState, _ item: Item) -> UIBackgroundConfiguration)?, 285 | cellConfigurationHandler: ((Cell, IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Void)? = nil, 286 | listConfigurationHandler: ((_ config: inout UICollectionLayoutListConfiguration) -> Void)? = nil 287 | ) { 288 | var listConfig = UICollectionLayoutListConfiguration(appearance: listAppearance) 289 | listConfig.backgroundColor = .clear 290 | listConfigurationHandler?(&listConfig) 291 | 292 | self.init(data, 293 | selection: selection, 294 | layout: UICollectionViewCompositionalLayout.list(using: listConfig)) { cell, indexPath, item in 295 | if #available(iOS 15.0, *) { 296 | cell.configurationUpdateHandler = { cell, state in 297 | cell.contentConfiguration = contentConfiguration(indexPath, state, item) 298 | cell.backgroundConfiguration = backgroundConfiguration?(indexPath, state, item) 299 | cellConfigurationHandler?(cell as! Cell, indexPath, state, item) 300 | } 301 | } else { 302 | cell.contentConfiguration = contentConfiguration(indexPath, .init(traitCollection: .current), item) 303 | cell.backgroundConfiguration = backgroundConfiguration?(indexPath, .init(traitCollection: .current), item) 304 | cellConfigurationHandler?(cell, indexPath, .init(traitCollection: .current), item) 305 | } 306 | } 307 | 308 | self.collectionViewBackgroundColor = if #available(iOS 15.0, *) { 309 | Color(uiColor: listAppearance.defaultBackgroundColor) 310 | } else { 311 | Color(listAppearance.defaultBackgroundColor) 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionView+ViewModifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionView+ViewModifiers.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 9/27/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension CollectionView { 11 | // MARK: - Misc Properties 12 | 13 | /// Sets the view’s background to a color. 14 | /// - Parameter color: An instance of a color that is drawn behind the modified view. 15 | /// - Returns: A view with the specified color drawn behind it. 16 | public func backgroundColor( 17 | _ color: UIColor 18 | ) -> CollectionView { 19 | var new = self 20 | new.collectionViewBackgroundColor = if #available(iOS 15.0, *) { 21 | Color(uiColor: color) 22 | } else { 23 | Color(color) 24 | } 25 | return new 26 | } 27 | 28 | /// Sets the view’s background to a color. 29 | /// - Parameter color: An instance of a color that is drawn behind the modified view. 30 | /// - Returns: A view with the specified color drawn behind it. 31 | public func backgroundColor( 32 | _ color: Color? 33 | ) -> CollectionView { 34 | var new = self 35 | new.collectionViewBackgroundColor = color 36 | return new 37 | } 38 | 39 | /// Sets the view’s background to a style. 40 | /// - Parameter style: An instance of a color that is drawn behind the modified view. 41 | /// - Returns: A view with the specified color drawn behind it. 42 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) 43 | public func backgroundStyle( 44 | _ style: S 45 | ) -> some View where S: ShapeStyle { 46 | self.backgroundView { 47 | Rectangle().fill(BackgroundStyle.background) 48 | } 49 | } 50 | 51 | /// Layers the given view behind this view. 52 | /// - Parameters: 53 | /// - background: The view to draw behind this view. 54 | /// - alignment: The alignment with a default value of [`center`](https://developer.apple.com/documentation/swiftui/alignment/center) that you use to position the background view. 55 | @available(iOS, deprecated: 14, obsoleted: 15) 56 | @available(macOS, deprecated: 11, obsoleted: 12) 57 | @available(macCatalyst, deprecated: 14, obsoleted: 15) 58 | @available(tvOS, deprecated: 14, obsoleted: 15) 59 | @available(watchOS, deprecated: 7, obsoleted: 8) 60 | public func backgroundView( 61 | _ background: Background, 62 | alignment: Alignment = .center 63 | ) -> some View where Background: View { 64 | var new = self 65 | new.collectionViewBackgroundColor = nil 66 | return new.background(background, alignment: alignment) 67 | } 68 | 69 | /// Layers the views that you specify behind this view. 70 | /// - Parameters: 71 | /// - alignment: The alignment that the modifier uses to position the implicit [`ZStack`](https://developer.apple.com/documentation/swiftui/zstack) that groups the background views. The default is [`center`](https://developer.apple.com/documentation/swiftui/alignment/center). 72 | /// - content: A [`ViewBuilder`](https://developer.apple.com/documentation/swiftui/viewbuilder) that you use to declare the views to draw behind this view, stacked in a cascading order from bottom to top. The last view that you list appears at the front of the stack. 73 | /// - Returns: A view that uses the specified content as a background. 74 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) 75 | public func backgroundView( 76 | alignment: Alignment = .center, 77 | @ViewBuilder content: () -> V 78 | ) -> some View where V: View { 79 | var new = self 80 | new.collectionViewBackgroundColor = nil 81 | return new.background(alignment: alignment, content: content) 82 | } 83 | 84 | // MARK: - Delegate Callbacks 85 | 86 | 87 | 88 | // MARK: - Single Selection 89 | 90 | /// Adds an action to perform when a cell is selected. 91 | /// - Parameters: 92 | /// - handler: A closure to run when the cell is selected. 93 | /// - indexPath: The index path of the cell that was selected. 94 | /// - Returns: A view that runs the given action when a cell is selected. 95 | public func onSelect( 96 | _ handler: CollectionViewVoidCallback? 97 | ) -> CollectionView { 98 | var new = self 99 | new.didSelectItemHandler = handler 100 | return new 101 | } 102 | 103 | /// Adds closures for checking if a cell should be selected/deselected. 104 | /// 105 | /// When setting either item to `nil`, all items will be selected/deselected normally. 106 | /// - Parameters: 107 | /// - shouldSelectItem: A closure that should return `true` if the specified item should be selected. 108 | /// - shouldDeselectItem: A closure that should return `true` if the specified item should be deselected. 109 | public func itemSelection( 110 | shouldSelectItem: CollectionViewBoolCallback? = nil, 111 | shouldDeselectItem: CollectionViewBoolCallback? = nil 112 | ) -> CollectionView { 113 | var new = self 114 | new.shouldSelectItemHandler = shouldSelectItem 115 | new.shouldDeselectItemHandler = shouldDeselectItem 116 | return new 117 | } 118 | 119 | /// Sets if cell selection should be allowed. 120 | /// - Parameter allowed: Whether cells should be allowed to be selected. 121 | public func shouldAllowSelection( 122 | _ allowed: Bool 123 | ) -> CollectionView { 124 | var new = self 125 | new.shouldSelectItemHandler = { _, _ in allowed } 126 | return new 127 | } 128 | 129 | // MARK: - Multiple Selection 130 | 131 | public func shouldBeginMultipleSelectionInteraction( 132 | _ handler: CollectionViewBoolCallback? 133 | ) -> CollectionView { 134 | var new = self 135 | new.shouldBeginMultipleSelectionInteractionHandler = handler 136 | return new 137 | } 138 | 139 | public func shouldAllowTwoFingerMultipleSelectionInteraction( 140 | _ allowed: Bool 141 | ) -> CollectionView { 142 | var new = self 143 | new.shouldBeginMultipleSelectionInteractionHandler = { _, _ in allowed } 144 | return new 145 | } 146 | 147 | public func didBeginMultipleSelectionInteraction( 148 | _ handler: CollectionViewVoidCallback? 149 | ) -> CollectionView { 150 | var new = self 151 | new.didBeginMultipleSelectionInteractionHandler = handler 152 | return new 153 | } 154 | 155 | public func didEndMultipleSelectionInteraction( 156 | _ handler: (() -> Void)? 157 | ) -> CollectionView { 158 | var new = self 159 | new.didEndMultipleSelectionInteractionHandler = handler 160 | return new 161 | } 162 | 163 | // MARK: - Highlighting 164 | 165 | public func shouldHighlightItem( 166 | _ handler: CollectionViewBoolCallback? 167 | ) -> CollectionView { 168 | var new = self 169 | new.shouldHighlightItemHandler = handler 170 | return new 171 | } 172 | 173 | public func shouldAllowHighlighting( 174 | _ allowed: Bool 175 | ) -> CollectionView { 176 | var new = self 177 | new.shouldHighlightItemHandler = { _, _ in allowed } 178 | return new 179 | } 180 | 181 | public func didHighlightItem( 182 | _ handler: CollectionViewVoidCallback? 183 | ) -> CollectionView { 184 | var new = self 185 | new.didHighlightItemHandler = handler 186 | return new 187 | } 188 | 189 | public func didUnhighlightItem( 190 | _ handler: CollectionViewVoidCallback? 191 | ) -> CollectionView { 192 | var new = self 193 | new.didUnhighlightItemHandler = handler 194 | return new 195 | } 196 | 197 | // MARK: - Displaying Cells 198 | 199 | public func willDisplayCell( 200 | _ handler: ((_ cell: UICollectionViewCell, _ indexPath: IndexPath) -> Void)? 201 | ) -> CollectionView { 202 | var new = self 203 | new.willDisplayCellHandler = handler 204 | return new 205 | } 206 | 207 | public func didEndDisplayingCell( 208 | _ handler: ((_ cell: UICollectionViewCell, _ indexPath: IndexPath) -> Void)? 209 | ) -> CollectionView { 210 | var new = self 211 | new.didEndDisplayingCellHandler = handler 212 | return new 213 | } 214 | 215 | // MARK: - Context Menu 216 | 217 | public func willDisplayContextMenu( 218 | _ handler: ((_ configuration: UIContextMenuConfiguration, UIContextMenuInteractionAnimating?) -> Void)? 219 | ) -> CollectionView { 220 | var new = self 221 | new.willDisplayContextMenu = handler 222 | return new 223 | } 224 | 225 | public func willEndContextMenuInteraction( 226 | _ handler: ((_ configuration: UIContextMenuConfiguration, UIContextMenuInteractionAnimating?) -> Void)? 227 | ) -> CollectionView { 228 | var new = self 229 | new.willEndContextMenuInteraction = handler 230 | return new 231 | } 232 | 233 | public func onContextMenuPreviewTap( 234 | _ handler: ((_ configuration: UIContextMenuConfiguration, UIContextMenuInteractionAnimating?) -> Void)? 235 | ) -> CollectionView { 236 | var new = self 237 | new.willPerformPreviewAction = handler 238 | return new 239 | } 240 | 241 | public func contextMenu( 242 | menuIdentifier: NSCopying? = nil, 243 | swiftUIProvider previewProvider: @escaping (_ indexPaths: [IndexPath], _ point: CGPoint) -> Preview?, 244 | actionProvider: ((_ indexPaths: [IndexPath], _ point: CGPoint, [UIMenuElement]) -> UIMenu?)? = nil 245 | ) -> CollectionView { 246 | var new = self 247 | new.contextMenuConfigHandler = { indexPaths, point in 248 | UIContextMenuConfiguration( 249 | identifier: menuIdentifier, 250 | previewProvider: { 251 | guard let preview = previewProvider(indexPaths, point) else { return nil } 252 | return UIHostingController(rootView: preview) 253 | }, 254 | actionProvider: { menuElements in 255 | actionProvider?(indexPaths, point, menuElements) 256 | } 257 | ) 258 | } 259 | return new 260 | } 261 | 262 | public func contextMenu( 263 | menuIdentifier: NSCopying? = nil, 264 | uiKitProvider previewProvider: @escaping (_ indexPaths: [IndexPath], _ point: CGPoint) -> UIViewController?, 265 | actionProvider: ((_ indexPaths: [IndexPath], _ point: CGPoint, [UIMenuElement]) -> UIMenu?)? = nil 266 | ) -> CollectionView { 267 | var new = self 268 | new.contextMenuConfigHandler = { indexPaths, point in 269 | UIContextMenuConfiguration( 270 | identifier: menuIdentifier, 271 | previewProvider: { 272 | previewProvider(indexPaths, point) 273 | }, 274 | actionProvider: { menuElements in 275 | actionProvider?(indexPaths, point, menuElements) 276 | } 277 | ) 278 | } 279 | return new 280 | } 281 | 282 | // MARK: - Prefetching 283 | 284 | public func prefetching( 285 | prefetchItems: ((_ indexPaths: [IndexPath]) -> Void)? = nil, 286 | cancelPrefetchingItems: ((_ indexPaths: [IndexPath]) -> Void)? = nil 287 | ) -> CollectionView { 288 | var new = self 289 | new.prefetchItemsHandler = prefetchItems 290 | new.cancelPrefetchingHandler = cancelPrefetchingItems 291 | return new 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionView.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 9/25/23. 6 | // 7 | 8 | import SwiftUI 9 | import OrderedCollections 10 | 11 | public struct CollectionView 12 | where Section: Sendable & Hashable, Item: Sendable & Hashable, Cell: UICollectionViewCell, CollectionLayout: UICollectionViewLayout { 13 | 14 | public typealias ItemCollection = OrderedDictionary 15 | public typealias CellRegistration = UICollectionView.CellRegistration 16 | typealias DataSource = UICollectionViewDiffableDataSource 17 | typealias DataSourceSnapshot = NSDiffableDataSourceSnapshot 18 | 19 | private var singleSelection: Bool 20 | private var multipleSelection: Bool 21 | 22 | /// The data for populating the list. 23 | @Binding internal var data: ItemCollection 24 | 25 | /// A binding to a set that represents selected items. 26 | @Binding internal var selection: Set 27 | 28 | /// The layout object to use for organizing items. 29 | internal var layout: CollectionLayout 30 | 31 | internal var cellRegistration: CellRegistration 32 | /// A closure for creating a [`UIContentConfiguration`](https://developer.apple.com/documentation/uikit/uicontentconfiguration) for each item's cell. 33 | // internal var contentConfiguration: (IndexPath, Item) -> ContentConfiguration 34 | 35 | /// An optional closure for creating a [`UIBackgroundConfiguration`](https://developer.apple.com/documentation/uikit/uibackgroundconfiguration) for each item's cell. 36 | // internal var backgroundConfiguration: ((IndexPath, Item) -> UIBackgroundConfiguration)? 37 | /// An optional closure for configuring properties of each item's cell. 38 | /// 39 | /// One possible use can be to set the cell's [`configurationUpdateHandler`] (https://developer.apple.com/documentation/uikit/uicollectionviewcell/3751733-configurationupdatehandler) property. 40 | // public private(set) var cellConfigurationHandler: ((Cell, IndexPath, Item) -> Void)? 41 | 42 | // MARK: - Standard Init, Multiple Select 43 | 44 | /// Creates a collection view that allows users to select multiple items. 45 | /// 46 | /// If you'd like to allow multiple selection, but don't need to keep track of the selections, use `.constant([])` as input for `selection`. 47 | /// - Parameters: 48 | /// - data: The data for populating the list. 49 | /// - selection: A binding to a set that represents selected items. 50 | /// - layout: The layout object to use for organizing items. 51 | /// - cellType: A subclass of `UICollectionViewCell` that the collection view should use. It defaults to `UICollectionViewCell`. 52 | /// - cellRegistrationHandler: A closure that handles the cell registration and configuration. 53 | public init( 54 | _ data: Binding, 55 | selection: Binding>, 56 | layout: CollectionLayout, 57 | cellType: Cell.Type = UICollectionViewCell.self, 58 | cellRegistrationHandler: @escaping CellRegistration.Handler 59 | ) { 60 | self._data = data 61 | self._selection = selection 62 | self.singleSelection = true 63 | self.multipleSelection = true 64 | self.layout = layout 65 | self.cellRegistration = .init(handler: cellRegistrationHandler) 66 | } 67 | 68 | // MARK: - Standard Init, Single/No Select 69 | 70 | /// Creates a collection view that optionally allows users to select a single item. 71 | /// 72 | /// If you'd like to allow single selection, but don't need to keep track of the selection, use `.constant(nil)` as input for `selection`. 73 | /// - Parameters: 74 | /// - data: The data for populating the list. 75 | /// - selection: A binding to a selected value, if provided. Otherwise, no selection will be allowed. 76 | /// - layout: The layout object to use for organizing items. 77 | /// - cellType: A subclass of `UICollectionViewCell` that the collection view should use. It defaults to `UICollectionViewCell`. 78 | /// - cellRegistrationHandler: A closure that handles the cell registration and configuration. 79 | public init( 80 | _ data: Binding, 81 | selection: Binding? = nil, 82 | layout: CollectionLayout, 83 | cellType: Cell.Type = UICollectionViewCell.self, 84 | cellRegistrationHandler: @escaping CellRegistration.Handler 85 | ) { 86 | self._data = data 87 | 88 | if let selection { 89 | self._selection = .init(get: { 90 | if let item = selection.wrappedValue { [item] } else { [] } 91 | }, set: { selectedItems in 92 | selection.wrappedValue = selectedItems.first 93 | }) 94 | } else { 95 | self._selection = .constant([]) 96 | } 97 | 98 | self.singleSelection = selection != nil 99 | self.multipleSelection = false 100 | self.layout = layout 101 | self.cellRegistration = .init(handler: cellRegistrationHandler) 102 | } 103 | 104 | // MARK: - View Modifier Properties 105 | 106 | // MARK: Misc Properties 107 | 108 | internal var collectionViewBackgroundColor: Color? = nil 109 | 110 | // MARK: Callback Properties 111 | 112 | public typealias CollectionViewBoolCallback = (UICollectionView, _ indexPath: IndexPath) -> Bool 113 | public typealias CollectionViewVoidCallback = (UICollectionView, _ indexPath: IndexPath) -> Void 114 | 115 | // Single Selection 116 | 117 | internal var didSelectItemHandler: CollectionViewVoidCallback? = nil 118 | internal var shouldSelectItemHandler: CollectionViewBoolCallback? = nil 119 | internal var shouldDeselectItemHandler: CollectionViewBoolCallback? = nil 120 | 121 | // Multiple Selection 122 | 123 | internal var shouldBeginMultipleSelectionInteractionHandler: CollectionViewBoolCallback? = nil 124 | internal var didBeginMultipleSelectionInteractionHandler: CollectionViewVoidCallback? = nil 125 | internal var didEndMultipleSelectionInteractionHandler: (() -> Void)? = nil 126 | 127 | // Highlighting 128 | 129 | internal var shouldHighlightItemHandler: CollectionViewBoolCallback? = nil 130 | internal var didHighlightItemHandler: CollectionViewVoidCallback? = nil 131 | internal var didUnhighlightItemHandler: CollectionViewVoidCallback? = nil 132 | 133 | // Displaying Cells 134 | 135 | internal var willDisplayCellHandler: ((_ cell: UICollectionViewCell, _ indexPath: IndexPath) -> Void)? = nil 136 | internal var didEndDisplayingCellHandler: ((_ cell: UICollectionViewCell, _ indexPath: IndexPath) -> Void)? = nil 137 | 138 | // Context Menu 139 | 140 | internal var willDisplayContextMenu: ((_ configuration: UIContextMenuConfiguration, UIContextMenuInteractionAnimating?) -> Void)? = nil 141 | internal var willEndContextMenuInteraction: ((_ configuration: UIContextMenuConfiguration, UIContextMenuInteractionAnimating?) -> Void)? = nil 142 | internal var willPerformPreviewAction: ((_ configuration: UIContextMenuConfiguration, UIContextMenuInteractionAnimating?) -> Void)? = nil 143 | internal var contextMenuConfigHandler: ((_ indexPaths: [IndexPath], _ point: CGPoint) -> UIContextMenuConfiguration?)? = nil 144 | 145 | // Prefetch 146 | 147 | internal var prefetchItemsHandler: ((_ indexPaths: [IndexPath]) -> Void)? = nil 148 | internal var cancelPrefetchingHandler: ((_ indexPaths: [IndexPath]) -> Void)? = nil 149 | } 150 | 151 | // MARK: - Convenience Functions 152 | 153 | extension CollectionView { 154 | private func uiColor(from color: Color?, in context: Context) -> UIColor? { 155 | guard let color else { return nil } 156 | if #available(iOS 17.0, *) { 157 | return UIColor(cgColor: color.resolve(in: context.environment).cgColor) 158 | } else { 159 | return if let cgColor = color.cgColor { 160 | UIColor(cgColor: cgColor) 161 | } else { 162 | nil 163 | } 164 | } 165 | } 166 | } 167 | 168 | // MARK: - UIViewRepresentable 169 | 170 | extension CollectionView: UIViewRepresentable { 171 | public func makeUIView(context: Context) -> UICollectionView { 172 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) 173 | 174 | context.coordinator.setUpCollectionView(collectionView) 175 | 176 | collectionView.delegate = context.coordinator 177 | collectionView.prefetchDataSource = context.coordinator 178 | collectionView.allowsSelection = singleSelection 179 | collectionView.allowsMultipleSelection = multipleSelection 180 | 181 | return collectionView 182 | } 183 | 184 | public func updateUIView(_ uiView: UICollectionView, context: Context) { 185 | // Updates background color 186 | if uiView.backgroundColor != uiColor(from: collectionViewBackgroundColor, in: context) { 187 | uiView.backgroundColor = uiColor(from: collectionViewBackgroundColor, in: context) 188 | } 189 | 190 | // Check if this calls after initial loading 191 | updateDataSource(context.coordinator) 192 | 193 | // Update selected cells (if selection is enabled) 194 | if singleSelection || multipleSelection { 195 | let newSelectedIndexPaths = Set(selection 196 | .compactMap { context.coordinator.dataSource.indexPath(for: $0) }) 197 | let currentlySelectedIndexPaths = Set(uiView.indexPathsForSelectedItems ?? []) 198 | 199 | if newSelectedIndexPaths != currentlySelectedIndexPaths { 200 | let removed = currentlySelectedIndexPaths.subtracting(newSelectedIndexPaths) 201 | removed.forEach { 202 | uiView.deselectItem(at: $0, animated: true) 203 | } 204 | 205 | let added = newSelectedIndexPaths.subtracting(currentlySelectedIndexPaths) 206 | added.forEach { 207 | uiView.selectItem(at: $0, animated: true, scrollPosition: .centeredVertically) 208 | } 209 | } 210 | } 211 | } 212 | 213 | private func updateDataSource(_ coordinator: Coordinator) { 214 | if let dataSource = coordinator.dataSource { 215 | var snapshot: DataSourceSnapshot = .init() 216 | 217 | snapshot.appendSections(Array(data.keys)) 218 | for (section, items) in data { 219 | snapshot.appendItems(items, toSection: section) 220 | } 221 | 222 | // Animate if there were already items added. 223 | dataSource.apply(snapshot, animatingDifferences: !dataSource.snapshot().itemIdentifiers.isEmpty) 224 | } 225 | } 226 | 227 | public func makeCoordinator() -> Coordinator { 228 | Coordinator(self) 229 | } 230 | } 231 | 232 | private struct TestView: View { 233 | enum Sections: Hashable { 234 | case main 235 | } 236 | 237 | @State 238 | var items: OrderedDictionary = [Sections.main: ["String 1", "String 2", "String 3", "String 4"]] 239 | 240 | var body: some View { 241 | NavigationView { 242 | CollectionView( 243 | $items, 244 | selection: .constant([]), 245 | listAppearance: .sidebar) { indexPath, state, string in 246 | if indexPath.item > 0 { 247 | var config = UIListContentConfiguration.sidebarCell() 248 | config.image = UIImage(systemName: "speaker.wave.3.fill") 249 | config.imageProperties.cornerRadius = 40 250 | config.text = string 251 | config.secondaryText = string 252 | return config 253 | } else { 254 | var config = UIListContentConfiguration.sidebarHeader() 255 | config.text = string 256 | return config 257 | } 258 | } backgroundConfiguration: { indexPath, state, _ in 259 | if indexPath.item > 0 { 260 | .listSidebarCell() 261 | } else { 262 | .listGroupedHeaderFooter() 263 | } 264 | } cellConfigurationHandler: { cell, _, state, _ in 265 | 266 | } listConfigurationHandler: { config in 267 | config.headerMode = .firstItemInSection 268 | // config.backgroundColor = .systemGroupedBackground 269 | } 270 | // .backgroundColor(.systemRed) 271 | .onSelect { _, indexPath in 272 | print(indexPath) 273 | } 274 | .ignoresSafeArea() 275 | .navigationTitle("Test") 276 | .toolbar { 277 | ToolbarItem(placement: .primaryAction) { 278 | Button("Test") { 279 | items[.main]?.append("Test \(items[.main]?.count ?? 0)") 280 | } 281 | } 282 | } 283 | } 284 | } 285 | } 286 | 287 | #Preview("SwiftUI") { 288 | TestView() 289 | } 290 | -------------------------------------------------------------------------------- /Sources/CollectionView/Extensions/ListLayoutAppearance+DefaultBackgroundColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListLayoutAppearance+DefaultBackgroundColor.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 10/2/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UICollectionLayoutListConfiguration.Appearance { 11 | var defaultBackgroundColor: UIColor { 12 | switch self { 13 | case .plain, .sidebarPlain: 14 | .systemBackground 15 | case .grouped, .insetGrouped, .sidebar: 16 | .systemGroupedBackground 17 | @unknown default: 18 | .systemBackground 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/CollectionView/Extensions/OrderedDictionary+IndexPath.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrderedDictionary+IndexPath.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 9/28/23. 6 | // 7 | 8 | import Foundation 9 | import OrderedCollections 10 | 11 | extension OrderedDictionary where Value: RandomAccessCollection, Value.Index == Int { 12 | public subscript(_ indexPath: IndexPath) -> Value.Element? { 13 | guard let sectionKey = key(for: indexPath.section), 14 | let value = self[sectionKey], 15 | value.count > indexPath.item else { return nil } 16 | return value[indexPath.item] 17 | } 18 | } 19 | 20 | extension OrderedDictionary { 21 | public func key(for index: Int) -> Key? { 22 | guard keys.count > index else { return nil } 23 | return keys[index] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/CollectionView/ResultBuilders/Builder Inits.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder Inits.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 2/24/24. 6 | // 7 | 8 | import SwiftUI 9 | import CompositionalLayoutBuilder 10 | 11 | public typealias CollectionViewLayoutHandler = () -> CompositionalLayout 12 | 13 | @available(iOS 16, macCatalyst 16, tvOS 16, visionOS 1, *) 14 | extension CollectionView where Cell == UICollectionViewCell, CollectionLayout == CompositionalLayout.Layout { 15 | public init( 16 | _ data: Binding, 17 | selection: Binding>, 18 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 19 | @CompositionalLayoutBuilder 20 | layout: @escaping CollectionViewLayoutHandler 21 | ) where Content: View { 22 | self.init( 23 | data, 24 | selection: selection, 25 | layout: layout().layout, 26 | cellContent: cellContent 27 | ) 28 | } 29 | 30 | public init( 31 | _ data: Binding, 32 | selection: Binding? = nil, 33 | @ViewBuilder cellContent: @escaping (IndexPath, _ state: UICellConfigurationState, _ item: Item) -> Content, 34 | @CompositionalLayoutBuilder 35 | layout: @escaping CollectionViewLayoutHandler 36 | ) where Content: View { 37 | self.init( 38 | data, 39 | selection: selection, 40 | layout: layout().layout, 41 | cellContent: cellContent 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CollectionView/ResultBuilders/BuilderTesting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuilderTesting.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 2/24/24. 6 | // 7 | 8 | import SwiftUI 9 | import CompositionalLayoutBuilder 10 | import OrderedCollections 11 | 12 | #if os(iOS) 13 | @available(iOS 16, *) 14 | private struct LayoutBuilderTest: View { 15 | @State 16 | var items: OrderedDictionary = .dummyData 17 | 18 | @State var selection: Testing.Item? = nil 19 | 20 | var body: some View { 21 | CollectionView($items, selection: $selection) { indexPath, state, item in 22 | VStack { 23 | Image(systemName: item.systemImageName) 24 | Text(item.title) 25 | Text(item.subtitle) 26 | } 27 | .font(.body) 28 | .foregroundStyle(state.isSelected ? .blue : .primary) 29 | .disabled(indexPath.item == 0) 30 | .swipeActions { 31 | Button(role: .destructive) { 32 | print("Test") 33 | } label: { 34 | Label("", systemImage: "trash") 35 | } 36 | Button(role: .destructive) { 37 | print("Test") 38 | } label: { 39 | Label("", systemImage: "trash") 40 | } 41 | .tint(.blue) 42 | } 43 | .swipeActions(edge: .leading) { 44 | Button(role: .destructive) { 45 | print("Test") 46 | } label: { 47 | Label("", systemImage: "trash") 48 | } 49 | Button(role: .destructive) { 50 | print("Test") 51 | } label: { 52 | Label("", systemImage: "trash") 53 | } 54 | .tint(.blue) 55 | } 56 | .padding() 57 | .background(.yellow) 58 | } layout: { 59 | CompositionalSection { 60 | CompositionalGroup(.horizontal, width: .fractionalWidth(1), height: .absolute(200)) { 61 | CompositionalGroup(.vertical, width: .fractionalWidth(0.75), height: .fractionalHeight(1)) { 62 | CompositionalItem(width: .fractionalWidth(1), height: .fractionalHeight(0.5)) 63 | .repeating(count: 2) 64 | } 65 | 66 | CompositionalItem(width: .fractionalWidth(0.25), height: .fractionalHeight(1)) 67 | } 68 | } 69 | 70 | CompositionalSection { 71 | CompositionalGroup(.horizontal, width: .fractionalWidth(1), height: .absolute(100)) { 72 | CompositionalItem(width: .fractionalWidth(0.5), height: .fractionalHeight(1)) 73 | } 74 | } 75 | .orthogonalScrollingBehavior(.continuous) 76 | } 77 | // .backgroundStyle(.green) 78 | } 79 | } 80 | 81 | @available(iOS 16, *) 82 | #Preview { 83 | LayoutBuilderTest() 84 | } 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/CollectionView/Testing/HostingInitTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HostingInitTest.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 10/1/23. 6 | // 7 | 8 | import SwiftUI 9 | import OrderedCollections 10 | 11 | @available(iOS 16, *) 12 | struct HostingInitTest: View { 13 | @State 14 | var items: OrderedDictionary = .dummyData 15 | 16 | @State var selection: Testing.Item? = nil 17 | 18 | var body: some View { 19 | CollectionView( 20 | $items, 21 | selection: $selection, 22 | layout: compositionalLayout(), 23 | cellBackground: LinearGradient(colors: [.blue, .green], startPoint: .topLeading, endPoint: .bottomTrailing)) { indexPath, state, item in 24 | VStack { 25 | Image(systemName: item.systemImageName) 26 | Text(item.title) 27 | Text(item.subtitle) 28 | } 29 | .font(state.isSelected ? .title : .body) 30 | .disabled(indexPath.item == 0) 31 | .swipeActions { 32 | Button(role: .destructive) { 33 | print("Test") 34 | } label: { 35 | Label("", systemImage: "trash") 36 | } 37 | Button(role: .destructive) { 38 | print("Test") 39 | } label: { 40 | Label("", systemImage: "trash") 41 | } 42 | .tint(.blue) 43 | } 44 | .swipeActions(edge: .leading) { 45 | Button(role: .destructive) { 46 | print("Test") 47 | } label: { 48 | Label("", systemImage: "trash") 49 | } 50 | Button(role: .destructive) { 51 | print("Test") 52 | } label: { 53 | Label("", systemImage: "trash") 54 | } 55 | .tint(.blue) 56 | } 57 | // } cellBackground: { _, state, _ in 58 | // Text("BACKGROUND") 59 | // .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 60 | // .background(state.isSwiped ? .orange : .yellow) 61 | } cellConfigurationHandler: { cell, indexPath, _, _ in 62 | if indexPath.item == 1 { 63 | 64 | } 65 | } 66 | .backgroundStyle(BackgroundStyle.background) 67 | // .itemSelection(shouldSelectItem: { _, indexPath in 68 | // indexPath.item != 0 69 | // }) 70 | .ignoresSafeArea() 71 | .toolbar { 72 | ToolbarItem(placement: .primaryAction) { 73 | Button("Select First Item") { 74 | selection = items[.section1]?.first 75 | } 76 | } 77 | } 78 | } 79 | 80 | func listLayout() -> UICollectionViewLayout { 81 | return UICollectionViewCompositionalLayout.list(using: .init(appearance: .insetGrouped)) 82 | } 83 | 84 | func compositionalLayout() -> UICollectionViewCompositionalLayout { 85 | let layout = UICollectionViewCompositionalLayout { 86 | (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in 87 | 88 | let leadingItem = NSCollectionLayoutItem( 89 | layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7), 90 | heightDimension: .fractionalHeight(1.0))) 91 | leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) 92 | 93 | let trailingItem = NSCollectionLayoutItem( 94 | layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), 95 | heightDimension: .fractionalHeight(0.3))) 96 | trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) 97 | let trailingGroup = NSCollectionLayoutGroup.vertical( 98 | layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3), 99 | heightDimension: .fractionalHeight(1.0)), 100 | subitem: trailingItem, count: 2) 101 | 102 | let nestedGroup = NSCollectionLayoutGroup.horizontal( 103 | layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), 104 | heightDimension: .fractionalHeight(0.4)), 105 | subitems: [leadingItem, trailingGroup]) 106 | let section = NSCollectionLayoutSection(group: nestedGroup) 107 | section.orthogonalScrollingBehavior = .continuous 108 | return section 109 | 110 | } 111 | return layout 112 | } 113 | } 114 | 115 | @available(iOS 16, *) 116 | #Preview { 117 | NavigationView { 118 | HostingInitTest() 119 | .navigationTitle("Test") 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/CollectionView/Testing/Initializer Testing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Initializer Testing.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 10/1/23. 6 | // 7 | 8 | import Foundation 9 | import OrderedCollections 10 | 11 | enum Testing { 12 | enum Sections: String, Hashable { 13 | case section1 14 | case section2 15 | } 16 | 17 | struct Item: Hashable { 18 | var title: String 19 | var subtitle: String 20 | var systemImageName: String 21 | } 22 | } 23 | 24 | extension OrderedDictionary where Key == Testing.Sections, Value == [Testing.Item] { 25 | internal static var dummyData: Self { 26 | [ 27 | .section1: [ 28 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill"), 29 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill"), 30 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill"), 31 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill"), 32 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill") 33 | ], 34 | .section2: [ 35 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill"), 36 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "books.vertical.fill"), 37 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill"), 38 | Testing.Item(title: String(UUID().uuidString.prefix(8)), subtitle: String(UUID().uuidString.prefix(8)), systemImageName: "trash.fill"), 39 | ] 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/CollectionView/Testing/ListInitTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListInitTest.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 10/2/23. 6 | // 7 | 8 | import SwiftUI 9 | import OrderedCollections 10 | 11 | struct ListInitTest: View { 12 | @State 13 | var items: OrderedDictionary = .dummyData 14 | 15 | @State var selection: Testing.Item? = nil 16 | 17 | var body: some View { 18 | CollectionView( 19 | $items, 20 | selection: $selection, 21 | listAppearance: .insetGrouped) { indexPath, state, item in 22 | let isHeader = indexPath.item == 0 23 | var contentConfig: UIListContentConfiguration 24 | if isHeader { 25 | contentConfig = UIListContentConfiguration.groupedHeader() 26 | } else { 27 | contentConfig = UIListContentConfiguration.valueCell() 28 | contentConfig.secondaryText = item.subtitle 29 | } 30 | 31 | contentConfig.text = item.title 32 | contentConfig.image = UIImage(systemName: item.systemImageName) 33 | contentConfig.secondaryTextProperties.color = .label 34 | contentConfig.imageProperties.cornerRadius = 10 35 | 36 | 37 | return contentConfig 38 | } backgroundConfiguration: { indexPath, state, item in 39 | if indexPath.item == 0 { 40 | UIBackgroundConfiguration.listGroupedHeaderFooter() 41 | } else { 42 | UIBackgroundConfiguration.listGroupedCell() 43 | } 44 | } cellConfigurationHandler: { cell, indexPath, state, _ in 45 | // if indexPath.item == 1 { 46 | cell.indentationLevel = indexPath.item 47 | // } 48 | } listConfigurationHandler: { config in 49 | config.headerMode = .firstItemInSection 50 | } 51 | .itemSelection(shouldSelectItem: { _, indexPath in 52 | indexPath.item != 0 53 | }) 54 | .ignoresSafeArea() 55 | .toolbar { 56 | ToolbarItem(placement: .primaryAction) { 57 | Button("Select First Item") { 58 | selection = items[.section1]?[1] 59 | } 60 | } 61 | } 62 | } 63 | 64 | func listLayout() -> UICollectionViewLayout { 65 | return UICollectionViewCompositionalLayout.list(using: .init(appearance: .insetGrouped)) 66 | } 67 | } 68 | 69 | @available(iOS 16, *) 70 | #Preview { 71 | NavigationStack { 72 | ListInitTest() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/CollectionView/Testing/StandardInitMultipleSelectTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StandardInitMultipleSelectTest.swift 3 | // 4 | // 5 | // Created by Edon Valdman on 9/28/23. 6 | // 7 | 8 | import SwiftUI 9 | import OrderedCollections 10 | 11 | struct StandardInitMultipleSelectTest: View { 12 | @State 13 | var items: OrderedDictionary = .dummyData 14 | 15 | @State var selection: Set = [] 16 | 17 | var body: some View { 18 | CollectionView( 19 | $items, 20 | selection: $selection, 21 | layout: layout()) { cell, indexPath, item in 22 | cell.contentConfiguration = { 23 | let isHeader = indexPath.item == 0 24 | var contentConfig: UIListContentConfiguration 25 | if isHeader { 26 | contentConfig = UIListContentConfiguration.groupedHeader() 27 | } else { 28 | contentConfig = UIListContentConfiguration.valueCell() 29 | contentConfig.secondaryText = item.subtitle 30 | } 31 | 32 | contentConfig.text = item.title 33 | contentConfig.image = UIImage(systemName: item.systemImageName) 34 | contentConfig.secondaryTextProperties.color = .label 35 | contentConfig.imageProperties.cornerRadius = 10 36 | contentConfig.imageProperties.preferredSymbolConfiguration = .init(font: .preferredFont(forTextStyle: .largeTitle), scale: .default) 37 | return contentConfig 38 | }() 39 | 40 | cell.backgroundConfiguration = { 41 | if indexPath.item == 0 { 42 | UIBackgroundConfiguration.listGroupedHeaderFooter() 43 | } else { 44 | UIBackgroundConfiguration.listGroupedCell() 45 | } 46 | }() 47 | } 48 | .itemSelection(shouldSelectItem: { _, indexPath in 49 | indexPath.item != 0 50 | }) 51 | // .contextMenu(menuIdentifier: nil, swiftUIProvider: { indexPaths, point in 52 | // guard let firstIndexPath = indexPaths.first, 53 | // let item = self.items[firstIndexPath] else { return nil } 54 | // 55 | // return Text(item.title) 56 | // }, actionProvider: { indexPaths, point, _ in 57 | // return nil 58 | // }) 59 | // .background(.green) 60 | // .backgroundColor(Color.yellow) 61 | .ignoresSafeArea() 62 | .toolbar { 63 | ToolbarItem(placement: .primaryAction) { 64 | Button("Remove Random") { 65 | guard let random = selection.randomElement() else { return } 66 | selection.remove(random) 67 | } 68 | } 69 | } 70 | // .onChange(of: selection) { newValue in 71 | // print(newValue.map(\.title)) 72 | // } 73 | } 74 | 75 | func layout() -> UICollectionViewCompositionalLayout { 76 | var listConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped) 77 | listConfig.headerMode = .firstItemInSection 78 | // listConfig.backgroundColor = .clear 79 | return .list(using: listConfig) 80 | } 81 | } 82 | 83 | @available(iOS 17.0, *) 84 | #Preview { 85 | NavigationView { 86 | StandardInitMultipleSelectTest() 87 | // .navigationTitle("Test") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyCollectedDataTypes 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyTracking 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Tests/CollectionViewTests/CollectionViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | //@testable import CollectionView 3 | 4 | final class CollectionViewTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------