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