├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── CollectionView.xcscheme ├── Package.swift ├── README.md ├── Sources └── CollectionView │ ├── CollectionView.swift │ └── CollectionView_Utils.swift ├── Tests ├── CollectionViewTests │ ├── CollectionViewTests.swift │ └── XCTestManifests.swift └── LinuxMain.swift └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.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/CollectionView.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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 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(SupportedPlatform.IOSVersion.v13), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "CollectionView", 15 | targets: ["CollectionView"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "CollectionView", 26 | dependencies: []), 27 | .testTarget( 28 | name: "CollectionViewTests", 29 | dependencies: ["CollectionView"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CollectionView 2 | 3 | A SwiftUI implementation of a grid layout similar to UICollectionView with UICollectionViewFlowLayout. 4 | 5 | Updates and documentation to follow. 6 | 7 | ## Features 8 | 9 | * Bindings to the data source and selected items 10 | * Selection mode 11 | * Custom column count 12 | * Custom row height 13 | * Custom spacing 14 | * Block-based tap and long-press actions 15 | * @ViewBuilder to produce each item's view (cell) 16 | 17 | ## Usage 18 | 19 | Add `import CollectionView` to your SwiftUI file and add `CollectionView(...)` to your view hierarchy. 20 | 21 | ```swift 22 | import SwiftUI 23 | import CollectionView 24 | 25 | struct CollectionView_Previews: PreviewProvider { 26 | struct ItemModel: Identifiable, Equatable { 27 | let id: Int 28 | let color: Color 29 | } 30 | 31 | @State static var items = [ItemModel(id: 0, color: Color.red), 32 | ItemModel(id: 1, color: Color.blue), 33 | ItemModel(id: 2, color: Color.green), 34 | ItemModel(id: 3, color: Color.yellow), 35 | ItemModel(id: 4, color: Color.orange), 36 | ItemModel(id: 5, color: Color.purple)] 37 | 38 | @State static var selectedItems = [ItemModel]() 39 | @State static var selectionMode = false 40 | 41 | static var previews: some View { 42 | CollectionView(items: $items, 43 | selectedItems: $selectedItems, 44 | selectionMode: $selectionMode) 45 | { item, _ in 46 | Rectangle() 47 | .foregroundColor(item.color) 48 | } 49 | } 50 | } 51 | 52 | ``` 53 | ![Screenshot](https://github.com/pourhadi/collectionview/blob/master/screenshot.png?raw=true) 54 | 55 | ### CollectionView init parameters 56 | 57 | * `items: Binding<[Item]>` 58 | 59 | Required. 60 | 61 | A binding to an array of values that conform to `Identifiable` and `Equatable`. This is the collection view's data source. 62 | 63 | * `selectedItems: Binding<[Item]>` 64 | 65 | Required. 66 | 67 | A binding to an array of values that conform to `Identifiable` and `Equatable`. 68 | 69 | When `selectionMode` is true, this will populate with the items selected by the user. When `selectionMode` is false, this will either be an empty array or be populated with the most-recently-selected item. Good to use for displaying / dismissing a child / detail view. 70 | 71 | * `selectionMode: Binding` 72 | 73 | Required. 74 | 75 | A binding to a bool value. Set to true to set the collection view in to selection mode. 76 | 77 | * `layout: CollectionView.Layout` 78 | 79 | Not required. Default is `CollectionView.Layout()` 80 | 81 | `Layout` is a struct containing the layout information for the collection view, with the defaults: 82 | 83 | * `itemSpacing: CGFloat` = 2.0 84 | * `numberOfColumns: Int` = 3 85 | * `rowHeight: CollectionView.RowHeight` = .sameAsItemWidth 86 | 87 | An enum for setting the desired height for the collection view's rows. 88 | 89 | ```swift 90 | public typealias CollectionViewRowHeightBlock = (_ row: Int, _ rowMetrics: GeometryProxy, _ itemSpacing: CGFloat, _ numberOfColumns: Int) -> CGFloat 91 | 92 | public enum RowHeight { 93 | case constant(CGFloat) 94 | case sameAsItemWidth 95 | case dynamic(CollectionViewRowHeightBlock) 96 | } 97 | ``` 98 | * `rowPadding: EdgeInsets` = EdgeInsets initialized with all zeros 99 | * `scrollViewInsets: EdgeInsets` = EdgeInsets initialized with all zeros 100 | 101 | * `tapAction: ((Item, GeometryProxy) -> Void)?` 102 | 103 | Not required. Defaults to nil. 104 | 105 | A block that will be called if an item is tapped on. 106 | 107 | * `longPressAction: ((Item, GeometryProxy) -> Void)?` 108 | 109 | Not required. Defaults to nil. 110 | 111 | A block that will be called after a successful long-press gesture on the item cell. 112 | 113 | * `pressAction: ((Item, Bool) -> Void)?` 114 | 115 | Not required. Defaults to nil. 116 | 117 | A block that will be called when a long-press gesture is possible, with a bool indicating whether the item is being pressed. 118 | 119 | * `itemBuilder: @escaping (Item, _ itemMetrics: GeometryProxy) -> ItemContent)` 120 | 121 | Required. 122 | 123 | A block that produces the view (cell) associated with a particular item. 124 | 125 | 126 | 127 | ## Planned features: 128 | * Sections 129 | * Customizable selection style 130 | * ?? 131 | 132 | ## Installation 133 | 134 | #### Swift Package Manager 135 | You can use [The Swift Package Manager](https://swift.org/package-manager) to install this library. 136 | 137 | ```swift 138 | import PackageDescription 139 | 140 | let package = Package( 141 | name: "YOUR_PROJECT_NAME", 142 | targets: [], 143 | dependencies: [ 144 | .package(url: "https://github.com/pourhadi/collectionview.git", .branch("master")) 145 | ] 146 | ) 147 | ``` 148 | 149 | Note that the [Swift Package Manager](https://swift.org/package-manager) is still in early design and development, for more information checkout its [GitHub Page](https://github.com/apple/swift-package-manager). 150 | 151 | ## License (MIT) 152 | 153 | Copyright (c) 2019 - present Daniel Pourhadi 154 | 155 | Permission is hereby granted, free of charge, to any person obtaining a copy 156 | of this software and associated documentation files (the "Software"), to deal 157 | in the Software without restriction, including without limitation the rights 158 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 159 | copies of the Software, and to permit persons to whom the Software is 160 | furnished to do so, subject to the following conditions: 161 | 162 | The above copyright notice and this permission notice shall be included in 163 | all copies or substantial portions of the Software. 164 | 165 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 166 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 167 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 168 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 169 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 170 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 171 | THE SOFTWARE. 172 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionView.swift 3 | // 4 | // Created by Daniel Pourhadi on 12/26/19. 5 | // Copyright © 2019 dan pourhadi. All rights reserved. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | public extension CollectionView { 12 | func rowPadding(_ padding: EdgeInsets) -> Self { 13 | self.layout.rowPadding = padding 14 | return self 15 | } 16 | 17 | func itemSpacing(_ itemSpacing: CGFloat) -> Self { 18 | self.layout.itemSpacing = itemSpacing 19 | return self 20 | } 21 | 22 | func numberOfColumns(_ numberOfColumns: Int) -> Self { 23 | self.layout.numberOfColumns = numberOfColumns 24 | return self 25 | } 26 | 27 | func rowHeight(_ rowHeight: RowHeight) -> Self { 28 | self.layout.rowHeight = rowHeight 29 | return self 30 | } 31 | 32 | func scrollViewInsets(_ scrollViewInsets: EdgeInsets) -> Self { 33 | self.layout.scrollViewInsets = scrollViewInsets 34 | return self 35 | } 36 | } 37 | 38 | fileprivate let ScrollViewCoordinateSpaceKey = "ScrollViewCoordinateSpace" 39 | 40 | public struct CollectionView: View where ItemContent: View, Item: Identifiable & Equatable { 41 | private struct Row: View where Content: View { 42 | @Binding var selectedItems: [Item] 43 | @Binding var selectionMode: Bool 44 | 45 | let content: () -> Content 46 | 47 | init(selectedItems: Binding<[Item]>, 48 | selectionMode: Binding, 49 | @ViewBuilder content: @escaping () -> Content) { 50 | self._selectedItems = selectedItems 51 | self._selectionMode = selectionMode 52 | self.content = content 53 | } 54 | 55 | var body: some View { 56 | self.content() 57 | } 58 | } 59 | 60 | public class Layout: ObservableObject { 61 | @Published public var rowPadding: EdgeInsets 62 | @Published public var numberOfColumns: Int 63 | @Published public var itemSpacing: CGFloat 64 | @Published public var rowHeight: RowHeight 65 | @Published public var scrollViewInsets: EdgeInsets 66 | 67 | public init(rowPadding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0), 68 | numberOfColumns: Int = 3, 69 | itemSpacing: CGFloat = 2, 70 | rowHeight: RowHeight = .sameAsItemWidth, 71 | scrollViewInsets: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) { 72 | self.rowPadding = rowPadding 73 | self.numberOfColumns = numberOfColumns 74 | self.itemSpacing = itemSpacing 75 | self.rowHeight = rowHeight 76 | self.scrollViewInsets = scrollViewInsets 77 | } 78 | } 79 | 80 | public typealias CollectionViewRowHeightBlock = (_ row: Int, _ rowMetrics: GeometryProxy, _ itemSpacing: CGFloat, _ numberOfColumns: Int) -> CGFloat 81 | 82 | public enum RowHeight { 83 | case constant(CGFloat) 84 | case sameAsItemWidth 85 | case dynamic(CollectionViewRowHeightBlock) 86 | } 87 | 88 | @ObservedObject private var layout: Layout 89 | 90 | @Binding private var items: [Item] 91 | 92 | private var selectedItems: Binding<[Item]> 93 | private var selectionMode: Binding 94 | 95 | private var numberOfColumns: Int { 96 | return self.layout.numberOfColumns 97 | } 98 | 99 | private var itemSpacing: CGFloat { 100 | return self.layout.itemSpacing 101 | } 102 | 103 | private var rowHeight: RowHeight { 104 | return self.layout.rowHeight 105 | } 106 | 107 | private let itemBuilder: (Item, GeometryProxy) -> ItemContent 108 | private let tapAction: ((Item, GeometryProxy) -> Void)? 109 | private let longPressAction: ((Item, GeometryProxy) -> Void)? 110 | private let pressAction: ((Item, Bool) -> Void)? 111 | 112 | public init(items: Binding<[Item]>, 113 | selectedItems: Binding<[Item]>, 114 | selectionMode: Binding, 115 | layout: Layout = Layout(), 116 | tapAction: ((Item, GeometryProxy) -> Void)? = nil, 117 | longPressAction: ((Item, GeometryProxy) -> Void)? = nil, 118 | pressAction: ((Item, Bool) -> Void)? = nil, 119 | @ViewBuilder itemBuilder: @escaping (Item, GeometryProxy) -> ItemContent) { 120 | self._items = items 121 | self.selectedItems = selectedItems 122 | self.selectionMode = selectionMode 123 | self.itemBuilder = itemBuilder 124 | self.tapAction = tapAction 125 | self.longPressAction = longPressAction 126 | self.pressAction = pressAction 127 | self.layout = layout 128 | } 129 | 130 | private struct ItemRow: Identifiable { 131 | let id: Int 132 | let items: [Item] 133 | } 134 | 135 | public var body: some View { 136 | var currentRow = [Item]() 137 | var rows = [ItemRow]() 138 | 139 | for item in self.items { 140 | currentRow.append(item) 141 | 142 | if currentRow.count >= self.numberOfColumns { 143 | rows.append(ItemRow(id: rows.count, items: currentRow)) 144 | currentRow = [] 145 | } 146 | } 147 | 148 | if currentRow.count > 0 { 149 | rows.append(ItemRow(id: rows.count, items: currentRow)) 150 | } 151 | 152 | return Group { 153 | GeometryReader { metrics in 154 | ScrollView { 155 | VStack(spacing: self.itemSpacing) { 156 | ForEach(rows) { row in 157 | self.getRow(for: row, metrics: metrics).padding(self.padding(for: row.id, rowCount: rows.count)) 158 | } 159 | }.coordinateSpace(name: ScrollViewCoordinateSpaceKey) 160 | }.padding(EdgeInsets(top: 0, leading: self.layout.scrollViewInsets.leading, bottom: 0, trailing: self.layout.scrollViewInsets.trailing)) 161 | } 162 | } 163 | } 164 | 165 | private func padding(for row: Int, rowCount: Int) -> EdgeInsets { 166 | let leading = self.layout.rowPadding.leading 167 | let trailing = self.layout.rowPadding.trailing 168 | 169 | var top = self.layout.rowPadding.top 170 | var bottom = self.layout.rowPadding.bottom 171 | 172 | if row == 0 { 173 | top += self.layout.scrollViewInsets.top 174 | } 175 | 176 | if row == rowCount - 1 { 177 | bottom += self.layout.scrollViewInsets.bottom 178 | } 179 | 180 | return EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing) 181 | } 182 | 183 | private func getRow(for row: ItemRow, metrics: GeometryProxy) -> some View { 184 | return Row(selectedItems: self.selectedItems, selectionMode: self.selectionMode) { 185 | HStack(spacing: self.itemSpacing) { 186 | ForEach(row.items) { item in 187 | GeometryReader { itemMetrics in 188 | ZStack { 189 | Group { 190 | self.itemBuilder(item, itemMetrics) 191 | if self.selectionMode.wrappedValue { 192 | Circle() 193 | .stroke(Color.white, lineWidth: 2) 194 | .frame(width: 20, height: 20) 195 | .background(self.selectedItems.wrappedValue.contains(item) ? Color.blue : Color.clear) 196 | .position(x: itemMetrics.size.width - 18, y: itemMetrics.size.height - 18) 197 | .shadow(radius: 2) 198 | } 199 | } 200 | .zIndex(2) 201 | .allowsHitTesting(false) 202 | 203 | Group { 204 | Rectangle().foregroundColor(Color.clear) 205 | } 206 | .background(Color(UIColor.systemBackground)) 207 | .allowsHitTesting(true) 208 | .zIndex(1) 209 | .onTapGesture { 210 | if self.selectionMode.wrappedValue { 211 | if let index = self.selectedItems.wrappedValue.firstIndex(of: item) { 212 | self.selectedItems.wrappedValue.remove(at: index) 213 | } else { 214 | self.selectedItems.wrappedValue.append(item) 215 | } 216 | } else { 217 | self.selectedItems.wrappedValue = [item] 218 | } 219 | 220 | self.tapAction?(item, itemMetrics) 221 | } 222 | .onLongPressGesture(minimumDuration: 0.25, maximumDistance: 10, pressing: { pressing in 223 | self.pressAction?(item, pressing) 224 | 225 | }) { 226 | self.longPressAction?(item, itemMetrics) 227 | } 228 | } 229 | } 230 | } 231 | }.frame(height: self.getRowHeight(for: row.id, metrics: metrics)) 232 | } 233 | } 234 | 235 | private func getColumnWidth(for width: CGFloat) -> CGFloat { 236 | let w = ((width - (self.layout.rowPadding.leading + self.layout.rowPadding.trailing + (self.layout.itemSpacing * CGFloat(self.layout.numberOfColumns - 1)))) / CGFloat(self.layout.numberOfColumns)) 237 | 238 | return w 239 | } 240 | 241 | private func getRowHeight(for row: Int, metrics: GeometryProxy?) -> CGFloat { 242 | guard let metrics = metrics else { return 0 } 243 | 244 | switch self.rowHeight { 245 | case .constant(let constant): return constant 246 | case .sameAsItemWidth: 247 | return self.getColumnWidth(for: metrics.size.width) 248 | case .dynamic(let rowHeightBlock): 249 | return rowHeightBlock(row, metrics, self.itemSpacing, self.numberOfColumns) 250 | } 251 | } 252 | } 253 | 254 | struct CollectionView_Previews: PreviewProvider { 255 | struct ItemModel: Identifiable, Equatable { 256 | let id: Int 257 | let color: Color 258 | } 259 | 260 | @State static var items = [ItemModel(id: 0, color: Color.red), 261 | ItemModel(id: 1, color: Color.blue), 262 | ItemModel(id: 2, color: Color.green), 263 | ItemModel(id: 3, color: Color.yellow), 264 | ItemModel(id: 4, color: Color.orange), 265 | ItemModel(id: 5, color: Color.purple)] 266 | 267 | @State static var selectedItems = [ItemModel]() 268 | @State static var selectionMode = false 269 | 270 | static var previews: some View { 271 | CollectionView(items: $items, 272 | selectedItems: $selectedItems, 273 | selectionMode: $selectionMode) 274 | { item, _ in 275 | Rectangle() 276 | .foregroundColor(item.color) 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionView_Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Daniel Pourhadi on 1/5/20. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// https://zacwhite.com/2019/scrollview-content-offsets-swiftui/ 12 | struct Run: View { 13 | let block: () -> Void 14 | 15 | var body: some View { 16 | DispatchQueue.main.async(execute: block) 17 | return AnyView(EmptyView()) 18 | } 19 | } 20 | 21 | 22 | /// tweaked from https://zacwhite.com/2019/scrollview-content-offsets-swiftui/ 23 | public struct OffsetScrollView: View where Content : View { 24 | 25 | private class StateStore { 26 | var initialOffset: CGPoint? 27 | } 28 | 29 | private let store = StateStore() 30 | 31 | public var content: Content 32 | 33 | public var axes: Axis.Set 34 | 35 | public var showsIndicators: Bool 36 | 37 | @State private var initialOffset: CGPoint? 38 | 39 | public let contentOffsetChanged: (CGPoint) -> Void 40 | 41 | public init(_ axes: Axis.Set = .vertical, 42 | showsIndicators: Bool = true, 43 | contentOffsetChanged: @escaping (CGPoint) -> Void, 44 | @ViewBuilder content: () -> Content) { 45 | self.axes = axes 46 | self.showsIndicators = showsIndicators 47 | self.contentOffsetChanged = contentOffsetChanged 48 | self.content = content() 49 | } 50 | 51 | public var body: some View { 52 | ScrollView(axes, showsIndicators: showsIndicators) { 53 | GeometryReader { geometry in 54 | Run { 55 | let globalOrigin = geometry.frame(in: .global).origin 56 | // self.store.initialOffset = self.store.initialOffset ?? globalOrigin 57 | let initialOffset = CGPoint.zero 58 | let offset = CGPoint(x: globalOrigin.x - initialOffset.x, y: globalOrigin.y - initialOffset.y) 59 | self.contentOffsetChanged(offset) 60 | } 61 | }.frame(width: 0, height: 0) 62 | 63 | content 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Tests/CollectionViewTests/CollectionViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CollectionView 3 | 4 | final class CollectionViewTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(CollectionView().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Tests/CollectionViewTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(CollectionViewTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import CollectionViewTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += CollectionViewTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pourhadi/collectionview/98fabf67c9d8a36e0fd4c32c590d984e2d4cdd76/screenshot.png --------------------------------------------------------------------------------