├── .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://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Florin-vr%2FJustifiableFlowLayout%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/lorin-vr/JustifiableFlowLayout) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Florin-vr%2FJustifiableFlowLayout%2Fbadge%3Ftype%3Dplatforms)](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 | ![Animation showing the addition and removal of items simultaneously](demo/add-remove-animate.gif) 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 | --------------------------------------------------------------------------------