diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..db1d853 --- /dev/null +++ b/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MijickGridView", + platforms: [ + .iOS(.v14) + ], + products: [ + .library(name: "MijickGridView", targets: ["MijickGridView"]), + ], + targets: [ + .target(name: "MijickGridView", dependencies: [], path: "Sources"), + .testTarget(name: "MijickGridViewTests", dependencies: ["MijickGridView"], path: "Tests") + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..bef01be --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +
+ +

+ GridView Logo +

+ + +

+ Layouts made simple +

+ +

+ Lay out your data in the blink of an eye. Keep your code clean +

+ +

+ Try demo we prepared +

+ + +
+ +

+ Library in beta version + Designed for SwiftUI + Platforms: iOS + Release: 0.3.0 + + Swift Package Manager: Compatible + + License: MIT +

+ +

+ + Stars + + + Follow us on Twitter + + + Let's work together + + Made in Kraków +

+ + +

+ GridView Examples +

+ +
+ +GridView is a free, and open-source library for SwiftUI that makes creating grids easier and much cleaner. +* **Improves code quality.** Create a grid using `GridView` constructor - simply pass your data and we'll deal with the rest. Simple as never! +* **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. + +
+ +# Getting Started +### ✋ Requirements + +| **Platforms** | **Minimum Swift Version** | +|:----------|:----------| +| iOS 14+ | 5.0 | + +### ⏳ Installation + +#### [Swift Package Manager][spm] +Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the Swift compiler. + +Once you have your Swift package set up, adding Navigattie as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. + +```Swift +dependencies: [ + .package(url: "https://github.com/Mijick/GridView", branch(“main”)) +] +``` + +
+ +# Usage +### 1. Call initialiser +To declare a Grid for your data set, call the constructor: + +```Swift +struct ContentView: View { + private let data = [SomeData]() + + var body: some View { + GridView(data, id: \.self) { element in + SomeItem(element: element) + } + } +} +``` + +### 2. Customise Grid +Your GridView can be customised by calling `configBuilder` inside the initialiser: + +```Swift +struct ContentView: View { + private let data = [SomeData]() + + var body: some View { + GridView(data, id: \.self, content: SomeItem.init, configBuilder: { $0 + .insertionPolicy(.fill) + .columns(4) + .verticalSpacing(12) + }) + } +} +``` + + +### 3. Declare number of columns +You can change the number of columns of an item by calling .columns of Item: +```Swift +struct ContentView: View { ... } +struct SomeItem: View { + ... + + var body: some View { + ... + .columns(2) + } +} +``` + + +
+ +# Try our demo +See for yourself how does it work by cloning [project][Demo] we created + +# License +Navigattie is released under the MIT license. See [LICENSE][License] for details. + +

