├── .github └── FUNDING.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── Internal │ ├── Extensions │ │ ├── Array++.swift │ │ └── Int++.swift │ ├── Matrix │ │ ├── Matrix.Item.swift │ │ ├── Matrix.Position.swift │ │ ├── Matrix.Range.swift │ │ └── Matrix.swift │ ├── Other │ │ └── AnyGridElement.swift │ ├── Protocols │ │ └── Configurable.swift │ ├── View Modifiers │ │ └── HeightReader.swift │ └── Views │ │ └── GridView.swift └── Public │ ├── Configurables │ └── Public+GridViewConfig.swift │ ├── Enumerations │ └── Public+InsertionPolicy.swift │ ├── Extensions │ ├── Public+GridView.swift │ └── Public+View.swift │ └── Protocols │ └── Public+GridElement.swift └── Tests ├── Cases └── Matrix_Test.swift └── Helpers └── Array++.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mijick 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: mijick 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.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 Mijick 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.8 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: "MijickGridView", 8 | platforms: [ 9 | .iOS(.v14) 10 | ], 11 | products: [ 12 | .library(name: "MijickGridView", targets: ["MijickGridView"]), 13 | ], 14 | targets: [ 15 | .target(name: "MijickGridView", dependencies: [], path: "Sources"), 16 | .testTarget(name: "MijickGridViewTests", dependencies: ["MijickGridView"], path: "Tests") 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | 5 | 6 | 7 | GridView Logo 8 | 9 |

10 | 11 | 12 |

13 | Layouts made simple 14 |

15 | 16 |

17 | Lay out your data in the blink of an eye. Keep your code clean 18 |

19 | 20 |

21 | Try demo we prepared 22 | | 23 | Roadmap 24 | | 25 | Propose a new feature 26 |

27 | 28 |
29 | 30 |

31 | Library in beta version 32 | Designed for SwiftUI 33 | Platforms: iOS 34 | Current Version 35 | License: MIT 36 |

37 | 38 |

39 | Made in Kraków 40 | 41 | Follow us on X 42 | 43 | 44 | Let's work together 45 | 46 | 47 | Stargazers 48 | 49 |

50 | 51 | 52 |

53 | GridView Examples 54 |

55 | 56 | 57 |
58 | 59 | GridView is a free, and open-source library for SwiftUI that makes creating grids easier and much cleaner. 60 | * **Improves code quality.** Create a grid using `GridView` constructor - simply pass your data and we'll deal with the rest. Simple as never! 61 | * **Designed for SwiftUI.** While developing the library, we have used the power of SwiftUI to give you powerful tool to speed up your implementation process. 62 | 63 |
64 | 65 | # Getting Started 66 | ### ✋ Requirements 67 | 68 | | **Platforms** | **Minimum Swift Version** | 69 | |:----------|:----------| 70 | | iOS 14+ | 5.0 | 71 | 72 | ### ⏳ Installation 73 | 74 | #### [Swift Package Manager][spm] 75 | Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the Swift compiler. 76 | 77 | Once you have your Swift package set up, adding GridView as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. 78 | 79 | ```Swift 80 | dependencies: [ 81 | .package(url: "https://github.com/Mijick/GridView", branch(“main”)) 82 | ] 83 | ``` 84 | 85 |
86 | 87 | # Usage 88 | ### 1. Call initialiser 89 | To declare a Grid for your data set, call the constructor: 90 | 91 | ```Swift 92 | struct ContentView: View { 93 | private let data = [SomeData]() 94 | 95 | var body: some View { 96 | GridView(data, id: \.self) { element in 97 | SomeItem(element: element) 98 | } 99 | } 100 | } 101 | ``` 102 | 103 | ### 2. Customise Grid 104 | Your GridView can be customised by calling `configBuilder` inside the initialiser: 105 | 106 | ```Swift 107 | struct ContentView: View { 108 | private let data = [SomeData]() 109 | 110 | var body: some View { 111 | GridView(data, id: \.self, content: SomeItem.init, configBuilder: { $0 112 | .insertionPolicy(.fill) 113 | .columns(4) 114 | .verticalSpacing(12) 115 | }) 116 | } 117 | } 118 | ``` 119 | 120 | 121 | ### 3. Declare number of columns 122 | You can change the number of columns of an item by calling .columns of Item: 123 | ```Swift 124 | struct ContentView: View { ... } 125 | struct SomeItem: View { 126 | ... 127 | 128 | var body: some View { 129 | ... 130 | .columns(2) 131 | } 132 | } 133 | ``` 134 | 135 | 136 |
137 | 138 | # Try our demo 139 | See for yourself how does it work by cloning [project][Demo] we created 140 | 141 | # License 142 | GridView is released under the MIT license. See [LICENSE][License] for details. 143 | 144 |

145 | 146 | # Our other open source SwiftUI libraries 147 | [PopupView] - The most powerful popup library that allows you to present any popup 148 |
149 | [NavigationView] - Easier and cleaner way of navigating through your app 150 |
151 | [CalendarView] - Create your own calendar object in no time 152 |
153 | [CameraView] - The most powerful CameraController. Designed for SwiftUI 154 |
155 | [Timer] - Modern API for Timer 156 | 157 | 158 | [MIT]: https://en.wikipedia.org/wiki/MIT_License 159 | [SPM]: https://www.swift.org/package-manager 160 | 161 | [Demo]: https://github.com/Mijick/GridView-Demo 162 | [License]: https://github.com/Mijick/GridView/blob/main/LICENSE 163 | 164 | [PopupView]: https://github.com/Mijick/PopupView 165 | [NavigationView]: https://github.com/Mijick/NavigationView 166 | [CalendarView]: https://github.com/Mijick/CalendarView 167 | [CameraView]: https://github.com/Mijick/CameraView 168 | [Timer]: https://github.com/Mijick/Timer 169 | -------------------------------------------------------------------------------- /Sources/Internal/Extensions/Array++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array++.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | // MARK: - Removing Duplicates 15 | extension Array where Element: Hashable { 16 | func removingDuplicates() -> Self { Array(Set(self)) } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Internal/Extensions/Int++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Int++.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | extension Int { 15 | func toDouble() -> Double { .init(self) } 16 | func toCGFloat() -> CGFloat { .init(self) } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Internal/Matrix/Matrix.Item.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Matrix.Item.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | extension Matrix { struct Item { 15 | var index: Int 16 | var value: CGFloat 17 | var columns: Int 18 | }} 19 | extension Matrix.Item: Hashable { 20 | func hash(into hasher: inout Hasher) { hasher.combine(index) } 21 | } 22 | extension Matrix.Item { 23 | var isEmpty: Bool { value == 0 } 24 | } 25 | 26 | 27 | // MARK: - Array 28 | extension [[Matrix.Item]] { 29 | subscript(_ position: Matrix.Position) -> Matrix.Item { 30 | get { self[position.row][position.column] } 31 | set { self[position.row][position.column] = newValue } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Internal/Matrix/Matrix.Position.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Matrix.Position.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | extension Matrix { struct Position { 15 | let row: Int 16 | let column: Int 17 | }} 18 | extension Matrix.Position: Comparable { 19 | static func < (lhs: Matrix.Position, rhs: Matrix.Position) -> Bool { 20 | if lhs.row == rhs.row { return lhs.column < rhs.column } 21 | return lhs.row < rhs.row 22 | } 23 | } 24 | 25 | // MARK: - Creating Item Range 26 | extension Matrix.Position { 27 | func createItemRange(_ item: Matrix.Item) -> Matrix.Range { .init(from: self, to: withColumn(column + item.columns - 1)) } 28 | } 29 | 30 | // MARK: - Helpers 31 | extension Matrix.Position { 32 | func withRow(_ rowIndex: Int) -> Self { .init(row: rowIndex, column: column) } 33 | func withColumn(_ columnIndex: Int) -> Self { .init(row: row, column: columnIndex) } 34 | 35 | func nextRow() -> Self { withRow(row + 1) } 36 | func nextColumn() -> Self { withColumn(column + 1) } 37 | 38 | func previousRow() -> Self { withRow(row - 1) } 39 | func previousColumn() -> Self { withColumn(column - 1) } 40 | } 41 | 42 | // MARK: - Objects 43 | extension Matrix.Position { 44 | static var zero: Self { .init(row: 0, column: 0) } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Internal/Matrix/Matrix.Range.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Matrix.Range.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | extension Matrix { struct Range { 15 | var start: Position 16 | var end: Position 17 | 18 | init(from startPosition: Matrix.Position, to endPosition: Matrix.Position) { 19 | if startPosition > endPosition { fatalError("Start position must precede end position") } 20 | 21 | start = startPosition 22 | end = endPosition 23 | } 24 | }} 25 | 26 | extension Matrix.Range { 27 | func updating(newStart: Matrix.Position) -> Matrix.Range { 28 | let endIndexOffset = newStart.column + columns.count - 1 29 | 30 | var range = self 31 | range.start = newStart 32 | range.end = newStart.withColumn(endIndexOffset) 33 | return range 34 | } 35 | } 36 | 37 | // MARK: - Helpers 38 | extension Matrix.Range { 39 | var rows: ClosedRange { start.row...end.row } 40 | var columns: ClosedRange { start.column...end.column } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Internal/Matrix/Matrix.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Matrix.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | struct Matrix { 15 | let config: GridView.Config 16 | private var items: [[Item]] = [] 17 | private var matrixInitialised: Bool = false 18 | 19 | 20 | init(_ config: GridView.Config) { self.items = .init(numberOfColumns: config.numberOfColumns); self.config = config } 21 | } 22 | 23 | // MARK: - Inserting Items 24 | extension Matrix { 25 | mutating func insert(_ item: Item, isLast: Bool) { if !matrixInitialised { 26 | let position = findPositionForItem(item) 27 | let range = position.createItemRange(item) 28 | 29 | checkItem(item) 30 | addNewRowIfNeeded(position) 31 | insertItem(item, range) 32 | sortMatrix(isLast) 33 | }} 34 | } 35 | 36 | // MARK: Checking Item 37 | private extension Matrix { 38 | func checkItem(_ item: Item) { 39 | if item.columns > config.numberOfColumns { fatalError("Single element cannot have more columns than the entire view.") } 40 | } 41 | } 42 | 43 | // MARK: Finding Position 44 | private extension Matrix { 45 | func findPositionForItem(_ item: Item) -> Position { 46 | guard item.index > 0 else { return .zero } 47 | 48 | let previousItemRangeEnd = getRange(for: item.index - 1).end 49 | return canInsertItemInRow(item, previousItemRangeEnd) ? 50 | previousItemRangeEnd.nextColumn() : previousItemRangeEnd.nextRow().withColumn(0) 51 | } 52 | } 53 | private extension Matrix { 54 | func canInsertItemInRow(_ item: Item, _ previousItemRangeEnd: Position) -> Bool { item.columns + previousItemRangeEnd.column < numberOfColumns } 55 | } 56 | 57 | // MARK: Inserting New Row 58 | private extension Matrix { 59 | mutating func addNewRowIfNeeded(_ position: Position) { if position.row >= items.count { 60 | items.insertEmptyRow(numberOfColumns: numberOfColumns) 61 | }} 62 | } 63 | 64 | // MARK: Filling Array 65 | private extension Matrix { 66 | mutating func insertItem(_ item: Item, _ range: Range, _ isLast: Bool = false) { 67 | let columnHeight = getHeights() 68 | let range = recalculateRange(range, columnHeight, isLast) 69 | 70 | fillInMatrixWithEmptySpaces(range, columnHeight) 71 | fillInMatrixWithItem(item, range) 72 | } 73 | } 74 | private extension Matrix { 75 | func recalculateRange(_ range: Range, _ columnHeights: [[CGFloat]], _ isLast: Bool) -> Range { 76 | guard isLast, range.end.row > 0 else { return range } 77 | 78 | let columnsRangeStart = range.columns.lowerBound, 79 | columnsRangeEnd = config.numberOfColumns - range.columns.count + 1, 80 | columnsRange = columnsRangeStart.. 0 { 91 | range.columns.forEach { column in 92 | let currentPosition = range.start.withColumn(column), previousPosition = currentPosition.previousRow() 93 | let difference = columnHeights[currentPosition] - columnHeights[previousPosition] 94 | 95 | if shouldFillInEmptySpace(difference, items[previousPosition]) { 96 | items[previousPosition] = .init(index: -1, value: difference, columns: 1) 97 | } 98 | } 99 | }} 100 | mutating func fillInMatrixWithItem(_ item: Item, _ range: Range) { range.columns.forEach { column in 101 | items[range.start.withColumn(column)] = item 102 | }} 103 | } 104 | private extension Matrix { 105 | func shouldFillInEmptySpace(_ difference: CGFloat, _ item: Item) -> Bool { difference > 0 && item.isEmpty } 106 | } 107 | 108 | // MARK: Sorting Matrix 109 | private extension Matrix { 110 | mutating func sortMatrix(_ isLastItem: Bool) { if policy == .fill && isLastItem { 111 | let items = getUniqueSortedItems() 112 | let proposedSortedMatrix = getProposedSortedMatrix(items) 113 | 114 | eraseTemporaryMatrix() 115 | insertSortedMatrix(proposedSortedMatrix) 116 | setMatrixAsInitialised() 117 | }} 118 | } 119 | private extension Matrix { 120 | func getUniqueSortedItems() -> [Item] { items 121 | .flatMap { $0 } 122 | .filter { $0.index != -1 } 123 | .removingDuplicates() 124 | .sorted(by: { $0.index < $1.index }) 125 | .sorted(by: { $0.columns > $1.columns }) 126 | } 127 | func getProposedSortedMatrix(_ items: [Item]) -> [[Item]] { 128 | var array: [[Item]] = [] 129 | 130 | for item in items where !array.contains(item) { 131 | let bestRow = getBestRow(array, items, item) 132 | array.append(bestRow) 133 | } 134 | 135 | return array 136 | } 137 | mutating func eraseTemporaryMatrix() { 138 | items = .init(numberOfColumns: numberOfColumns) 139 | } 140 | mutating func insertSortedMatrix(_ proposedSortedMatrix: [[Item]]) { 141 | for row in 0.. [Matrix.Item] { 161 | var proposedRows = [[item1]] 162 | 163 | for item2 in getRemainingItems(results, items, item1) { 164 | switch proposedRows.lastItem.columns + item2.columns <= numberOfColumns { 165 | case true: proposedRows.lastItem.append(item2) 166 | case false: proposedRows.append([]) 167 | } 168 | } 169 | 170 | return proposedRows.pickingBest() 171 | } 172 | } 173 | private extension Matrix { 174 | func getRemainingItems(_ results: [[Item]], _ items: [Item], _ item1: Item) -> [Item] { items 175 | .filter { $0.columns + item1.columns <= numberOfColumns } 176 | .filter { !results.contains($0) } 177 | .filter { item1 != $0 } 178 | } 179 | } 180 | 181 | // MARK: - Getting Item Position 182 | extension Matrix { 183 | func getRange(for index: Int) -> Range { 184 | let startPosition = getStartPosition(for: index) 185 | return startPosition.createItemRange(items[startPosition]) 186 | } 187 | } 188 | private extension Matrix { 189 | func getStartPosition(for index: Int) -> Position { 190 | let rowIndex = items.firstIndex(where: { $0.contains(where: { $0.index == index }) }) ?? 0 191 | let columnIndex = items[rowIndex].firstIndex(where: { $0.index == index }) ?? 0 192 | return .init(row: rowIndex, column: columnIndex) 193 | } 194 | } 195 | 196 | // MARK: - Getting Column Heights 197 | extension Matrix { 198 | func getHeights() -> [[CGFloat]] { 199 | var array: [[CGFloat]] = .init(repeating: .init(repeating: 0, count: numberOfColumns), count: items.count) 200 | 201 | for row in 0.. 0 ? array[position.previousRow()] : 0 219 | 220 | array[position] = currentValue + previousRowPositionValue 221 | } 222 | func updateValuesForMultigridItem(_ position: Position, _ item: Item, _ array: inout [[CGFloat]]) { if item.columns > 1 { 223 | let range = getStartPosition(for: item.index).createItemRange(item) 224 | 225 | guard range.end.column == position.column else { return } 226 | 227 | let maxValue = array[position.row][range.columns].max() ?? 0 228 | range.columns.forEach { array[position.row][$0] = maxValue } 229 | }} 230 | } 231 | 232 | // MARK: - Others 233 | extension Matrix { 234 | var itemsSpacing: CGFloat { config.spacing.vertical } 235 | var policy: InsertionPolicy { config.insertionPolicy } 236 | var numberOfColumns: Int { config.numberOfColumns } 237 | } 238 | 239 | 240 | // MARK: - Helpers 241 | fileprivate extension [[Matrix.Item]] { 242 | init(numberOfColumns: Int) { self = [.empty(numberOfColumns)] } 243 | mutating func insertEmptyRow(numberOfColumns: Int) { append(.empty(numberOfColumns)) } 244 | } 245 | fileprivate extension [[Matrix.Item]] { 246 | func contains(_ element: Matrix.Item) -> Bool { joined().contains(where: { $0.index == element.index }) } 247 | func pickingBest() -> [Matrix.Item] { self.min(by: { $0.heightsDifference < $1.heightsDifference }) ?? [] } 248 | } 249 | fileprivate extension [[Matrix.Item]] { 250 | var lastItem: [Matrix.Item] { 251 | get { last ?? [] } 252 | set { self[count - 1] = newValue } 253 | } 254 | } 255 | 256 | fileprivate extension [Matrix.Item] { 257 | static func empty(_ numberOfColumns: Int) -> Self { .init(repeating: .init(index: -1, value: 0, columns: 1), count: numberOfColumns) } 258 | } 259 | fileprivate extension [Matrix.Item] { 260 | var heightsDifference: CGFloat { 261 | let min = self.min(by: { $0.value < $1.value })?.value ?? 0 262 | let max = self.max(by: { $0.value > $1.value })?.value ?? 0 263 | 264 | return max - min 265 | } 266 | var columns: Int { reduce(0, { $0 + $1.columns }) } 267 | } 268 | 269 | fileprivate extension [[CGFloat]] { 270 | subscript(position: Matrix.Position) -> CGFloat { 271 | get { self[position.row][position.column] } 272 | set { self[position.row][position.column] = newValue } 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /Sources/Internal/Other/AnyGridElement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyGridElement.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | struct AnyGridElement: GridElement { 15 | @State var height: CGFloat? = nil 16 | let columns: Int 17 | private let _body: AnyView 18 | 19 | 20 | var body: some View { _body } 21 | init(_ element: some View, numberOfColumns: Int? = nil) { 22 | self.columns = Self.getNumberOfColumns(element, numberOfColumns) 23 | self._body = AnyView(element) 24 | } 25 | } 26 | private extension AnyGridElement { 27 | static func getNumberOfColumns(_ element: some View, _ numberOfColumns: Int?) -> Int { 28 | if let element = element as? any GridElement { return element.columns } 29 | return numberOfColumns ?? 1 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Internal/Protocols/Configurable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configurable.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | public protocol Configurable { init() } 13 | extension Configurable { 14 | func changing(path: WritableKeyPath, to value: T) -> Self { 15 | var clone = self 16 | clone[keyPath: path] = value 17 | return clone 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Internal/View Modifiers/HeightReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeightReader.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | extension View { 15 | func readHeight(onChange action: @escaping (CGFloat) -> ()) -> some View { modifier(Modifier(onHeightChange: action)) } 16 | } 17 | 18 | // MARK: - Implementation 19 | fileprivate struct Modifier: ViewModifier { 20 | let onHeightChange: (CGFloat) -> () 21 | 22 | func body(content: Content) -> some View { content 23 | .background( 24 | GeometryReader { geo -> Color in 25 | DispatchQueue.main.async { onHeightChange(geo.size.height) } 26 | return Color.clear 27 | } 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Internal/Views/GridView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridView.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | public struct GridView: View { 15 | @State var matrix: Matrix 16 | var elements: [AnyGridElement] 17 | var config: Config 18 | 19 | 20 | public var body: some View { 21 | GeometryReader { reader in 22 | ZStack(alignment: .topLeading) { 23 | ForEach(0.. some View { 31 | elements[index] 32 | .readHeight { saveHeight($0, index) } 33 | .fixedSize(horizontal: false, vertical: true) 34 | .alignmentGuide(.top) { handleTopAlignmentGuide(index, $0, reader) } 35 | .alignmentGuide(.leading) { handleLeadingAlignmentGuide(index, $0, reader) } 36 | .frame(width: calculateItemWidth(index, reader.size.width), height: elements[index].height) 37 | } 38 | } 39 | 40 | // MARK: - Reading Height 41 | private extension GridView { 42 | func saveHeight(_ value: CGFloat, _ index: Int) { 43 | elements[index].height = value 44 | } 45 | } 46 | 47 | // MARK: - Vertical Alignment 48 | private extension GridView { 49 | func handleTopAlignmentGuide(_ index: Int, _ dimensions: ViewDimensions, _ reader: GeometryProxy) -> CGFloat { 50 | insertItem(index, dimensions.height) 51 | 52 | let topPadding = getTopPaddingValue(index) 53 | return topPadding 54 | } 55 | } 56 | private extension GridView { 57 | func insertItem(_ index: Int, _ value: CGFloat) { DispatchQueue.main.async { 58 | let item = Matrix.Item(index: index, value: value, columns: elements[index].columns) 59 | matrix.insert(item, isLast: index == elements.count - 1) 60 | }} 61 | func getTopPaddingValue(_ index: Int) -> CGFloat { 62 | let range = matrix.getRange(for: index) 63 | 64 | guard range.start.row > 0 else { return 0 } 65 | 66 | let heights = matrix.getHeights()[range.start.row - 1] 67 | let itemTopPadding = heights[range.columns].max() ?? 0 68 | return -itemTopPadding 69 | } 70 | } 71 | 72 | // MARK: - Horizontal Alignment 73 | private extension GridView { 74 | func handleLeadingAlignmentGuide(_ index: Int, _ dimensions: ViewDimensions, _ reader: GeometryProxy) -> CGFloat { 75 | let availableWidth = reader.size.width 76 | let itemPadding = calculateItemLeadingPadding(index, availableWidth) 77 | return itemPadding 78 | } 79 | } 80 | private extension GridView { 81 | func calculateItemLeadingPadding(_ index: Int, _ availableWidth: CGFloat) -> CGFloat { 82 | let columnNumber = matrix.getRange(for: index).start.column 83 | let singleColumnWidth = calculateSingleColumnWidth(availableWidth) 84 | 85 | let rawColumnsPaddingValue = singleColumnWidth * columnNumber.toCGFloat() 86 | let rawSpacingPaddingValue = (columnNumber.toCGFloat() - 1) * config.spacing.horizontal 87 | 88 | let rawPaddingValue = rawColumnsPaddingValue + rawSpacingPaddingValue 89 | return -rawPaddingValue 90 | } 91 | } 92 | 93 | // MARK: - Dimensions 94 | private extension GridView { 95 | func calculateItemWidth(_ index: Int, _ availableWidth: CGFloat) -> CGFloat { 96 | let itemColumns = elements[index].columns 97 | let singleColumnWidth = calculateSingleColumnWidth(availableWidth) 98 | 99 | let fixedItemWidth = singleColumnWidth * itemColumns.toCGFloat() 100 | let additionalHorizontalSpacing = (itemColumns.toCGFloat() - 1) * config.spacing.horizontal 101 | return fixedItemWidth + additionalHorizontalSpacing 102 | } 103 | func calculateContentHeight() -> CGFloat { matrix.getHeights().flatMap { $0 }.max() ?? 0 } 104 | } 105 | private extension GridView { 106 | func calculateSingleColumnWidth(_ availableWidth: CGFloat) -> CGFloat { 107 | let totalSpacingValue = getHorizontalSpacingTotalValue() 108 | let itemsWidth = availableWidth - totalSpacingValue 109 | return itemsWidth / matrix.numberOfColumns.toCGFloat() 110 | } 111 | func getHorizontalSpacingTotalValue() -> CGFloat { 112 | let numberOfSpaces = matrix.numberOfColumns - 1 113 | return numberOfSpaces.toCGFloat() * config.spacing.horizontal 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/Public/Configurables/Public+GridViewConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Public+GridViewConfig.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | // MARK: - Policies 15 | public extension GridView.Config { 16 | func insertionPolicy(_ value: InsertionPolicy) -> Self { changing(path: \.insertionPolicy, to: value) } 17 | } 18 | 19 | // MARK: - Composition 20 | public extension GridView.Config { 21 | func columns(_ value: Int) -> Self { changing(path: \.numberOfColumns, to: value) } 22 | func verticalSpacing(_ value: CGFloat) -> Self { changing(path: \.spacing.vertical, to: value) } 23 | func horizontalSpacing(_ value: CGFloat) -> Self { changing(path: \.spacing.horizontal, to: value) } 24 | } 25 | 26 | 27 | // MARK: - Internal 28 | extension GridView { public struct Config: Configurable { public init() {} 29 | private(set) var insertionPolicy: InsertionPolicy = .ordered 30 | 31 | private(set) var numberOfColumns: Int = 2 32 | private(set) var spacing: (vertical: CGFloat, horizontal: CGFloat) = (8, 8) 33 | }} 34 | -------------------------------------------------------------------------------- /Sources/Public/Enumerations/Public+InsertionPolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Public+InsertionPolicy.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | public enum InsertionPolicy { case ordered, fill } 13 | -------------------------------------------------------------------------------- /Sources/Public/Extensions/Public+GridView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Public+GridView.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | // MARK: - Initialisers 15 | extension GridView { 16 | public init(_ data: Data, id: KeyPath, @ViewBuilder content: @escaping (Data.Element) -> any View, configBuilder: (Config) -> Config = { $0 }) { self.init( 17 | matrix: .init(configBuilder(.init())), 18 | elements: data.map { .init(content($0)) }, 19 | config: configBuilder(.init()) 20 | )} 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Public/Extensions/Public+View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Public+View.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | public extension View { 15 | func columns(_ value: Int) -> some GridElement { AnyGridElement(self, numberOfColumns: value) } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Public/Protocols/Public+GridElement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Public+GridElement.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | public protocol GridElement: View { 15 | var columns: Int { get } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/Cases/Matrix_Test.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Matrix_Test.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import XCTest 13 | @testable import GridView 14 | 15 | final class Matrix_Test: XCTestCase { 16 | var matrix: Matrix = .init(columns: 4, itemsSpacing: 8, policy: .fill) 17 | } 18 | 19 | // MARK: - Inserting 20 | extension Matrix_Test { 21 | func testInsertValue_WhenMatrixIsEmpty() { 22 | matrix.insert(.init(index: 0, value: 100)) 23 | 24 | let result: [[CGFloat]] = matrix.items.map { $0.map(\.value) } 25 | let expectedResult: [[CGFloat]] = [[100, 0, 0, 0]] 26 | 27 | XCTAssertEqual(result, expectedResult) 28 | } 29 | func testInsertValue_WhenItemWithIndexIsPresent_ShouldNotInsertItem() { 30 | matrix.insert(.init(index: 0, value: 100)) 31 | matrix.insert(.init(index: 0, value: 200)) 32 | 33 | let result = matrix.items.flatMap { $0 }.filter { !$0.isEmpty }.count 34 | let expectedResult = 1 35 | 36 | XCTAssertEqual(result, expectedResult) 37 | } 38 | func testInsertValue_InsertManyItemsOfDifferentIndexes_ShouldInsertItems() { 39 | for index in 0..<5 { matrix.insert(.init(index: index, value: 100)) } 40 | 41 | let result = matrix.items.flatMap { $0 }.filter { !$0.isEmpty }.count 42 | let expectedResult = 5 43 | 44 | XCTAssertEqual(result, expectedResult) 45 | } 46 | func testInsertValue_InsertManyItemsOfDifferentIndexes_ShouldAddNewRow() { 47 | for index in 0..<5 { matrix.insert(.init(index: index, value: 100)) } 48 | 49 | let result = matrix.items.count 50 | let expectedResult = 2 51 | 52 | XCTAssertEqual(result, expectedResult) 53 | } 54 | func testInsertValue_WhenMatrixHasOneRowFilled_OrderedPolicy_ShouldMatchPattern() { 55 | matrix = .init(columns: 4, itemsSpacing: 8, policy: .ordered) 56 | for index in 0..<10 { matrix.insert(.init(index: index, value: entryValues[index])) } 57 | 58 | let result: [[CGFloat]] = matrix.items.map { $0.map { $0.value } } 59 | let expectedResult: [[CGFloat]] = [ 60 | [100, 200, 50, 100], 61 | [150, 100, 50, 100], 62 | [100, 150, 0, 0] 63 | ] 64 | 65 | XCTAssertEqual(result, expectedResult) 66 | } 67 | func testInsertValue_WhenMatrixHasOneRowFilled_FillPolicy_ShouldMatchPattern() { 68 | for index in 0..<10 { matrix.insert(.init(index: index, value: entryValues[index])) } 69 | 70 | let result: [[CGFloat]] = matrix.items.map { $0.map { $0.value } } 71 | let expectedResult: [[CGFloat]] = [ 72 | [100, 200, 50, 100], 73 | [100, 150, 150, 50], 74 | [100, 0, 0, 100] 75 | ] 76 | 77 | XCTAssertEqual(result, expectedResult) 78 | } 79 | } 80 | 81 | private extension Matrix_Test { 82 | var entryValues: [CGFloat] { [100, 200, 50, 100, 150, 100, 50, 100, 100, 150] } 83 | } 84 | -------------------------------------------------------------------------------- /Tests/Helpers/Array++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array++.swift of MijickGridView 3 | // 4 | // Created by Tomasz Kurylik 5 | // - Twitter: https://twitter.com/tkurylik 6 | // - Mail: tomasz.kurylik@mijick.com 7 | // - GitHub: https://github.com/FulcrumOne 8 | // 9 | // Copyright ©2023 Mijick. Licensed under MIT License. 10 | 11 | 12 | import SwiftUI 13 | 14 | extension [[CGFloat]] { 15 | func toString() -> String { 16 | var text = "" 17 | 18 | for row in 0..