├── .spi.yml
├── demo
├── add-remove-animate.gif
├── default-similar-widths.png
├── default-varied-widths.png
├── justified-similar-widths.png
└── justified-varied-widths.png
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Package.swift
├── LICENSE.txt
├── README.md
└── Sources
└── JustifiableFlowLayout
└── JustifiableFlowLayout.swift
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [JustifiableFlowLayout]
--------------------------------------------------------------------------------
/demo/add-remove-animate.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lorin-vr/JustifiableFlowLayout/HEAD/demo/add-remove-animate.gif
--------------------------------------------------------------------------------
/demo/default-similar-widths.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lorin-vr/JustifiableFlowLayout/HEAD/demo/default-similar-widths.png
--------------------------------------------------------------------------------
/demo/default-varied-widths.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lorin-vr/JustifiableFlowLayout/HEAD/demo/default-varied-widths.png
--------------------------------------------------------------------------------
/demo/justified-similar-widths.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lorin-vr/JustifiableFlowLayout/HEAD/demo/justified-similar-widths.png
--------------------------------------------------------------------------------
/demo/justified-varied-widths.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lorin-vr/JustifiableFlowLayout/HEAD/demo/justified-varied-widths.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | *.xcbkptlist
6 | xcuserdata/
7 | DerivedData/
8 | .swiftpm/config/registries.json
9 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
10 | .netrc
11 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.7
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: "JustifiableFlowLayout",
8 | platforms: [
9 | .iOS(.v16),
10 | .macOS(.v13)
11 | ],
12 | products: [
13 | .library(
14 | name: "JustifiableFlowLayout",
15 | targets: ["JustifiableFlowLayout"]),
16 | ],
17 | dependencies: [],
18 | targets: [
19 | .target(
20 | name: "JustifiableFlowLayout",
21 | dependencies: [])
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Lorin van Riel
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://swiftpackageindex.com/lorin-vr/JustifiableFlowLayout) [](https://swiftpackageindex.com/lorin-vr/JustifiableFlowLayout)
2 |
3 | # JustifiableFlowLayout
4 |
5 | A SwiftUI layout that arranges a collection of items from left to right and top to bottom, in sequential order.
6 |
7 | By default it is a standard flow layout, where an exact spacing is applied between items. For example:
8 |
9 |
10 |
11 | It can also be used as a 'justified' flow layout, where the widest item determines the spacing and alignment, creating an even appearance of neat columns. For example:
12 |
13 |
14 |
15 | Animation is supported. Here's an example of adding and removing items simultaneously:
16 |
17 | 
18 |
19 | ## Usage
20 |
21 | Sample:
22 |
23 | ```swift
24 | import JustifiableFlowLayout
25 |
26 | let uniqueWords = ["cloud", "hill", "thunder", "if", "run", "fright", "just", "twenty", "agent", "theatre", "ink", "so", "rig", "week", "range", "today"]
27 |
28 | JustifiableFlowLayout(minSpacing: 10, shouldJustify: false) {
29 | ForEach(uniqueWords, id: \.self) { word in
30 | Text(word)
31 | .padding(10)
32 | .border(Color.mint, width: 2)
33 | }
34 | }
35 |
36 | ```
37 |
38 | `JustifiableFlowLayout` also works well inside a `ScrollView`.
39 |
40 | ## Installation
41 |
42 | ### Swift package manager
43 |
44 | Add the swift package to your project:
45 |
46 | ```
47 | https://github.com/lorin-vr/JustifiableFlowLayout.git
48 | ```
49 |
50 | ## Discussion
51 |
52 | `JustifiableFlowLayout` can be used as an alternative to SwiftUI collection view layouts like `Grid` and `LazyVGrid`.
53 |
54 | Unlike `LazyVGrid`, `JustifiableFlowLayout` lays out its items all at once. This potentially allows for more performant layout and animation, but may not be suitable for a large collection of items.
55 |
56 | Unlike `Grid`, you don't specify the number of rows required. `JustifiableFlowLayout` will decide how many rows it needs based on the item sizes. When using `JustifiableFlowLayout`, there is no need to define `GridItem` or `GridRow`.
57 |
--------------------------------------------------------------------------------
/Sources/JustifiableFlowLayout/JustifiableFlowLayout.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A Layout that lays out its subviews left to right and top to bottom, in sequential order. It can use exact spacing, or justify by the width of the widest subview.
4 | ///
5 | /// By default it is a standard flow layout, where an exact spacing is applied between items.
6 | ///
7 | /// It can also be used as a 'justified' flow layout, where the widest item determines the spacing and alignment, creating an even appearance of neat columns.
8 | public struct JustifiableFlowLayout: Layout {
9 |
10 | private let minSpacing: CGFloat
11 |
12 | private let shouldJustify: Bool
13 |
14 | /// Creates a JustifiableFlowLayout.
15 | ///
16 | /// - Parameters:
17 | /// - minSpacing: The minimum spacing between subviews. Defaults to 4.0.
18 | /// - shouldJustify: If true, the width of the widest subview is used to align the subviews into columns. If false, the `minSpacing` is applied precisely between items. Defaults to false.
19 | public init(minSpacing: CGFloat = 4, shouldJustify: Bool = false) {
20 | self.minSpacing = minSpacing
21 | self.shouldJustify = shouldJustify
22 | }
23 |
24 | public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
25 | let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
26 |
27 | // The widest child will be used to determine placement
28 | let justifiedWidth = shouldJustify ? maxWidth(sizes: sizes) : 0
29 |
30 | // The total width offered to the grid
31 | // If the proposed width is unspecified, let the system replace it with an appropriate guess
32 | let maxLineWidth = proposal.replacingUnspecifiedDimensions().width
33 |
34 | // The total height needed by the grid, to be calculated
35 | var totalHeight: CGFloat = 0
36 |
37 | // The total width needed by the grid, to be calculated
38 | var totalWidth: CGFloat = 0
39 |
40 | // Dimensions of the current line
41 | var lineWidth: CGFloat = 0
42 | var lineHeight: CGFloat = 0
43 |
44 | for size in sizes {
45 | let itemWidth = shouldJustify ? justifiedWidth : size.width
46 |
47 | // if the item is too wide to fit on the current line...
48 | if lineWidth + itemWidth > maxLineWidth {
49 | // ... move to a new line
50 |
51 | totalHeight += lineHeight + minSpacing
52 | lineWidth = itemWidth + minSpacing
53 | lineHeight = size.height
54 | } else {
55 | // else update the width and height of the current line
56 | lineWidth += itemWidth + minSpacing
57 | lineHeight = max(lineHeight, size.height)
58 | }
59 |
60 | // The final spacing can be removed if this is the end of the line
61 | totalWidth = max(totalWidth, lineWidth - minSpacing)
62 | }
63 |
64 | totalHeight += lineHeight
65 |
66 | return CGSize(width: totalWidth, height: totalHeight)
67 | }
68 |
69 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
70 | let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
71 |
72 | // The widest child will be used to determine placement
73 | let justifiedWidth = shouldJustify ? maxWidth(sizes: sizes) : 0
74 |
75 | // The total width offered to the grid
76 | // If the proposed width is unspecified, let the system replace it with an appropriate guess
77 | let maxLineWidth = proposal.replacingUnspecifiedDimensions().width
78 | let maxX = min(bounds.minX + maxLineWidth, bounds.maxX)
79 |
80 | // x and y positions to place the next item
81 | // start laying out from the rectangle's top left corner
82 | var lineX = bounds.minX
83 | var lineY = bounds.minY
84 |
85 | // Height of the current line
86 | var lineHeight: CGFloat = 0
87 |
88 | for index in subviews.indices {
89 | let itemWidth = shouldJustify ? justifiedWidth : sizes[index].width
90 |
91 | // if the item is too wide to fit on the current line...
92 | if lineX + itemWidth > maxX {
93 | // ... move to a new line
94 | lineY += lineHeight + minSpacing
95 | lineHeight = 0
96 | lineX = bounds.minX
97 | }
98 |
99 | // place the item, anchored at its centre
100 | subviews[index].place(
101 | at: .init(
102 | x: lineX + itemWidth / 2, // centreX
103 | y: lineY + sizes[index].height / 2 // centreY
104 | ),
105 | anchor: .center,
106 | proposal: ProposedViewSize(sizes[index])
107 | )
108 |
109 | // make sure the line height is big enough to fit the latest item
110 | lineHeight = max(lineHeight, sizes[index].height)
111 |
112 | // update the width of the line
113 | lineX += itemWidth + minSpacing
114 | }
115 | }
116 |
117 | // MARK: Private helpers
118 |
119 | private func maxWidth(sizes: [CGSize]) -> CGFloat {
120 | sizes
121 | .map { size in size.width }
122 | .max() ?? 0
123 | }
124 | }
125 |
126 | // MARK: - Previews
127 |
128 | #if DEBUG
129 |
130 | enum SampleData {
131 | static let uniqueLetters = ["r", "s", "t", "u", "v", "w", "x", "y", "z", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"]
132 |
133 | static let uniqueWords = ["cloud", "hill", "thunder", "if", "run", "fright", "just", "twenty", "agent", "theatre", "ink", "so", "rig", "week", "range", "today"]
134 | }
135 |
136 | #Preview("Default, similar widths") {
137 | JustifiableFlowLayout(minSpacing: 10, shouldJustify: false) {
138 | ForEach(SampleData.uniqueLetters, id: \.self) { letter in
139 | Text(letter)
140 | .font(.largeTitle)
141 | .padding(20)
142 | .background(Color.yellow.opacity(0.3))
143 | }
144 | }
145 | .padding()
146 | }
147 |
148 | #Preview("Justified, similar widths") {
149 | JustifiableFlowLayout(minSpacing: 10, shouldJustify: true) {
150 | ForEach(SampleData.uniqueLetters, id: \.self) { letter in
151 | Text(letter)
152 | .font(.largeTitle)
153 | .padding(20)
154 | .background(Color.yellow.opacity(0.3))
155 | }
156 | }
157 | .padding()
158 | }
159 |
160 | #Preview("Default, varied widths") {
161 | JustifiableFlowLayout(minSpacing: 10, shouldJustify: false) {
162 | ForEach(SampleData.uniqueWords, id: \.self) { word in
163 | Text(word)
164 | .font(.title2)
165 | .padding(20)
166 | .border(Color.mint, width: 2.5)
167 | }
168 | }
169 | .padding()
170 | }
171 |
172 | #Preview("Justified, varied widths") {
173 | JustifiableFlowLayout(minSpacing: 10, shouldJustify: true) {
174 | ForEach(SampleData.uniqueWords, id: \.self) { word in
175 | Text(word)
176 | .font(.title2)
177 | .padding(20)
178 | .border(Color.mint, width: 2.5)
179 | }
180 | }
181 | .padding()
182 | }
183 | #endif
184 |
--------------------------------------------------------------------------------