+ +# Our other open source SwiftUI libraries +[PopupView] - The most powerful popup library that allows you to present any popup +
+[Navigattie] - Easier and cleaner way of navigating through your app + + +[MIT]: https://en.wikipedia.org/wiki/MIT_License +[SPM]: https://www.swift.org/package-manager + +[Demo]: https://github.com/Mijick/GridView-Demo +[License]: https://github.com/Mijick/GridView/blob/main/LICENSE + +[PopupView]: https://github.com/Mijick/PopupView +[Navigattie]: https://github.com/Mijick/Navigattie diff --git a/Sources/Internal/Extensions/Array++.swift b/Sources/Internal/Extensions/Array++.swift new file mode 100644 index 0000000..e4b95dd --- /dev/null +++ b/Sources/Internal/Extensions/Array++.swift @@ -0,0 +1,17 @@ +// +// Array++.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +// MARK: - Removing Duplicates +extension Array where Element: Hashable { + func removingDuplicates() -> Self { Array(Set(self)) } +} diff --git a/Sources/Internal/Extensions/Int++.swift b/Sources/Internal/Extensions/Int++.swift new file mode 100644 index 0000000..8373ddf --- /dev/null +++ b/Sources/Internal/Extensions/Int++.swift @@ -0,0 +1,17 @@ +// +// Int++.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension Int { + func toDouble() -> Double { .init(self) } + func toCGFloat() -> CGFloat { .init(self) } +} diff --git a/Sources/Internal/Matrix/Matrix.Item.swift b/Sources/Internal/Matrix/Matrix.Item.swift new file mode 100644 index 0000000..8107fe3 --- /dev/null +++ b/Sources/Internal/Matrix/Matrix.Item.swift @@ -0,0 +1,33 @@ +// +// Matrix.Item.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension Matrix { struct Item { + var index: Int + var value: CGFloat + var columns: Int +}} +extension Matrix.Item: Hashable { + func hash(into hasher: inout Hasher) { hasher.combine(index) } +} +extension Matrix.Item { + var isEmpty: Bool { value == 0 } +} + + +// MARK: - Array +extension [[Matrix.Item]] { + subscript(_ position: Matrix.Position) -> Matrix.Item { + get { self[position.row][position.column] } + set { self[position.row][position.column] = newValue } + } +} diff --git a/Sources/Internal/Matrix/Matrix.Position.swift b/Sources/Internal/Matrix/Matrix.Position.swift new file mode 100644 index 0000000..a01fdaa --- /dev/null +++ b/Sources/Internal/Matrix/Matrix.Position.swift @@ -0,0 +1,45 @@ +// +// Matrix.Position.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension Matrix { struct Position { + let row: Int + let column: Int +}} +extension Matrix.Position: Comparable { + static func < (lhs: Matrix.Position, rhs: Matrix.Position) -> Bool { + if lhs.row == rhs.row { return lhs.column < rhs.column } + return lhs.row < rhs.row + } +} + +// MARK: - Creating Item Range +extension Matrix.Position { + func createItemRange(_ item: Matrix.Item) -> Matrix.Range { .init(from: self, to: withColumn(column + item.columns - 1)) } +} + +// MARK: - Helpers +extension Matrix.Position { + func withRow(_ rowIndex: Int) -> Self { .init(row: rowIndex, column: column) } + func withColumn(_ columnIndex: Int) -> Self { .init(row: row, column: columnIndex) } + + func nextRow() -> Self { withRow(row + 1) } + func nextColumn() -> Self { withColumn(column + 1) } + + func previousRow() -> Self { withRow(row - 1) } + func previousColumn() -> Self { withColumn(column - 1) } +} + +// MARK: - Objects +extension Matrix.Position { + static var zero: Self { .init(row: 0, column: 0) } +} diff --git a/Sources/Internal/Matrix/Matrix.Range.swift b/Sources/Internal/Matrix/Matrix.Range.swift new file mode 100644 index 0000000..e878d6a --- /dev/null +++ b/Sources/Internal/Matrix/Matrix.Range.swift @@ -0,0 +1,41 @@ +// +// Matrix.Range.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension Matrix { struct Range { + var start: Position + var end: Position + + init(from startPosition: Matrix.Position, to endPosition: Matrix.Position) { + if startPosition > endPosition { fatalError("Start position must precede end position") } + + start = startPosition + end = endPosition + } +}} + +extension Matrix.Range { + func updating(newStart: Matrix.Position) -> Matrix.Range { + let endIndexOffset = newStart.column + columns.count - 1 + + var range = self + range.start = newStart + range.end = newStart.withColumn(endIndexOffset) + return range + } +} + +// MARK: - Helpers +extension Matrix.Range { + var rows: ClosedRange { start.row...end.row } + var columns: ClosedRange { start.column...end.column } +} diff --git a/Sources/Internal/Matrix/Matrix.swift b/Sources/Internal/Matrix/Matrix.swift new file mode 100644 index 0000000..32c6312 --- /dev/null +++ b/Sources/Internal/Matrix/Matrix.swift @@ -0,0 +1,274 @@ +// +// Matrix.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +struct Matrix { + let config: GridView.Config + private var items: [[Item]] = [] + private var matrixInitialised: Bool = false + + + init(_ config: GridView.Config) { self.items = .init(numberOfColumns: config.numberOfColumns); self.config = config } +} + +// MARK: - Inserting Items +extension Matrix { + mutating func insert(_ item: Item, isLast: Bool) { if !matrixInitialised { + let position = findPositionForItem(item) + let range = position.createItemRange(item) + + checkItem(item) + addNewRowIfNeeded(position) + insertItem(item, range) + sortMatrix(isLast) + }} +} + +// MARK: Checking Item +private extension Matrix { + func checkItem(_ item: Item) { + if item.columns > config.numberOfColumns { fatalError("Single element cannot have more columns than the entire view.") } + } +} + +// MARK: Finding Position +private extension Matrix { + func findPositionForItem(_ item: Item) -> Position { + guard item.index > 0 else { return .zero } + + let previousItemRangeEnd = getRange(for: item.index - 1).end + return canInsertItemInRow(item, previousItemRangeEnd) ? + previousItemRangeEnd.nextColumn() : previousItemRangeEnd.nextRow().withColumn(0) + } +} +private extension Matrix { + func canInsertItemInRow(_ item: Item, _ previousItemRangeEnd: Position) -> Bool { item.columns + previousItemRangeEnd.column < numberOfColumns } +} + +// MARK: Inserting New Row +private extension Matrix { + mutating func addNewRowIfNeeded(_ position: Position) { if position.row >= items.count { + items.insertEmptyRow(numberOfColumns: numberOfColumns) + }} +} + +// MARK: Filling Array +private extension Matrix { + mutating func insertItem(_ item: Item, _ range: Range, _ isLast: Bool = false) { + let columnHeight = getHeights() + let range = recalculateRange(range, columnHeight, isLast) + + fillInMatrixWithEmptySpaces(range, columnHeight) + fillInMatrixWithItem(item, range) + } +} +private extension Matrix { + func recalculateRange(_ range: Range, _ columnHeights: [[CGFloat]], _ isLast: Bool) -> Range { + guard isLast, range.end.row > 0 else { return range } + + let columnsRangeStart = range.columns.lowerBound, + columnsRangeEnd = config.numberOfColumns - range.columns.count + 1, + columnsRange = columnsRangeStart.. 0 { + range.columns.forEach { column in + let currentPosition = range.start.withColumn(column), previousPosition = currentPosition.previousRow() + let difference = columnHeights[currentPosition] - columnHeights[previousPosition] + + if shouldFillInEmptySpace(difference, items[previousPosition]) { + items[previousPosition] = .init(index: -1, value: difference, columns: 1) + } + } + }} + mutating func fillInMatrixWithItem(_ item: Item, _ range: Range) { range.columns.forEach { column in + items[range.start.withColumn(column)] = item + }} +} +private extension Matrix { + func shouldFillInEmptySpace(_ difference: CGFloat, _ item: Item) -> Bool { difference > 0 && item.isEmpty } +} + +// MARK: Sorting Matrix +private extension Matrix { + mutating func sortMatrix(_ isLastItem: Bool) { if policy == .fill && isLastItem { + let items = getUniqueSortedItems() + let proposedSortedMatrix = getProposedSortedMatrix(items) + + eraseTemporaryMatrix() + insertSortedMatrix(proposedSortedMatrix) + setMatrixAsInitialised() + }} +} +private extension Matrix { + func getUniqueSortedItems() -> [Item] { items + .flatMap { $0 } + .filter { $0.index != -1 } + .removingDuplicates() + .sorted(by: { $0.index < $1.index }) + .sorted(by: { $0.columns > $1.columns }) + } + func getProposedSortedMatrix(_ items: [Item]) -> [[Item]] { + var array: [[Item]] = [] + + for item in items where !array.contains(item) { + let bestRow = getBestRow(array, items, item) + array.append(bestRow) + } + + return array + } + mutating func eraseTemporaryMatrix() { + items = .init(numberOfColumns: numberOfColumns) + } + mutating func insertSortedMatrix(_ proposedSortedMatrix: [[Item]]) { + for row in 0.. [Matrix.Item] { + var proposedRows = [[item1]] + + for item2 in getRemainingItems(results, items, item1) { + switch proposedRows.lastItem.columns + item2.columns <= numberOfColumns { + case true: proposedRows.lastItem.append(item2) + case false: proposedRows.append([]) + } + } + + return proposedRows.pickingBest() + } +} +private extension Matrix { + func getRemainingItems(_ results: [[Item]], _ items: [Item], _ item1: Item) -> [Item] { items + .filter { $0.columns + item1.columns <= numberOfColumns } + .filter { !results.contains($0) } + .filter { item1 != $0 } + } +} + +// MARK: - Getting Item Position +extension Matrix { + func getRange(for index: Int) -> Range { + let startPosition = getStartPosition(for: index) + return startPosition.createItemRange(items[startPosition]) + } +} +private extension Matrix { + func getStartPosition(for index: Int) -> Position { + let rowIndex = items.firstIndex(where: { $0.contains(where: { $0.index == index }) }) ?? 0 + let columnIndex = items[rowIndex].firstIndex(where: { $0.index == index }) ?? 0 + return .init(row: rowIndex, column: columnIndex) + } +} + +// MARK: - Getting Column Heights +extension Matrix { + func getHeights() -> [[CGFloat]] { + var array: [[CGFloat]] = .init(repeating: .init(repeating: 0, count: numberOfColumns), count: items.count) + + for row in 0.. 0 ? array[position.previousRow()] : 0 + + array[position] = currentValue + previousRowPositionValue + } + func updateValuesForMultigridItem(_ position: Position, _ item: Item, _ array: inout [[CGFloat]]) { if item.columns > 1 { + let range = getStartPosition(for: item.index).createItemRange(item) + + guard range.end.column == position.column else { return } + + let maxValue = array[position.row][range.columns].max() ?? 0 + range.columns.forEach { array[position.row][$0] = maxValue } + }} +} + +// MARK: - Others +extension Matrix { + var itemsSpacing: CGFloat { config.spacing.vertical } + var policy: InsertionPolicy { config.insertionPolicy } + var numberOfColumns: Int { config.numberOfColumns } +} + + +// MARK: - Helpers +fileprivate extension [[Matrix.Item]] { + init(numberOfColumns: Int) { self = [.empty(numberOfColumns)] } + mutating func insertEmptyRow(numberOfColumns: Int) { append(.empty(numberOfColumns)) } +} +fileprivate extension [[Matrix.Item]] { + func contains(_ element: Matrix.Item) -> Bool { joined().contains(where: { $0.index == element.index }) } + func pickingBest() -> [Matrix.Item] { self.min(by: { $0.heightsDifference < $1.heightsDifference }) ?? [] } +} +fileprivate extension [[Matrix.Item]] { + var lastItem: [Matrix.Item] { + get { last ?? [] } + set { self[count - 1] = newValue } + } +} + +fileprivate extension [Matrix.Item] { + static func empty(_ numberOfColumns: Int) -> Self { .init(repeating: .init(index: -1, value: 0, columns: 1), count: numberOfColumns) } +} +fileprivate extension [Matrix.Item] { + var heightsDifference: CGFloat { + let min = self.min(by: { $0.value < $1.value })?.value ?? 0 + let max = self.max(by: { $0.value > $1.value })?.value ?? 0 + + return max - min + } + var columns: Int { reduce(0, { $0 + $1.columns }) } +} + +fileprivate extension [[CGFloat]] { + subscript(position: Matrix.Position) -> CGFloat { + get { self[position.row][position.column] } + set { self[position.row][position.column] = newValue } + } +} diff --git a/Sources/Internal/Other/AnyGridElement.swift b/Sources/Internal/Other/AnyGridElement.swift new file mode 100644 index 0000000..d3af690 --- /dev/null +++ b/Sources/Internal/Other/AnyGridElement.swift @@ -0,0 +1,31 @@ +// +// AnyGridElement.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +struct AnyGridElement: GridElement { + @State var height: CGFloat? = nil + let columns: Int + private let _body: AnyView + + + var body: some View { _body } + init(_ element: some View, numberOfColumns: Int? = nil) { + self.columns = Self.getNumberOfColumns(element, numberOfColumns) + self._body = AnyView(element) + } +} +private extension AnyGridElement { + static func getNumberOfColumns(_ element: some View, _ numberOfColumns: Int?) -> Int { + if let element = element as? any GridElement { return element.columns } + return numberOfColumns ?? 1 + } +} diff --git a/Sources/Internal/Protocols/Configurable.swift b/Sources/Internal/Protocols/Configurable.swift new file mode 100644 index 0000000..39a6b1d --- /dev/null +++ b/Sources/Internal/Protocols/Configurable.swift @@ -0,0 +1,19 @@ +// +// Configurable.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +public protocol Configurable { init() } +extension Configurable { + func changing(path: WritableKeyPath, to value: T) -> Self { + var clone = self + clone[keyPath: path] = value + return clone + } +} diff --git a/Sources/Internal/View Modifiers/HeightReader.swift b/Sources/Internal/View Modifiers/HeightReader.swift new file mode 100644 index 0000000..a7087a8 --- /dev/null +++ b/Sources/Internal/View Modifiers/HeightReader.swift @@ -0,0 +1,30 @@ +// +// HeightReader.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension View { + func readHeight(onChange action: @escaping (CGFloat) -> ()) -> some View { modifier(Modifier(onHeightChange: action)) } +} + +// MARK: - Implementation +fileprivate struct Modifier: ViewModifier { + let onHeightChange: (CGFloat) -> () + + func body(content: Content) -> some View { content + .background( + GeometryReader { geo -> Color in + DispatchQueue.main.async { onHeightChange(geo.size.height) } + return Color.clear + } + ) + } +} diff --git a/Sources/Internal/Views/GridView.swift b/Sources/Internal/Views/GridView.swift new file mode 100644 index 0000000..127d82b --- /dev/null +++ b/Sources/Internal/Views/GridView.swift @@ -0,0 +1,115 @@ +// +// GridView.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +public struct GridView: View { + @State var matrix: Matrix + var elements: [AnyGridElement] + var config: Config + + + public var body: some View { + GeometryReader { reader in + ZStack(alignment: .topLeading) { + ForEach(0.. some View { + elements[index] + .readHeight { saveHeight($0, index) } + .fixedSize(horizontal: false, vertical: true) + .alignmentGuide(.top) { handleTopAlignmentGuide(index, $0, reader) } + .alignmentGuide(.leading) { handleLeadingAlignmentGuide(index, $0, reader) } + .frame(width: calculateItemWidth(index, reader.size.width), height: elements[index].height) + } +} + +// MARK: - Reading Height +private extension GridView { + func saveHeight(_ value: CGFloat, _ index: Int) { + elements[index].height = value + } +} + +// MARK: - Vertical Alignment +private extension GridView { + func handleTopAlignmentGuide(_ index: Int, _ dimensions: ViewDimensions, _ reader: GeometryProxy) -> CGFloat { + insertItem(index, dimensions.height) + + let topPadding = getTopPaddingValue(index) + return topPadding + } +} +private extension GridView { + func insertItem(_ index: Int, _ value: CGFloat) { DispatchQueue.main.async { + let item = Matrix.Item(index: index, value: value, columns: elements[index].columns) + matrix.insert(item, isLast: index == elements.count - 1) + }} + func getTopPaddingValue(_ index: Int) -> CGFloat { + let range = matrix.getRange(for: index) + + guard range.start.row > 0 else { return 0 } + + let heights = matrix.getHeights()[range.start.row - 1] + let itemTopPadding = heights[range.columns].max() ?? 0 + return -itemTopPadding + } +} + +// MARK: - Horizontal Alignment +private extension GridView { + func handleLeadingAlignmentGuide(_ index: Int, _ dimensions: ViewDimensions, _ reader: GeometryProxy) -> CGFloat { + let availableWidth = reader.size.width + let itemPadding = calculateItemLeadingPadding(index, availableWidth) + return itemPadding + } +} +private extension GridView { + func calculateItemLeadingPadding(_ index: Int, _ availableWidth: CGFloat) -> CGFloat { + let columnNumber = matrix.getRange(for: index).start.column + let singleColumnWidth = calculateSingleColumnWidth(availableWidth) + + let rawColumnsPaddingValue = singleColumnWidth * columnNumber.toCGFloat() + let rawSpacingPaddingValue = (columnNumber.toCGFloat() - 1) * config.spacing.horizontal + + let rawPaddingValue = rawColumnsPaddingValue + rawSpacingPaddingValue + return -rawPaddingValue + } +} + +// MARK: - Dimensions +private extension GridView { + func calculateItemWidth(_ index: Int, _ availableWidth: CGFloat) -> CGFloat { + let itemColumns = elements[index].columns + let singleColumnWidth = calculateSingleColumnWidth(availableWidth) + + let fixedItemWidth = singleColumnWidth * itemColumns.toCGFloat() + let additionalHorizontalSpacing = (itemColumns.toCGFloat() - 1) * config.spacing.horizontal + return fixedItemWidth + additionalHorizontalSpacing + } + func calculateContentHeight() -> CGFloat { matrix.getHeights().flatMap { $0 }.max() ?? 0 } +} +private extension GridView { + func calculateSingleColumnWidth(_ availableWidth: CGFloat) -> CGFloat { + let totalSpacingValue = getHorizontalSpacingTotalValue() + let itemsWidth = availableWidth - totalSpacingValue + return itemsWidth / matrix.numberOfColumns.toCGFloat() + } + func getHorizontalSpacingTotalValue() -> CGFloat { + let numberOfSpaces = matrix.numberOfColumns - 1 + return numberOfSpaces.toCGFloat() * config.spacing.horizontal + } +} diff --git a/Sources/Public/Configurables/Public+GridViewConfig.swift b/Sources/Public/Configurables/Public+GridViewConfig.swift new file mode 100644 index 0000000..5cae0b9 --- /dev/null +++ b/Sources/Public/Configurables/Public+GridViewConfig.swift @@ -0,0 +1,33 @@ +// +// Public+GridViewConfig.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +// MARK: - Policies +public extension GridView.Config { + func insertionPolicy(_ value: InsertionPolicy) -> Self { changing(path: \.insertionPolicy, to: value) } +} + +// MARK: - Composition +public extension GridView.Config { + func columns(_ value: Int) -> Self { changing(path: \.numberOfColumns, to: value) } + func verticalSpacing(_ value: CGFloat) -> Self { changing(path: \.spacing.vertical, to: value) } + func horizontalSpacing(_ value: CGFloat) -> Self { changing(path: \.spacing.horizontal, to: value) } +} + + +// MARK: - Internal +extension GridView { public struct Config: Configurable { public init() {} + private(set) var insertionPolicy: InsertionPolicy = .ordered + + private(set) var numberOfColumns: Int = 2 + private(set) var spacing: (vertical: CGFloat, horizontal: CGFloat) = (8, 8) +}} diff --git a/Sources/Public/Enumerations/Public+InsertionPolicy.swift b/Sources/Public/Enumerations/Public+InsertionPolicy.swift new file mode 100644 index 0000000..c65f5d6 --- /dev/null +++ b/Sources/Public/Enumerations/Public+InsertionPolicy.swift @@ -0,0 +1,12 @@ +// +// Public+InsertionPolicy.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +public enum InsertionPolicy { case ordered, fill } diff --git a/Sources/Public/Extensions/Public+GridView.swift b/Sources/Public/Extensions/Public+GridView.swift new file mode 100644 index 0000000..d044980 --- /dev/null +++ b/Sources/Public/Extensions/Public+GridView.swift @@ -0,0 +1,21 @@ +// +// Public+GridView.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +// MARK: - Initialisers +extension GridView { + public init(_ data: Data, id: KeyPath, @ViewBuilder content: @escaping (Data.Element) -> any View, configBuilder: (Config) -> Config = { $0 }) { self.init( + matrix: .init(configBuilder(.init())), + elements: data.map { .init(content($0)) }, + config: configBuilder(.init()) + )} +} diff --git a/Sources/Public/Extensions/Public+View.swift b/Sources/Public/Extensions/Public+View.swift new file mode 100644 index 0000000..2a5747c --- /dev/null +++ b/Sources/Public/Extensions/Public+View.swift @@ -0,0 +1,16 @@ +// +// Public+View.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +public extension View { + func columns(_ value: Int) -> some GridElement { AnyGridElement(self, numberOfColumns: value) } +} diff --git a/Sources/Public/Protocols/Public+GridElement.swift b/Sources/Public/Protocols/Public+GridElement.swift new file mode 100644 index 0000000..7d7483a --- /dev/null +++ b/Sources/Public/Protocols/Public+GridElement.swift @@ -0,0 +1,16 @@ +// +// Public+GridElement.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +public protocol GridElement: View { + var columns: Int { get } +} diff --git a/Tests/Cases/Matrix_Test.swift b/Tests/Cases/Matrix_Test.swift new file mode 100644 index 0000000..2042067 --- /dev/null +++ b/Tests/Cases/Matrix_Test.swift @@ -0,0 +1,83 @@ +// +// Matrix_Test.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import XCTest +@testable import GridView + +final class Matrix_Test: XCTestCase { + var matrix: Matrix = .init(columns: 4, itemsSpacing: 8, policy: .fill) +} + +// MARK: - Inserting +extension Matrix_Test { + func testInsertValue_WhenMatrixIsEmpty() { + matrix.insert(.init(index: 0, value: 100)) + + let result: [[CGFloat]] = matrix.items.map { $0.map(\.value) } + let expectedResult: [[CGFloat]] = [[100, 0, 0, 0]] + + XCTAssertEqual(result, expectedResult) + } + func testInsertValue_WhenItemWithIndexIsPresent_ShouldNotInsertItem() { + matrix.insert(.init(index: 0, value: 100)) + matrix.insert(.init(index: 0, value: 200)) + + let result = matrix.items.flatMap { $0 }.filter { !$0.isEmpty }.count + let expectedResult = 1 + + XCTAssertEqual(result, expectedResult) + } + func testInsertValue_InsertManyItemsOfDifferentIndexes_ShouldInsertItems() { + for index in 0..<5 { matrix.insert(.init(index: index, value: 100)) } + + let result = matrix.items.flatMap { $0 }.filter { !$0.isEmpty }.count + let expectedResult = 5 + + XCTAssertEqual(result, expectedResult) + } + func testInsertValue_InsertManyItemsOfDifferentIndexes_ShouldAddNewRow() { + for index in 0..<5 { matrix.insert(.init(index: index, value: 100)) } + + let result = matrix.items.count + let expectedResult = 2 + + XCTAssertEqual(result, expectedResult) + } + func testInsertValue_WhenMatrixHasOneRowFilled_OrderedPolicy_ShouldMatchPattern() { + matrix = .init(columns: 4, itemsSpacing: 8, policy: .ordered) + for index in 0..<10 { matrix.insert(.init(index: index, value: entryValues[index])) } + + let result: [[CGFloat]] = matrix.items.map { $0.map { $0.value } } + let expectedResult: [[CGFloat]] = [ + [100, 200, 50, 100], + [150, 100, 50, 100], + [100, 150, 0, 0] + ] + + XCTAssertEqual(result, expectedResult) + } + func testInsertValue_WhenMatrixHasOneRowFilled_FillPolicy_ShouldMatchPattern() { + for index in 0..<10 { matrix.insert(.init(index: index, value: entryValues[index])) } + + let result: [[CGFloat]] = matrix.items.map { $0.map { $0.value } } + let expectedResult: [[CGFloat]] = [ + [100, 200, 50, 100], + [100, 150, 150, 50], + [100, 0, 0, 100] + ] + + XCTAssertEqual(result, expectedResult) + } +} + +private extension Matrix_Test { + var entryValues: [CGFloat] { [100, 200, 50, 100, 150, 100, 50, 100, 100, 150] } +} diff --git a/Tests/Helpers/Array++.swift b/Tests/Helpers/Array++.swift new file mode 100644 index 0000000..330a63f --- /dev/null +++ b/Tests/Helpers/Array++.swift @@ -0,0 +1,29 @@ +// +// Array++.swift of MijickGridView +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension [[CGFloat]] { + func toString() -> String { + var text = "" + + for row in 0..