├── .spi.yml ├── Resources ├── hflow.png ├── vflow.png ├── hflow-rtl.png ├── hflow-top.png ├── hflow-center.png ├── hflow-newline.png ├── hflow-spacing.png ├── hflow-distributed.png ├── hflow-flexibility.png ├── hflow-justified.png └── hflow-linebreak.png ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── WorkspaceSettings.xcsettings ├── Sources └── Flow │ ├── Internal │ ├── Utils.swift │ ├── Protocols.swift │ ├── Size.swift │ ├── LineBreaking.swift │ └── Layout.swift │ ├── HFlowLayout.swift │ ├── VFlowLayout.swift │ ├── Support.swift │ ├── Example │ └── ContentView.swift │ ├── HFlow.swift │ └── VFlow.swift ├── Tests └── FlowTests │ ├── Utils │ ├── Operators.swift │ └── TestSubview.swift │ ├── LineBreakingTests.swift │ └── FlowTests.swift ├── Package@swift-6.swift ├── Package.swift ├── LICENSE.txt └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Flow] -------------------------------------------------------------------------------- /Resources/hflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow.png -------------------------------------------------------------------------------- /Resources/vflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/vflow.png -------------------------------------------------------------------------------- /Resources/hflow-rtl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-rtl.png -------------------------------------------------------------------------------- /Resources/hflow-top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-top.png -------------------------------------------------------------------------------- /Resources/hflow-center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-center.png -------------------------------------------------------------------------------- /Resources/hflow-newline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-newline.png -------------------------------------------------------------------------------- /Resources/hflow-spacing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-spacing.png -------------------------------------------------------------------------------- /Resources/hflow-distributed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-distributed.png -------------------------------------------------------------------------------- /Resources/hflow-flexibility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-flexibility.png -------------------------------------------------------------------------------- /Resources/hflow-justified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-justified.png -------------------------------------------------------------------------------- /Resources/hflow-linebreak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tevelee/SwiftUI-Flow/HEAD/Resources/hflow-linebreak.png -------------------------------------------------------------------------------- /.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/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/Flow/Internal/Utils.swift: -------------------------------------------------------------------------------- 1 | extension Sequence { 2 | @inlinable 3 | func adjacentPairs() -> some Sequence<(Element, Element)> { 4 | zip(self, self.dropFirst()) 5 | } 6 | 7 | @inlinable 8 | func sum(of block: (Element) -> Result) -> Result { 9 | reduce(into: .zero) { $0 += block($1) } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/FlowTests/Utils/Operators.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | 4 | infix operator ×: MultiplicationPrecedence 5 | 6 | func × (lhs: CGFloat, rhs: CGFloat) -> CGSize { 7 | CGSize(width: lhs, height: rhs) 8 | } 9 | 10 | func × (lhs: CGFloat, rhs: CGFloat) -> TestSubview { 11 | .init(size: .init(width: lhs, height: rhs)) 12 | } 13 | 14 | func × (lhs: CGFloat, rhs: CGFloat) -> ProposedViewSize { 15 | .init(width: lhs, height: rhs) 16 | } 17 | 18 | infix operator ...: RangeFormationPrecedence 19 | 20 | func ... (lhs: CGSize, rhs: CGSize) -> TestSubview { 21 | TestSubview(minSize: lhs, idealSize: lhs, maxSize: rhs) 22 | } 23 | 24 | let inf: CGFloat = .infinity 25 | 26 | func repeated(_ factory: @autoclosure () -> T, times: Int) -> [T] { 27 | (1...times).map { _ in factory() } 28 | } 29 | -------------------------------------------------------------------------------- /Package@swift-6.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Flow", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13), 10 | .tvOS(.v16), 11 | .watchOS(.v9), 12 | .visionOS(.v1) 13 | ], 14 | products: [ 15 | .library(name: "Flow", targets: ["Flow"]) 16 | ], 17 | targets: [ 18 | .target( 19 | name: "Flow", 20 | exclude: ["Example"], 21 | swiftSettings: [ 22 | .enableUpcomingFeature("ExistentialAny"), 23 | .swiftLanguageMode(.v6) 24 | ] 25 | ), 26 | .testTarget( 27 | name: "FlowTests", 28 | dependencies: ["Flow"], 29 | swiftSettings: [ 30 | .enableUpcomingFeature("ExistentialAny"), 31 | .swiftLanguageMode(.v6) 32 | ] 33 | ) 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Flow", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13), 10 | .tvOS(.v16), 11 | .watchOS(.v9) 12 | ], 13 | products: [ 14 | .library(name: "Flow", targets: ["Flow"]) 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Flow", 19 | exclude: ["Example"], 20 | swiftSettings: [ 21 | .enableUpcomingFeature("ExistentialAny"), 22 | .enableExperimentalFeature("StrictConcurrency") 23 | ] 24 | ), 25 | .testTarget( 26 | name: "FlowTests", 27 | dependencies: ["Flow"], 28 | swiftSettings: [ 29 | .enableUpcomingFeature("ExistentialAny"), 30 | .enableExperimentalFeature("StrictConcurrency") 31 | ] 32 | ) 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Laszlo Teveli 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 | -------------------------------------------------------------------------------- /Sources/Flow/Internal/Protocols.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @usableFromInline 4 | protocol Subviews: RandomAccessCollection where Element: Subview, Index == Int {} 5 | 6 | extension LayoutSubviews: Subviews {} 7 | 8 | @usableFromInline 9 | protocol Subview { 10 | var spacing: ViewSpacing { get } 11 | var priority: Double { get } 12 | func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize 13 | func dimensions(_ proposal: ProposedViewSize) -> any Dimensions 14 | func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) 15 | subscript(key: K.Type) -> K.Value { get } 16 | } 17 | 18 | extension LayoutSubview: Subview { 19 | @usableFromInline 20 | func dimensions(_ proposal: ProposedViewSize) -> any Dimensions { 21 | dimensions(in: proposal) 22 | } 23 | } 24 | 25 | @usableFromInline 26 | protocol Dimensions { 27 | var width: CGFloat { get } 28 | var height: CGFloat { get } 29 | 30 | subscript(guide: HorizontalAlignment) -> CGFloat { get } 31 | subscript(guide: VerticalAlignment) -> CGFloat { get } 32 | } 33 | extension ViewDimensions: Dimensions {} 34 | 35 | extension Dimensions { 36 | @usableFromInline 37 | func size(on axis: Axis) -> Size { 38 | Size( 39 | breadth: value(on: axis), 40 | depth: value(on: axis.perpendicular) 41 | ) 42 | } 43 | 44 | @usableFromInline 45 | func value(on axis: Axis) -> CGFloat { 46 | switch axis { 47 | case .horizontal: width 48 | case .vertical: height 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/FlowTests/LineBreakingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Flow 3 | 4 | final class LineBreakingTests: XCTestCase { 5 | func test_flow() throws { 6 | // Given 7 | let sut = FlowLineBreaker() 8 | 9 | // When 10 | let breakpoints = sut.wrapItemsToLines( 11 | items: [ 12 | .init(size: .rigid(10), spacing: 10), 13 | .init(size: .rigid(20), spacing: 10), 14 | .init(size: .rigid(30), spacing: 10), 15 | .init(size: .rigid(40), spacing: 10), 16 | .init(size: .rigid(20), spacing: 10), 17 | .init(size: .rigid(30), spacing: 10) 18 | ], 19 | in: 80 20 | ) 21 | 22 | // Then 23 | XCTAssertEqual(breakpoints, [ 24 | [.init(index: 0, size: 10, leadingSpace: 0), .init(index: 1, size: 20, leadingSpace: 10), .init(index: 2, size: 30, leadingSpace: 10)], 25 | [.init(index: 3, size: 40, leadingSpace: 0), .init(index: 4, size: 20, leadingSpace: 10)], 26 | [.init(index: 5, size: 30, leadingSpace: 0)] 27 | ]) 28 | } 29 | 30 | func test_knuth_plass() throws { 31 | // Given 32 | let sut = KnuthPlassLineBreaker() 33 | 34 | // When 35 | let breakpoints = sut.wrapItemsToLines( 36 | items: [ 37 | .init(size: .rigid(10), spacing: 10), 38 | .init(size: .rigid(20), spacing: 10), 39 | .init(size: .rigid(30), spacing: 10), 40 | .init(size: .rigid(40), spacing: 10), 41 | .init(size: .rigid(20), spacing: 10), 42 | .init(size: .rigid(30), spacing: 10) 43 | ], 44 | in: 80 45 | ) 46 | 47 | // Then 48 | XCTAssertEqual(breakpoints, [ 49 | [.init(index: 0, size: 10, leadingSpace: 0), .init(index: 1, size: 20, leadingSpace: 10), .init(index: 2, size: 30, leadingSpace: 10)], 50 | [.init(index: 3, size: 40, leadingSpace: 0)], 51 | [.init(index: 4, size: 20, leadingSpace: 0), .init(index: 5, size: 30, leadingSpace: 10)] 52 | ]) 53 | } 54 | } 55 | 56 | private extension ClosedRange { 57 | static func rigid(_ value: Bound) -> Self { 58 | value ... value 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Flow/Internal/Size.swift: -------------------------------------------------------------------------------- 1 | import CoreFoundation 2 | import SwiftUI 3 | 4 | @usableFromInline 5 | struct Size: Sendable { 6 | @usableFromInline 7 | var breadth: CGFloat 8 | @usableFromInline 9 | var depth: CGFloat 10 | 11 | @usableFromInline 12 | init(breadth: CGFloat, depth: CGFloat) { 13 | self.breadth = breadth 14 | self.depth = depth 15 | } 16 | 17 | @usableFromInline 18 | static let zero = Size(breadth: 0, depth: 0) 19 | 20 | @usableFromInline 21 | subscript(axis: Axis) -> CGFloat { 22 | get { 23 | self[keyPath: keyPath(on: axis)] 24 | } 25 | set { 26 | self[keyPath: keyPath(on: axis)] = newValue 27 | } 28 | } 29 | 30 | @usableFromInline 31 | func keyPath(on axis: Axis) -> WritableKeyPath { 32 | switch axis { 33 | case .horizontal: \.breadth 34 | case .vertical: \.depth 35 | } 36 | } 37 | } 38 | 39 | extension Axis { 40 | @usableFromInline 41 | var perpendicular: Axis { 42 | switch self { 43 | case .horizontal: .vertical 44 | case .vertical: .horizontal 45 | } 46 | } 47 | } 48 | 49 | // MARK: Fixed orientation -> orientation independent 50 | 51 | protocol FixedOrientation2DCoordinate { 52 | init(size: Size, axis: Axis) 53 | func value(on axis: Axis) -> CGFloat 54 | } 55 | 56 | extension FixedOrientation2DCoordinate { 57 | @inlinable 58 | func size(on axis: Axis) -> Size { 59 | Size(breadth: value(on: axis), depth: value(on: axis.perpendicular)) 60 | } 61 | } 62 | 63 | extension CGPoint: FixedOrientation2DCoordinate { 64 | @inlinable 65 | init(size: Size, axis: Axis) { 66 | self.init(x: size[axis], y: size[axis.perpendicular]) 67 | } 68 | 69 | @inlinable 70 | func value(on axis: Axis) -> CGFloat { 71 | switch axis { 72 | case .horizontal: x 73 | case .vertical: y 74 | } 75 | } 76 | } 77 | 78 | extension CGSize: FixedOrientation2DCoordinate { 79 | @inlinable 80 | init(size: Size, axis: Axis) { 81 | self.init(width: size[axis], height: size[axis.perpendicular]) 82 | } 83 | 84 | @inlinable 85 | func value(on axis: Axis) -> CGFloat { 86 | switch axis { 87 | case .horizontal: width 88 | case .vertical: height 89 | } 90 | } 91 | 92 | @inlinable 93 | static var infinity: CGSize { 94 | CGSize( 95 | width: CGFloat.infinity, 96 | height: CGFloat.infinity 97 | ) 98 | } 99 | } 100 | 101 | extension ProposedViewSize: FixedOrientation2DCoordinate { 102 | @inlinable 103 | init(size: Size, axis: Axis) { 104 | self.init(width: size[axis], height: size[axis.perpendicular]) 105 | } 106 | 107 | @inlinable 108 | func value(on axis: Axis) -> CGFloat { 109 | switch axis { 110 | case .horizontal: width ?? .infinity 111 | case .vertical: height ?? .infinity 112 | } 113 | } 114 | } 115 | 116 | extension CGRect { 117 | @inlinable 118 | func minimumValue(on axis: Axis) -> CGFloat { 119 | switch axis { 120 | case .horizontal: minX 121 | case .vertical: minY 122 | } 123 | } 124 | 125 | @inlinable 126 | func maximumValue(on axis: Axis) -> CGFloat { 127 | switch axis { 128 | case .horizontal: maxX 129 | case .vertical: maxY 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/Flow/HFlowLayout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A layout that arranges its children in a horizontally flowing manner. 4 | @frozen 5 | public struct HFlowLayout: Sendable { 6 | @usableFromInline 7 | let layout: FlowLayout 8 | 9 | /// Creates a horizontal flow with the given spacing and vertical alignment. 10 | /// 11 | /// - Parameters: 12 | /// - alignment: The guide for aligning the subviews in this flow. This 13 | /// guide has the same vertical screen coordinate for every child view. 14 | /// - spacing: The distance between adjacent subviews, or `nil` if you 15 | /// want the flow to choose a default distance for each pair of subviews. 16 | /// - justified: Whether the layout should fill the remaining 17 | /// available space in each row by stretching spaces. 18 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 19 | /// mode tries to distribute items more evenly by minimizing the empty 20 | /// spaces left in each row, while respecting their order. 21 | @inlinable 22 | public init( 23 | alignment: VerticalAlignment = .center, 24 | itemSpacing: CGFloat? = nil, 25 | rowSpacing: CGFloat? = nil, 26 | justified: Bool = false, 27 | distributeItemsEvenly: Bool = false 28 | ) { 29 | self.init( 30 | horizontalAlignment: .leading, 31 | verticalAlignment: alignment, 32 | horizontalSpacing: itemSpacing, 33 | verticalSpacing: rowSpacing, 34 | justified: justified, 35 | distributeItemsEvenly: distributeItemsEvenly 36 | ) 37 | } 38 | 39 | /// Creates a horizontal flow with the given spacing and alignment. 40 | /// 41 | /// - Parameters: 42 | /// - horizonalAlignment: The guide for aligning the subviews horizontally. 43 | /// - horizonalSpacing: The distance between subviews on the horizontal axis. 44 | /// - verticalAlignment: The guide for aligning the subviews vertically. 45 | /// - verticalSpacing: The distance between subviews on the vertical axis. 46 | /// - justified: Whether the layout should fill the remaining 47 | /// available space in each row by stretching spaces. 48 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 49 | /// mode tries to distribute items more evenly by minimizing the empty 50 | /// spaces left in each row, while respecting their order. 51 | @inlinable 52 | public init( 53 | horizontalAlignment: HorizontalAlignment, 54 | verticalAlignment: VerticalAlignment, 55 | horizontalSpacing: CGFloat? = nil, 56 | verticalSpacing: CGFloat? = nil, 57 | justified: Bool = false, 58 | distributeItemsEvenly: Bool = false 59 | ) { 60 | layout = .horizontal( 61 | horizontalAlignment: horizontalAlignment, 62 | verticalAlignment: verticalAlignment, 63 | horizontalSpacing: horizontalSpacing, 64 | verticalSpacing: verticalSpacing, 65 | justified: justified, 66 | distributeItemsEvenly: distributeItemsEvenly 67 | ) 68 | } 69 | } 70 | 71 | extension HFlowLayout: Layout { 72 | @inlinable 73 | public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { 74 | layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) 75 | } 76 | 77 | @inlinable 78 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { 79 | layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) 80 | } 81 | 82 | @inlinable 83 | public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { 84 | FlowLayoutCache(subviews, axis: .horizontal) 85 | } 86 | 87 | @inlinable 88 | public static var layoutProperties: LayoutProperties { 89 | var properties = LayoutProperties() 90 | properties.stackOrientation = .horizontal 91 | return properties 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/Flow/VFlowLayout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A layout that arranges its children in a vertically flowing manner. 4 | @frozen 5 | public struct VFlowLayout { 6 | @usableFromInline 7 | let layout: FlowLayout 8 | 9 | /// Creates a vertical flow layout with the given spacing and horizontal alignment. 10 | /// 11 | /// - Parameters: 12 | /// - alignment: The guide for aligning the subviews in this flow. This 13 | /// guide has the same vertical screen coordinate for every child view. 14 | /// - itemSpacing: The distance between adjacent subviews, or `nil` if you 15 | /// want the flow to choose a default distance for each pair of subviews. 16 | /// - columnSpacing: The distance between adjacent columns, or `nil` if you 17 | /// want the flow to choose a default distance for each pair of columns. 18 | /// - justified: Whether the layout should fill the remaining 19 | /// available space in each column by stretching spaces. 20 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 21 | /// mode tries to distribute items more evenly by minimizing the empty 22 | /// spaces left in each column, while respecting their order. 23 | @inlinable 24 | public init( 25 | alignment: HorizontalAlignment = .center, 26 | itemSpacing: CGFloat? = nil, 27 | columnSpacing: CGFloat? = nil, 28 | justified: Bool = false, 29 | distributeItemsEvenly: Bool = false 30 | ) { 31 | self.init( 32 | horizontalAlignment: alignment, 33 | verticalAlignment: .top, 34 | horizontalSpacing: columnSpacing, 35 | verticalSpacing: itemSpacing, 36 | justified: justified, 37 | distributeItemsEvenly: distributeItemsEvenly 38 | ) 39 | } 40 | 41 | /// Creates a vertical flow with the given spacing and alignment. 42 | /// 43 | /// - Parameters: 44 | /// - horizonalAlignment: The guide for aligning the subviews horizontally. 45 | /// - horizonalSpacing: The distance between subviews on the horizontal axis. 46 | /// - verticalAlignment: The guide for aligning the subviews vertically. 47 | /// - verticalSpacing: The distance between subviews on the vertical axis. 48 | /// - justified: Whether the layout should fill the remaining 49 | /// available space in each column by stretching spaces. 50 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 51 | /// mode tries to distribute items more evenly by minimizing the empty 52 | /// spaces left in each column, while respecting their order. 53 | /// - content: A view builder that creates the content of this flow. 54 | @inlinable 55 | public init( 56 | horizontalAlignment: HorizontalAlignment, 57 | verticalAlignment: VerticalAlignment, 58 | horizontalSpacing: CGFloat? = nil, 59 | verticalSpacing: CGFloat? = nil, 60 | justified: Bool = false, 61 | distributeItemsEvenly: Bool = false 62 | ) { 63 | layout = .vertical( 64 | horizontalAlignment: horizontalAlignment, 65 | verticalAlignment: verticalAlignment, 66 | horizontalSpacing: horizontalSpacing, 67 | verticalSpacing: verticalSpacing, 68 | justified: justified, 69 | distributeItemsEvenly: distributeItemsEvenly 70 | ) 71 | } 72 | } 73 | 74 | extension VFlowLayout: Layout { 75 | @inlinable 76 | public func sizeThatFits( 77 | proposal: ProposedViewSize, 78 | subviews: LayoutSubviews, 79 | cache: inout FlowLayoutCache 80 | ) -> CGSize { 81 | layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) 82 | } 83 | 84 | @inlinable 85 | public func placeSubviews( 86 | in bounds: CGRect, 87 | proposal: ProposedViewSize, 88 | subviews: LayoutSubviews, 89 | cache: inout FlowLayoutCache 90 | ) { 91 | layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) 92 | } 93 | 94 | @inlinable 95 | public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { 96 | FlowLayoutCache(subviews, axis: .vertical) 97 | } 98 | 99 | @inlinable 100 | public static var layoutProperties: LayoutProperties { 101 | var properties = LayoutProperties() 102 | properties.stackOrientation = .vertical 103 | return properties 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/Flow/Support.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// The way line breaking treats flexible items. The default behavior is `.natural`. 4 | public enum FlexibilityBehavior: Sendable { 5 | /// The layout chooses the minimum space for the view, regardless of how much it can expand 6 | case minimum 7 | /// The layout allows the views to exapand as they naturally do. 8 | case natural 9 | /// If a view can expand, it allows to "push" out other views and fill a whole row on its own. 10 | case maximum 11 | } 12 | 13 | /// Cache to store certain properties of subviews in the layout (flexibility, spacing preferences, layout priority). 14 | /// Even though it needs to be public (because it's part of the layout protocol conformance), 15 | /// it's considered an internal implementation detail. 16 | public struct FlowLayoutCache { 17 | @usableFromInline 18 | struct SubviewCache { 19 | @usableFromInline 20 | var priority: Double 21 | @usableFromInline 22 | var spacing: ViewSpacing 23 | @usableFromInline 24 | var min: Size 25 | @usableFromInline 26 | var ideal: Size 27 | @usableFromInline 28 | var max: Size 29 | @usableFromInline 30 | var layoutValues: LayoutValues 31 | @usableFromInline 32 | struct LayoutValues { 33 | @usableFromInline 34 | var shouldStartInNewLine: Bool 35 | @usableFromInline 36 | var isLineBreak: Bool 37 | @usableFromInline 38 | var flexibility: FlexibilityBehavior 39 | 40 | @inlinable 41 | init(shouldStartInNewLine: Bool, isLineBreak: Bool, flexibility: FlexibilityBehavior) { 42 | self.shouldStartInNewLine = shouldStartInNewLine 43 | self.isLineBreak = isLineBreak 44 | self.flexibility = flexibility 45 | } 46 | } 47 | 48 | @inlinable 49 | init(_ subview: some Subview, axis: Axis) { 50 | priority = subview.priority 51 | spacing = subview.spacing 52 | min = subview.dimensions(.zero).size(on: axis) 53 | ideal = subview.dimensions(.unspecified).size(on: axis) 54 | max = subview.dimensions(.infinity).size(on: axis) 55 | layoutValues = LayoutValues( 56 | shouldStartInNewLine: subview[ShouldStartInNewLineLayoutValueKey.self], 57 | isLineBreak: subview[IsLineBreakLayoutValueKey.self], 58 | flexibility: subview[FlexibilityLayoutValueKey.self] 59 | ) 60 | } 61 | } 62 | 63 | @usableFromInline 64 | let subviewsCache: [SubviewCache] 65 | 66 | @inlinable 67 | init(_ subviews: some Subviews, axis: Axis) { 68 | subviewsCache = subviews.map { 69 | SubviewCache($0, axis: axis) 70 | } 71 | } 72 | } 73 | 74 | /// A view to manually insert breaks into flow layout, allowing precise control over line breaking. 75 | public struct LineBreak: View { 76 | /// Initializes a new line break view 77 | public init() {} 78 | 79 | public var body: some View { 80 | Color.clear 81 | .frame(width: 0, height: 0) 82 | .layoutValue(key: IsLineBreakLayoutValueKey.self, value: true) 83 | } 84 | } 85 | 86 | extension View { 87 | /// Allows flow layout elements to be started on new lines, allowing precise control over line breaking. 88 | public func startInNewLine(_ enabled: Bool = true) -> some View { 89 | layoutValue(key: ShouldStartInNewLineLayoutValueKey.self, value: enabled) 90 | } 91 | 92 | /// Allows modifying the flexibility behavior of views so that flow can layout them accordingly. 93 | /// This modifier can be placed outside of flow layout too, and propagate to all flow layouts inside that view tree (using environment). 94 | /// The default flexibility of each item in a flow is `.natural`. 95 | public func flexibility(_ behavior: FlexibilityBehavior) -> some View { 96 | layoutValue(key: FlexibilityLayoutValueKey.self, value: behavior) 97 | .environment(\.flexibility, behavior) 98 | } 99 | } 100 | 101 | @usableFromInline 102 | struct ShouldStartInNewLineLayoutValueKey: LayoutValueKey { 103 | @usableFromInline 104 | static let defaultValue = false 105 | } 106 | 107 | @usableFromInline 108 | struct IsLineBreakLayoutValueKey: LayoutValueKey { 109 | @usableFromInline 110 | static let defaultValue = false 111 | } 112 | 113 | @usableFromInline 114 | struct FlexibilityLayoutValueKey: LayoutValueKey { 115 | @usableFromInline 116 | static let defaultValue: FlexibilityBehavior = .natural 117 | } 118 | 119 | @usableFromInline 120 | struct FlexibilityEnvironmentKey: EnvironmentKey { 121 | @usableFromInline 122 | static let defaultValue: FlexibilityBehavior = .natural 123 | } 124 | 125 | extension EnvironmentValues { 126 | @usableFromInline 127 | var flexibility: FlexibilityBehavior { 128 | get { self[FlexibilityEnvironmentKey.self] } 129 | set { self[FlexibilityEnvironmentKey.self] = newValue } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI Flow Layout 2 | 3 | Introduces `HFlow` and `VFlow` similar to `HStack` and `VStack`. 4 | Arranges views in lines and cuts new lines accordingly (if elements don't fit the bounding space). 5 | 6 | ## HFlow 7 | 8 | ```swift 9 | import Flow 10 | 11 | struct Colors: View { 12 | let colors: [Color] = [ 13 | .blue, 14 | .orange, 15 | .green, 16 | .yellow, 17 | .brown, 18 | .mint, 19 | .indigo, 20 | .cyan, 21 | .gray, 22 | .pink 23 | ] 24 | 25 | var body: some View { 26 | HFlow { 27 | ForEach(colors, id: \.description) { color in 28 | RoundedRectangle(cornerRadius: 10) 29 | .fill(color.gradient) 30 | .frame(width: .random(in: 40...60), height: 50) 31 | } 32 | } 33 | .frame(maxWidth: 300) 34 | } 35 | } 36 | ``` 37 | 38 | ![HFlow](Resources/hflow.png) 39 | 40 | ## VFlow 41 | 42 | ```swift 43 | VFlow { 44 | ForEach(colors, id: \.description) { color in 45 | RoundedRectangle(cornerRadius: 10) 46 | .fill(color.gradient) 47 | .frame(width: 50, height: .random(in: 40...60)) 48 | } 49 | } 50 | .frame(maxHeight: 300) 51 | ``` 52 | 53 | ![VFlow](Resources/vflow.png) 54 | 55 | ## Alignment 56 | 57 | Supports the same alignments as HStack and VStack do. 58 | 59 | ```swift 60 | HFlow(alignment: .top) { 61 | ForEach(colors, id: \.description) { color in 62 | RoundedRectangle(cornerRadius: 10) 63 | .fill(color.gradient) 64 | .frame(width: 50, height: .random(in: 40...60)) 65 | } 66 | } 67 | .frame(maxWidth: 300) 68 | ``` 69 | 70 | ![HFlow](Resources/hflow-top.png) 71 | 72 | Additionally, alignment can be specified on both axes. Ideal for tags. 73 | 74 | ```swift 75 | HFlow(horizontalAlignment: .center, verticalAlignment: .top) { 76 | ForEach(colors, id: \.description) { color in 77 | RoundedRectangle(cornerRadius: 10) 78 | .fill(color.gradient) 79 | .frame(width: .random(in: 30...60), height: 30) 80 | } 81 | } 82 | .frame(maxWidth: 300) 83 | ``` 84 | 85 | ![HFlow](Resources/hflow-center.png) 86 | 87 | ## Spacing 88 | 89 | Customize spacing between rows and items separately. 90 | 91 | ```swift 92 | HFlow(itemSpacing: 4, rowSpacing: 20) { 93 | ForEach(colors, id: \.description) { color in 94 | RoundedRectangle(cornerRadius: 10) 95 | .fill(color.gradient) 96 | .frame(width: 50, height: 50) 97 | } 98 | } 99 | .frame(maxWidth: 300) 100 | ``` 101 | 102 | ![HFlow](Resources/hflow-spacing.png) 103 | 104 | ## Distribute items 105 | 106 | Distribute items evenly by minimizing the empty spaces left in each row. 107 | Implements the Knuth-Plass line breaking algorithm. 108 | 109 | ```swift 110 | HFlow(distributeItemsEvenly: true) { 111 | ForEach(colors, id: \.description) { color in 112 | RoundedRectangle(cornerRadius: 10) 113 | .fill(color.gradient) 114 | .frame(width: 65, height: 50) 115 | } 116 | } 117 | .frame(width: 300, alignment: .leading) 118 | ``` 119 | 120 | ![HFlow](Resources/hflow-distributed.png) 121 | 122 | ## Justified 123 | 124 | ```swift 125 | HFlow(justified: true) { 126 | ForEach(colors, id: \.description) { color in 127 | RoundedRectangle(cornerRadius: 10) 128 | .fill(color.gradient) 129 | .frame(width: 50, height: 50) 130 | } 131 | } 132 | .frame(width: 300) 133 | ``` 134 | 135 | ![HFlow](Resources/hflow-justified.png) 136 | 137 | ## Flexibility 138 | 139 | ```swift 140 | HFlow { // distributes flexible items proportionally 141 | RoundedRectangle(cornerRadius: 10) 142 | .fill(.red) 143 | .frame(minWidth: 50, maxWidth: .infinity) 144 | .frame(height: 50) 145 | .flexibility(.minimum) // takes as little space as possible, rigid 146 | RoundedRectangle(cornerRadius: 10) 147 | .fill(.green) 148 | .frame(minWidth: 50, maxWidth: .infinity) 149 | .frame(height: 50) 150 | .flexibility(.natural) // expands 151 | RoundedRectangle(cornerRadius: 10) 152 | .fill(.blue) 153 | .frame(minWidth: 50, maxWidth: .infinity) 154 | .frame(height: 50) 155 | .flexibility(.natural) // expands 156 | RoundedRectangle(cornerRadius: 10) 157 | .fill(.yellow) 158 | .frame(minWidth: 50, maxWidth: .infinity) 159 | .frame(height: 50) // takes as much space as possible 160 | .flexibility(.maximum) 161 | } 162 | .frame(width: 300) 163 | ``` 164 | 165 | ![HFlow](Resources/hflow-flexibility.png) 166 | 167 | ## Line breaks 168 | 169 | ```swift 170 | HFlow { 171 | RoundedRectangle(cornerRadius: 10) 172 | .fill(.red) 173 | .frame(width: 50, height: 50) 174 | RoundedRectangle(cornerRadius: 10) 175 | .fill(.green) 176 | .frame(width: 50, height: 50) 177 | RoundedRectangle(cornerRadius: 10) 178 | .fill(.blue) 179 | .frame(width: 50, height: 50) 180 | LineBreak() 181 | RoundedRectangle(cornerRadius: 10) 182 | .fill(.yellow) 183 | .frame(width: 50, height: 50) 184 | } 185 | .frame(width: 300) 186 | ``` 187 | 188 | ![HFlow](Resources/hflow-linebreak.png) 189 | 190 | ```swift 191 | HFlow { 192 | RoundedRectangle(cornerRadius: 10) 193 | .fill(.red) 194 | .frame(width: 50, height: 50) 195 | RoundedRectangle(cornerRadius: 10) 196 | .fill(.green) 197 | .frame(width: 50, height: 50) 198 | .startInNewLine() 199 | RoundedRectangle(cornerRadius: 10) 200 | .fill(.blue) 201 | .frame(width: 50, height: 50) 202 | RoundedRectangle(cornerRadius: 10) 203 | .fill(.yellow) 204 | .frame(width: 50, height: 50) 205 | } 206 | .frame(width: 300) 207 | ``` 208 | 209 | ![HFlow](Resources/hflow-newline.png) 210 | 211 | ## RTL 212 | 213 | Adapts to left-to-right and right-to-left environments too. 214 | 215 | ```swift 216 | HFlow { 217 | ForEach(colors, id: \.description) { color in 218 | RoundedRectangle(cornerRadius: 10) 219 | .fill(color.gradient) 220 | .frame(width: .random(in: 40...60), height: 50) 221 | } 222 | } 223 | .frame(maxWidth: 300) 224 | .environment(\.layoutDirection, .rightToLeft) 225 | ``` 226 | 227 | ![HFlow](Resources/hflow-rtl.png) 228 | -------------------------------------------------------------------------------- /Tests/FlowTests/Utils/TestSubview.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import XCTest 3 | @testable import Flow 4 | 5 | class TestSubview: Flow.Subview, CustomStringConvertible { 6 | var spacing = ViewSpacing() 7 | var priority: Double = 1 8 | var placement: (position: CGPoint, size: CGSize)? 9 | var minSize: CGSize 10 | var idealSize: CGSize 11 | var maxSize: CGSize 12 | var layoutValues: [ObjectIdentifier: Any] = [:] 13 | 14 | init(size: CGSize) { 15 | minSize = size 16 | idealSize = size 17 | maxSize = size 18 | } 19 | 20 | init(minSize: CGSize, idealSize: CGSize, maxSize: CGSize) { 21 | self.minSize = minSize 22 | self.idealSize = idealSize 23 | self.maxSize = maxSize 24 | } 25 | 26 | subscript(key: K.Type) -> K.Value { 27 | get { layoutValues[ObjectIdentifier(key)] as? K.Value ?? K.defaultValue } 28 | set { layoutValues[ObjectIdentifier(key)] = newValue } 29 | } 30 | 31 | func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { 32 | switch proposal { 33 | case .zero: 34 | minSize 35 | case .unspecified: 36 | idealSize 37 | case .infinity: 38 | maxSize 39 | default: 40 | CGSize( 41 | width: min(max(minSize.width, proposal.width ?? idealSize.width), maxSize.width), 42 | height: min(max(minSize.height, proposal.height ?? idealSize.height), maxSize.height) 43 | ) 44 | } 45 | } 46 | 47 | func dimensions(_ proposal: ProposedViewSize) -> any Dimensions { 48 | let size = switch proposal { 49 | case .zero: minSize 50 | case .unspecified: idealSize 51 | case .infinity: maxSize 52 | default: sizeThatFits(proposal) 53 | } 54 | return TestDimensions(width: size.width, height: size.height) 55 | } 56 | 57 | func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) { 58 | let size = sizeThatFits(proposal) 59 | placement = (position, size) 60 | } 61 | 62 | var description: String { 63 | "origin: \((placement?.position.x).map { "\($0)" } ?? "nil")×\((placement?.position.y).map { "\($0)" } ?? "nil"), size: \(idealSize.width)×\(idealSize.height)" 64 | } 65 | 66 | func flexibility(_ behavior: FlexibilityBehavior) -> Self { 67 | self[FlexibilityLayoutValueKey.self] = behavior 68 | return self 69 | } 70 | } 71 | 72 | final class WrappingText: TestSubview { 73 | override func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { 74 | let area = idealSize.width * idealSize.height 75 | if let proposedWidth = proposal.width, idealSize.width > proposedWidth { 76 | let height = (Int(1)...).first { area <= proposedWidth * CGFloat($0) }! 77 | return CGSize(width: proposedWidth, height: CGFloat(height)) 78 | } 79 | if let proposedHeight = proposal.height, idealSize.height > proposedHeight { 80 | let width = (Int(1)...).first { area <= proposedHeight * CGFloat($0) }! 81 | return CGSize(width: CGFloat(width), height: proposedHeight) 82 | } 83 | return super.sizeThatFits(proposal) 84 | } 85 | } 86 | 87 | extension [TestSubview]: Flow.Subviews {} 88 | 89 | typealias LayoutDescription = (subviews: [TestSubview], reportedSize: CGSize) 90 | 91 | extension FlowLayout { 92 | func layout(_ subviews: [TestSubview], in bounds: CGSize) -> LayoutDescription { 93 | var cache = makeCache(subviews) 94 | let size = sizeThatFits( 95 | proposal: ProposedViewSize(width: bounds.width, height: bounds.height), 96 | subviews: subviews, 97 | cache: &cache 98 | ) 99 | placeSubviews( 100 | in: CGRect(origin: .zero, size: bounds), 101 | proposal: ProposedViewSize( 102 | width: min(size.width, bounds.width), 103 | height: min(size.height, bounds.height) 104 | ), 105 | subviews: subviews, 106 | cache: &cache 107 | ) 108 | return (subviews, bounds) 109 | } 110 | 111 | func sizeThatFits(proposal: ProposedViewSize, subviews: [TestSubview]) -> CGSize { 112 | var cache = makeCache(subviews) 113 | return sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) 114 | } 115 | } 116 | 117 | func render(_ layout: LayoutDescription, border: Bool = true) -> String { 118 | struct Point: Hashable { 119 | let x, y: Int 120 | } 121 | let width = Int(layout.reportedSize.width) 122 | let height = Int(layout.reportedSize.height) 123 | 124 | var positions: Set = [] 125 | for view in layout.subviews { 126 | if let placement = view.placement { 127 | let point = placement.position 128 | for y in Int(point.y) ..< Int(point.y + placement.size.height) { 129 | for x in Int(point.x) ..< Int(point.x + placement.size.width) { 130 | let result = positions.insert(Point(x: x, y: y)) 131 | precondition(result.inserted, "Boxes should not overlap") 132 | precondition(x >= 0 && x < width && y >= 0 && y < height, "Out of bounds") 133 | } 134 | } 135 | } else { 136 | fatalError("Should be placed") 137 | } 138 | } 139 | var result = "" 140 | if border { 141 | result += "+" + String(repeating: "-", count: width) + "+\n" 142 | } 143 | for y in 0 ... height - 1 { 144 | if border { 145 | result += "|" 146 | } 147 | for x in 0 ... width - 1 { 148 | result += positions.contains(Point(x: x, y: y)) ? "X" : " " 149 | } 150 | if border { 151 | result += "|" 152 | } else { 153 | result = result.trimmingCharacters(in: .whitespaces) 154 | } 155 | result += "\n" 156 | } 157 | if border { 158 | result += "+" + String(repeating: "-", count: width) + "+\n" 159 | } 160 | return result.trimmingCharacters(in: .newlines) 161 | } 162 | 163 | private struct TestDimensions: Dimensions { 164 | let width, height: CGFloat 165 | 166 | subscript(guide: HorizontalAlignment) -> CGFloat { 167 | switch guide { 168 | case .center: 0.5 * width 169 | case .trailing: width 170 | default: 0 171 | } 172 | } 173 | 174 | subscript(guide: VerticalAlignment) -> CGFloat { 175 | switch guide { 176 | case .center: 0.5 * height 177 | case .bottom: height 178 | default: 0 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Sources/Flow/Example/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @State private var axis: Axis = .horizontal 5 | @State private var contents: Contents = .boxes 6 | @State private var width: CGFloat = 400 7 | @State private var height: CGFloat = 400 8 | @State private var itemSpacing: CGFloat? = nil 9 | @State private var lineSpacing: CGFloat? = nil 10 | @State private var justified: Bool = false 11 | @State private var horizontalAlignment: HAlignment = .leading 12 | @State private var verticalAlignment: VAlignment = .top 13 | @State private var distributeItemsEvenly: Bool = false 14 | private let texts = "This is a long text that wraps nicely in flow layout".components(separatedBy: " ").map { string in 15 | AnyView(Text(string)) 16 | } 17 | private let colors = [Color.red, .orange, .yellow, .mint, .green, .teal, .blue, .purple, .indigo].map { color in 18 | AnyView(color.frame(height: 30).frame(minWidth: 30)) 19 | } 20 | 21 | enum HAlignment: String, Hashable, CaseIterable, CustomStringConvertible { 22 | case leading, center, trailing 23 | var description: String { rawValue } 24 | var value: HorizontalAlignment { 25 | switch self { 26 | case .leading: .leading 27 | case .center: .center 28 | case .trailing: .trailing 29 | } 30 | } 31 | } 32 | 33 | enum VAlignment: String, Hashable, CaseIterable, CustomStringConvertible { 34 | case top, baseline, center, bottom 35 | var description: String { rawValue } 36 | var value: VerticalAlignment { 37 | switch self { 38 | case .top: .top 39 | case .baseline: .firstTextBaseline 40 | case .center: .center 41 | case .bottom: .bottom 42 | } 43 | } 44 | } 45 | 46 | var body: some View { 47 | NavigationSplitView(columnVisibility: .constant(.all)) { 48 | List { 49 | Section(header: Text("Content")) { 50 | picker($contents) 51 | } 52 | Section(header: Text("Layout")) { 53 | picker($axis) 54 | } 55 | Section(header: Text("Size")) { 56 | Grid { 57 | GridRow { 58 | Text("Width").gridColumnAlignment(.leading) 59 | Slider(value: $width.animation(.snappy), in: 0...400) 60 | .padding(.horizontal) 61 | } 62 | GridRow { 63 | Text("Height") 64 | Slider(value: $height.animation(.snappy), in: 0...400) 65 | .padding(.horizontal) 66 | } 67 | } 68 | } 69 | Section(header: Text("Alignment")) { 70 | picker($horizontalAlignment) 71 | picker($verticalAlignment) 72 | } 73 | Section(header: Text("Spacing")) { 74 | stepper("Item", $itemSpacing) 75 | stepper("Line", $lineSpacing) 76 | } 77 | Section(header: Text("Extras")) { 78 | Toggle("Justified", isOn: $justified) 79 | Toggle("Distibute evenly", isOn: $distributeItemsEvenly.animation()) 80 | } 81 | } 82 | .listStyle(.sidebar) 83 | .frame(minWidth: 250) 84 | .navigationTitle("Flow Layout") 85 | .padding() 86 | } detail: { 87 | layout { 88 | let views: [AnyView] = switch contents { 89 | case .texts: texts 90 | case .boxes: colors 91 | } 92 | ForEach(Array(views.enumerated()), id: \.offset) { $0.element.border(.blue) } 93 | } 94 | .border(.red.opacity(0.2)) 95 | .frame(maxWidth: width, maxHeight: height) 96 | .border(.red) 97 | } 98 | .frame(minWidth: 600, minHeight: 600) 99 | } 100 | 101 | private func stepper(_ title: String, _ selection: Binding) -> some View { 102 | HStack { 103 | Toggle(isOn: Binding( 104 | get: { selection.wrappedValue != nil }, 105 | set: { selection.wrappedValue = $0 ? 8 : nil }).animation() 106 | ) { 107 | Text(title) 108 | } 109 | if let value = selection.wrappedValue { 110 | Text("\(value.formatted())") 111 | Stepper("", value: Binding( 112 | get: { value }, 113 | set: { selection.wrappedValue = $0 } 114 | ).animation(), step: 4) 115 | } 116 | }.fixedSize() 117 | } 118 | 119 | private func picker(_ selection: Binding, style: some PickerStyle = .segmented) -> some View where Value: Hashable & CaseIterable & CustomStringConvertible, Value.AllCases: RandomAccessCollection { 120 | Picker("", selection: selection.animation()) { 121 | ForEach(Value.allCases, id: \.self) { value in 122 | Text(value.description).tag(value) 123 | } 124 | } 125 | .pickerStyle(style) 126 | } 127 | 128 | private var layout: AnyLayout { 129 | switch axis { 130 | case .horizontal: 131 | return AnyLayout( 132 | HFlow( 133 | horizontalAlignment: horizontalAlignment.value, 134 | verticalAlignment: verticalAlignment.value, 135 | horizontalSpacing: itemSpacing, 136 | verticalSpacing: lineSpacing, 137 | justified: justified, 138 | distributeItemsEvenly: distributeItemsEvenly 139 | ) 140 | ) 141 | case .vertical: 142 | return AnyLayout( 143 | VFlow( 144 | horizontalAlignment: horizontalAlignment.value, 145 | verticalAlignment: verticalAlignment.value, 146 | horizontalSpacing: lineSpacing, 147 | verticalSpacing: itemSpacing, 148 | justified: justified, 149 | distributeItemsEvenly: distributeItemsEvenly 150 | ) 151 | ) 152 | } 153 | } 154 | } 155 | 156 | enum Contents: String, CustomStringConvertible, CaseIterable { 157 | case texts 158 | case boxes 159 | 160 | var description: String { rawValue } 161 | } 162 | 163 | #Preview { 164 | ContentView() 165 | } 166 | -------------------------------------------------------------------------------- /Sources/Flow/Internal/LineBreaking.swift: -------------------------------------------------------------------------------- 1 | import CoreFoundation 2 | import Foundation 3 | 4 | @usableFromInline 5 | struct LineItemInput { 6 | @usableFromInline 7 | var size: ClosedRange 8 | @usableFromInline 9 | var spacing: CGFloat 10 | @usableFromInline 11 | var priority: Double = 0 12 | @usableFromInline 13 | var flexibility: FlexibilityBehavior = .natural 14 | @usableFromInline 15 | var isLineBreakView: Bool = false 16 | @usableFromInline 17 | var shouldStartInNewLine: Bool = false 18 | @inlinable 19 | var growingPotential: Double { 20 | if flexibility == .minimum { 21 | return 0 22 | } else { 23 | return size.upperBound - size.lowerBound 24 | } 25 | } 26 | } 27 | 28 | @usableFromInline 29 | protocol LineBreaking { 30 | @inlinable 31 | func wrapItemsToLines(items: LineBreakingInput, in availableSpace: CGFloat) -> LineBreakingOutput 32 | } 33 | 34 | @usableFromInline 35 | typealias LineBreakingInput = [LineItemInput] 36 | 37 | @usableFromInline 38 | typealias IndexedLineBreakingInput = [(offset: Int, element: LineItemInput)] 39 | 40 | @usableFromInline 41 | typealias LineBreakingOutput = [LineOutput] 42 | 43 | @usableFromInline 44 | typealias LineOutput = [LineItemOutput] 45 | 46 | @usableFromInline 47 | struct LineItemOutput: Equatable { 48 | @usableFromInline 49 | let index: Int 50 | @usableFromInline 51 | var size: CGFloat 52 | @usableFromInline 53 | var leadingSpace: CGFloat 54 | 55 | @inlinable 56 | init(index: Int, size: CGFloat, leadingSpace: CGFloat) { 57 | self.index = index 58 | self.size = size 59 | self.leadingSpace = leadingSpace 60 | } 61 | } 62 | 63 | @usableFromInline 64 | struct FlowLineBreaker: LineBreaking { 65 | @inlinable 66 | init() {} 67 | 68 | @inlinable 69 | func wrapItemsToLines(items: LineBreakingInput, in availableSpace: CGFloat) -> LineBreakingOutput { 70 | var currentLine: IndexedLineBreakingInput = [] 71 | var lines: LineBreakingOutput = [] 72 | 73 | for item in items.enumerated() { 74 | if sizes(of: currentLine + [item], availableSpace: availableSpace) != nil { 75 | currentLine.append(item) 76 | } else if let line = sizes(of: currentLine, availableSpace: availableSpace)?.items { 77 | lines.append(line) 78 | currentLine = [item] 79 | } 80 | } 81 | if let line = sizes(of: currentLine, availableSpace: availableSpace)?.items { 82 | lines.append(line) 83 | } 84 | return lines 85 | } 86 | } 87 | 88 | @usableFromInline 89 | struct KnuthPlassLineBreaker: LineBreaking { 90 | @inlinable 91 | init() {} 92 | 93 | @inlinable 94 | func wrapItemsToLines(items: LineBreakingInput, in availableSpace: CGFloat) -> LineBreakingOutput { 95 | if items.isEmpty { 96 | return [] 97 | } 98 | let count = items.count 99 | var costs: [CGFloat] = Array(repeating: .infinity, count: count + 1) 100 | var breaks: [Int?] = Array(repeating: nil, count: count + 1) 101 | 102 | costs[0] = 0 103 | 104 | for end in 1 ... count { 105 | for start in (0 ..< end).reversed() { 106 | let itemsToEvaluate: IndexedLineBreakingInput = (start ..< end).map { ($0, items[$0]) } 107 | guard let calculation = sizes(of: itemsToEvaluate, availableSpace: availableSpace) else { continue } 108 | let remainingSpace = calculation.remainingSpace 109 | let spacePenalty = remainingSpace * remainingSpace 110 | let stretchPenalty = zip(itemsToEvaluate, calculation.items).sum { item, calculation in 111 | let deviation = calculation.size - item.element.size.lowerBound 112 | return deviation * deviation 113 | } 114 | let bias = CGFloat(count - start) * 5 // Introduce a small bias to prefer breaks that fill earlier lines more 115 | let cost = costs[start] + spacePenalty + stretchPenalty + bias 116 | if cost < costs[end] { 117 | costs[end] = cost 118 | breaks[end] = start 119 | } 120 | } 121 | } 122 | 123 | var result: LineBreakingOutput = [] 124 | var end = items.count 125 | while let start = breaks[end] { 126 | let line = sizes(of: (start.. SizeCalculation? { 145 | if items.isEmpty { 146 | return nil 147 | } 148 | // Handle line break view 149 | let positionOfLineBreak = items.lastIndex(where: \.element.isLineBreakView) 150 | if let positionOfLineBreak, positionOfLineBreak > 0 { 151 | return nil 152 | } 153 | var items = items 154 | if let positionOfLineBreak, case let afterLineBreak = items.index(after: positionOfLineBreak), afterLineBreak < items.endIndex { 155 | items[afterLineBreak].element.spacing = 0 156 | } 157 | // Handle manual new line modifier 158 | let numberOfNewLines = items.filter(\.element.shouldStartInNewLine).count 159 | if numberOfNewLines > 1 { 160 | return nil 161 | } else if numberOfNewLines == 1, !items[0].element.shouldStartInNewLine { 162 | return nil 163 | } 164 | // Calculate total size 165 | let totalSizeOfItems = items.sum(of: \.element.size.lowerBound) + items.dropFirst().sum(of: \.element.spacing) 166 | if totalSizeOfItems > availableSpace { 167 | return nil 168 | } 169 | var remainingSpace = availableSpace - totalSizeOfItems 170 | // Handle expanded items 171 | for item in items where item.element.flexibility == .maximum { 172 | let size = max(item.element.size.lowerBound, min(availableSpace, item.element.size.upperBound)) 173 | if size - item.element.size.lowerBound > remainingSpace { 174 | return nil 175 | } 176 | } 177 | // Layout accoring to priorities and proportionally distribute remaining space between flexible views 178 | var result: LineOutput = items.map { LineItemOutput(index: $0.offset, size: $0.element.size.lowerBound, leadingSpace: $0.element.spacing) } 179 | result[0].leadingSpace = 0 180 | let itemsInPriorityOrder = Dictionary(grouping: items.enumerated(), by: \.element.element.priority) 181 | let priorities = itemsInPriorityOrder.keys.sorted(by: >) 182 | for priority in priorities where remainingSpace > 0 { 183 | let items = itemsInPriorityOrder[priority] ?? [] 184 | let itemsInFlexibilityOrder = items.sorted(using: KeyPathComparator(\.element.element.growingPotential)) 185 | var remainingItemCount = items.count 186 | for (index, item) in itemsInFlexibilityOrder { 187 | let offer = remainingSpace / CGFloat(remainingItemCount) 188 | let allocation = min(item.element.growingPotential, offer) 189 | result[index].size += allocation 190 | remainingSpace -= allocation 191 | remainingItemCount -= 1 192 | } 193 | } 194 | return SizeCalculation(items: result, remainingSpace: remainingSpace) 195 | } 196 | -------------------------------------------------------------------------------- /Sources/Flow/HFlow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view that arranges its children in a horizontal flow layout. 4 | /// 5 | /// The following example shows a simple horizontal flow of five text views: 6 | /// 7 | /// var body: some View { 8 | /// HFlow( 9 | /// alignment: .top, 10 | /// spacing: 10 11 | /// ) { 12 | /// ForEach( 13 | /// 1...5, 14 | /// id: \.self 15 | /// ) { 16 | /// Text("Item \($0)") 17 | /// } 18 | /// } 19 | /// } 20 | /// 21 | @frozen 22 | public struct HFlow: View { 23 | @usableFromInline 24 | let layout: HFlowLayout 25 | @usableFromInline 26 | let content: Content 27 | 28 | /// Creates a horizontal flow with the given spacing and vertical alignment. 29 | /// 30 | /// - Parameters: 31 | /// - alignment: The guide for aligning the subviews in this flow. This 32 | /// guide has the same vertical screen coordinate for every child view. 33 | /// - itemSpacing: The distance between adjacent subviews, or `nil` if you 34 | /// want the flow to choose a default distance for each pair of subviews. 35 | /// - rowSpacing: The distance between rows of subviews, or `nil` if you 36 | /// want the flow to choose a default distance for each pair of rows. 37 | /// - justified: Whether the layout should fill the remaining 38 | /// available space in each row by stretching spaces. 39 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 40 | /// mode tries to distribute items more evenly by minimizing the empty 41 | /// spaces left in each row, while respecting their order. 42 | /// - content: A view builder that creates the content of this flow. 43 | @inlinable 44 | public init( 45 | alignment: VerticalAlignment = .center, 46 | itemSpacing: CGFloat? = nil, 47 | rowSpacing: CGFloat? = nil, 48 | justified: Bool = false, 49 | distributeItemsEvenly: Bool = false, 50 | @ViewBuilder content contentBuilder: () -> Content 51 | ) { 52 | content = contentBuilder() 53 | layout = HFlowLayout( 54 | alignment: alignment, 55 | itemSpacing: itemSpacing, 56 | rowSpacing: rowSpacing, 57 | justified: justified, 58 | distributeItemsEvenly: distributeItemsEvenly 59 | ) 60 | } 61 | 62 | /// Creates a horizontal flow with the given spacing and vertical alignment. 63 | /// 64 | /// - Parameters: 65 | /// - alignment: The guide for aligning the subviews in this flow. This 66 | /// guide has the same vertical screen coordinate for every child view. 67 | /// - spacing: The distance between adjacent subviews, or `nil` if you 68 | /// want the flow to choose a default distance for each pair of subviews. 69 | /// - justified: Whether the layout should fill the remaining 70 | /// available space in each row by stretching spaces. 71 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 72 | /// mode tries to distribute items more evenly by minimizing the empty 73 | /// spaces left in each row, while respecting their order. 74 | /// - content: A view builder that creates the content of this flow. 75 | @inlinable 76 | public init( 77 | alignment: VerticalAlignment = .center, 78 | spacing: CGFloat? = nil, 79 | justified: Bool = false, 80 | distributeItemsEvenly: Bool = false, 81 | @ViewBuilder content contentBuilder: () -> Content 82 | ) { 83 | self.init( 84 | alignment: alignment, 85 | itemSpacing: spacing, 86 | rowSpacing: spacing, 87 | justified: justified, 88 | distributeItemsEvenly: distributeItemsEvenly, 89 | content: contentBuilder 90 | ) 91 | } 92 | 93 | /// Creates a horizontal flow with the given spacing and alignment. 94 | /// 95 | /// - Parameters: 96 | /// - horizonalAlignment: The guide for aligning the subviews horizontally. 97 | /// - horizonalSpacing: The distance between subviews on the horizontal axis. 98 | /// - verticalAlignment: The guide for aligning the subviews vertically. 99 | /// - verticalSpacing: The distance between subviews on the vertical axis. 100 | /// - justified: Whether the layout should fill the remaining 101 | /// available space in each row by stretching spaces. 102 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 103 | /// mode tries to distribute items more evenly by minimizing the empty 104 | /// spaces left in each row, while respecting their order. 105 | /// - content: A view builder that creates the content of this flow. 106 | @inlinable 107 | public init( 108 | horizontalAlignment: HorizontalAlignment, 109 | verticalAlignment: VerticalAlignment, 110 | horizontalSpacing: CGFloat? = nil, 111 | verticalSpacing: CGFloat? = nil, 112 | justified: Bool = false, 113 | distributeItemsEvenly: Bool = false, 114 | @ViewBuilder content contentBuilder: () -> Content 115 | ) { 116 | content = contentBuilder() 117 | layout = HFlowLayout( 118 | horizontalAlignment: horizontalAlignment, 119 | verticalAlignment: verticalAlignment, 120 | horizontalSpacing: horizontalSpacing, 121 | verticalSpacing: verticalSpacing, 122 | justified: justified, 123 | distributeItemsEvenly: distributeItemsEvenly 124 | ) 125 | } 126 | 127 | @usableFromInline 128 | @Environment(\.flexibility) var flexibility 129 | 130 | @inlinable 131 | public var body: some View { 132 | layout { 133 | content 134 | .layoutValue(key: FlexibilityLayoutValueKey.self, value: flexibility) 135 | } 136 | } 137 | } 138 | 139 | extension HFlow: Animatable where Content == EmptyView { 140 | public typealias AnimatableData = EmptyAnimatableData 141 | } 142 | 143 | extension HFlow: Layout, Sendable where Content == EmptyView { 144 | /// Creates a horizontal flow with the given spacing and vertical alignment. 145 | /// 146 | /// - Parameters: 147 | /// - alignment: The guide for aligning the subviews in this flow. This 148 | /// guide has the same vertical screen coordinate for every child view. 149 | /// - itemSpacing: The distance between adjacent subviews, or `nil` if you 150 | /// want the flow to choose a default distance for each pair of subviews. 151 | /// - rowSpacing: The distance between rows of subviews, or `nil` if you 152 | /// want the flow to choose a default distance for each pair of rows. 153 | /// - justified: Whether the layout should fill the remaining 154 | /// available space in each row by stretching spaces. 155 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 156 | /// mode tries to distribute items more evenly by minimizing the empty 157 | /// spaces left in each row, while respecting their order. 158 | @inlinable 159 | public init( 160 | alignment: VerticalAlignment = .center, 161 | itemSpacing: CGFloat? = nil, 162 | rowSpacing: CGFloat? = nil, 163 | justified: Bool = false, 164 | distributeItemsEvenly: Bool = false 165 | ) { 166 | self.init( 167 | alignment: alignment, 168 | itemSpacing: itemSpacing, 169 | rowSpacing: rowSpacing, 170 | justified: justified, 171 | distributeItemsEvenly: distributeItemsEvenly 172 | ) { 173 | EmptyView() 174 | } 175 | } 176 | 177 | /// Creates a horizontal flow with the given spacing and vertical alignment. 178 | /// 179 | /// - Parameters: 180 | /// - alignment: The guide for aligning the subviews in this flow. This 181 | /// guide has the same vertical screen coordinate for every child view. 182 | /// - spacing: The distance between adjacent subviews, or `nil` if you 183 | /// want the flow to choose a default distance for each pair of subviews. 184 | /// - justified: Whether the layout should fill the remaining 185 | /// available space in each row by stretching spaces. 186 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 187 | /// mode tries to distribute items more evenly by minimizing the empty 188 | /// spaces left in each row, while respecting their order. 189 | @inlinable 190 | public init( 191 | alignment: VerticalAlignment = .center, 192 | spacing: CGFloat? = nil, 193 | justified: Bool = false, 194 | distributeItemsEvenly: Bool = false 195 | ) { 196 | self.init( 197 | alignment: alignment, 198 | spacing: spacing, 199 | justified: justified, 200 | distributeItemsEvenly: distributeItemsEvenly 201 | ) { 202 | EmptyView() 203 | } 204 | } 205 | 206 | /// Creates a horizontal flow with the given spacing and alignment. 207 | /// 208 | /// - Parameters: 209 | /// - horizonalAlignment: The guide for aligning the subviews horizontally. 210 | /// - horizonalSpacing: The distance between subviews on the horizontal axis. 211 | /// - verticalAlignment: The guide for aligning the subviews vertically. 212 | /// - verticalSpacing: The distance between subviews on the vertical axis. 213 | /// - justified: Whether the layout should fill the remaining 214 | /// available space in each row by stretching spaces. 215 | /// - distributeItemsEvenly: Instead of prioritizing the first rows, this 216 | /// mode tries to distribute items more evenly by minimizing the empty 217 | /// spaces left in each row, while respecting their order. 218 | @inlinable 219 | public init( 220 | horizontalAlignment: HorizontalAlignment, 221 | verticalAlignment: VerticalAlignment, 222 | horizontalSpacing: CGFloat? = nil, 223 | verticalSpacing: CGFloat? = nil, 224 | justified: Bool = false, 225 | distributeItemsEvenly: Bool = false 226 | ) { 227 | self.init( 228 | horizontalAlignment: horizontalAlignment, 229 | verticalAlignment: verticalAlignment, 230 | horizontalSpacing: horizontalSpacing, 231 | verticalSpacing: verticalSpacing, 232 | justified: justified, 233 | distributeItemsEvenly: distributeItemsEvenly 234 | ) { 235 | EmptyView() 236 | } 237 | } 238 | 239 | @inlinable 240 | nonisolated public func sizeThatFits( 241 | proposal: ProposedViewSize, 242 | subviews: LayoutSubviews, 243 | cache: inout FlowLayoutCache 244 | ) -> CGSize { 245 | layout.sizeThatFits( 246 | proposal: proposal, 247 | subviews: subviews, 248 | cache: &cache 249 | ) 250 | } 251 | 252 | @inlinable 253 | nonisolated public func placeSubviews( 254 | in bounds: CGRect, 255 | proposal: ProposedViewSize, 256 | subviews: LayoutSubviews, 257 | cache: inout FlowLayoutCache 258 | ) { 259 | layout.placeSubviews( 260 | in: bounds, 261 | proposal: proposal, 262 | subviews: subviews, 263 | cache: &cache 264 | ) 265 | } 266 | 267 | @inlinable 268 | nonisolated public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { 269 | FlowLayoutCache(subviews, axis: .horizontal) 270 | } 271 | 272 | @inlinable 273 | nonisolated public static var layoutProperties: LayoutProperties { 274 | HFlowLayout.layoutProperties 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /Sources/Flow/VFlow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view that arranges its children in a vertical flow layout. 4 | /// 5 | /// The following example shows a simple vertical flow of 10 text views: 6 | /// 7 | /// var body: some View { 8 | /// VFlow( 9 | /// alignment: .leading, 10 | /// spacing: 10 11 | /// ) { 12 | /// ForEach( 13 | /// 1...10, 14 | /// id: \.self 15 | /// ) { 16 | /// Text("Item \($0)") 17 | /// } 18 | /// } 19 | /// } 20 | /// 21 | @frozen 22 | public struct VFlow: View { 23 | @usableFromInline 24 | let layout: VFlowLayout 25 | @usableFromInline 26 | let content: Content 27 | 28 | /// Creates a vertical flow with the given spacing and horizontal alignment. 29 | /// 30 | /// - Parameters: 31 | /// - alignment: The guide for aligning the subviews in this flow. This 32 | /// guide has the same vertical screen coordinate for every child view. 33 | /// - itemSpacing: The distance between adjacent subviews, or `nil` if you 34 | /// want the flow to choose a default distance for each pair of subviews. 35 | /// - columnSpacing: The distance between adjacent columns, or `nil` if you 36 | /// want the flow to choose a default distance for each pair of columns. 37 | /// - justified: Whether the layout should fill the remaining 38 | /// available space in each column by stretching either items or spaces. 39 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 40 | /// mode tries to distribute items more evenly by minimizing the empty 41 | /// spaces left in each column, while respecting their order. 42 | /// - content: A view builder that creates the content of this flow. 43 | @inlinable 44 | public init( 45 | alignment: HorizontalAlignment = .center, 46 | itemSpacing: CGFloat? = nil, 47 | columnSpacing: CGFloat? = nil, 48 | justified: Bool = false, 49 | distributeItemsEvenly: Bool = false, 50 | @ViewBuilder content contentBuilder: () -> Content 51 | ) { 52 | content = contentBuilder() 53 | layout = VFlowLayout( 54 | alignment: alignment, 55 | itemSpacing: itemSpacing, 56 | columnSpacing: columnSpacing, 57 | justified: justified, 58 | distributeItemsEvenly: distributeItemsEvenly 59 | ) 60 | } 61 | 62 | /// Creates a vertical flow with the given spacing and horizontal alignment. 63 | /// 64 | /// - Parameters: 65 | /// - alignment: The guide for aligning the subviews in this flow. This 66 | /// guide has the same vertical screen coordinate for every child view. 67 | /// - spacing: The distance between adjacent subviews, or `nil` if you 68 | /// want the flow to choose a default distance for each pair of subviews. 69 | /// - justified: Whether the layout should fill the remaining 70 | /// available space in each column by stretching either items or spaces. 71 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 72 | /// mode tries to distribute items more evenly by minimizing the empty 73 | /// spaces left in each column, while respecting their order. 74 | /// - content: A view builder that creates the content of this flow. 75 | @inlinable 76 | public init( 77 | alignment: HorizontalAlignment = .center, 78 | spacing: CGFloat? = nil, 79 | justified: Bool = false, 80 | distributeItemsEvenly: Bool = false, 81 | @ViewBuilder content contentBuilder: () -> Content 82 | ) { 83 | self.init( 84 | alignment: alignment, 85 | itemSpacing: spacing, 86 | columnSpacing: spacing, 87 | justified: justified, 88 | distributeItemsEvenly: distributeItemsEvenly, 89 | content: contentBuilder 90 | ) 91 | } 92 | 93 | /// Creates a vertical flow with the given spacing and alignment. 94 | /// 95 | /// - Parameters: 96 | /// - horizonalAlignment: The guide for aligning the subviews horizontally. 97 | /// - horizonalSpacing: The distance between subviews on the horizontal axis. 98 | /// - verticalAlignment: The guide for aligning the subviews vertically. 99 | /// - verticalSpacing: The distance between subviews on the vertical axis. 100 | /// - justified: Whether the layout should fill the remaining 101 | /// available space in each column by stretching spaces. 102 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 103 | /// mode tries to distribute items more evenly by minimizing the empty 104 | /// spaces left in each column, while respecting their order. 105 | /// - content: A view builder that creates the content of this flow. 106 | @inlinable 107 | public init( 108 | horizontalAlignment: HorizontalAlignment, 109 | verticalAlignment: VerticalAlignment, 110 | horizontalSpacing: CGFloat? = nil, 111 | verticalSpacing: CGFloat? = nil, 112 | justified: Bool = false, 113 | distributeItemsEvenly: Bool = false, 114 | @ViewBuilder content contentBuilder: () -> Content 115 | ) { 116 | content = contentBuilder() 117 | layout = VFlowLayout( 118 | horizontalAlignment: horizontalAlignment, 119 | verticalAlignment: verticalAlignment, 120 | horizontalSpacing: horizontalSpacing, 121 | verticalSpacing: verticalSpacing, 122 | justified: justified, 123 | distributeItemsEvenly: distributeItemsEvenly 124 | ) 125 | } 126 | 127 | @usableFromInline 128 | @Environment(\.flexibility) var flexibility 129 | 130 | public var body: some View { 131 | layout { 132 | content 133 | .layoutValue(key: FlexibilityLayoutValueKey.self, value: flexibility) 134 | } 135 | } 136 | } 137 | 138 | extension VFlow: Animatable where Content == EmptyView { 139 | public typealias AnimatableData = EmptyAnimatableData 140 | } 141 | 142 | extension VFlow: Layout, Sendable where Content == EmptyView { 143 | /// Creates a vertical flow with the given spacing and horizontal alignment. 144 | /// 145 | /// - Parameters: 146 | /// - alignment: The guide for aligning the subviews in this flow. This 147 | /// guide has the same vertical screen coordinate for every child view. 148 | /// - itemSpacing: The distance between adjacent subviews, or `nil` if you 149 | /// want the flow to choose a default distance for each pair of subviews. 150 | /// - columnSpacing: The distance between adjacent columns, or `nil` if you 151 | /// want the flow to choose a default distance for each pair of columns. 152 | /// - justified: Whether the layout should fill the remaining 153 | /// available space in each column by stretching either items or spaces. 154 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 155 | /// mode tries to distribute items more evenly by minimizing the empty 156 | /// spaces left in each column, while respecting their order. 157 | @inlinable 158 | public init( 159 | alignment: HorizontalAlignment = .center, 160 | itemSpacing: CGFloat? = nil, 161 | columnSpacing: CGFloat? = nil, 162 | justified: Bool = false, 163 | distributeItemsEvenly: Bool = false 164 | ) { 165 | self.init( 166 | alignment: alignment, 167 | itemSpacing: itemSpacing, 168 | columnSpacing: columnSpacing, 169 | justified: justified, 170 | distributeItemsEvenly: distributeItemsEvenly 171 | ) { 172 | EmptyView() 173 | } 174 | } 175 | 176 | /// Creates a vertical flow with the given spacing and horizontal alignment. 177 | /// 178 | /// - Parameters: 179 | /// - alignment: The guide for aligning the subviews in this flow. This 180 | /// guide has the same vertical screen coordinate for every child view. 181 | /// - spacing: The distance between adjacent subviews, or `nil` if you 182 | /// want the flow to choose a default distance for each pair of subviews. 183 | /// - justified: Whether the layout should fill the remaining 184 | /// available space in each column by stretching either items or spaces. 185 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 186 | /// mode tries to distribute items more evenly by minimizing the empty 187 | /// spaces left in each column, while respecting their order. 188 | @inlinable 189 | public init( 190 | alignment: HorizontalAlignment = .center, 191 | spacing: CGFloat? = nil, 192 | justified: Bool = false, 193 | distributeItemsEvenly: Bool = false 194 | ) { 195 | self.init( 196 | alignment: alignment, 197 | spacing: spacing, 198 | justified: justified, 199 | distributeItemsEvenly: distributeItemsEvenly 200 | ) { 201 | EmptyView() 202 | } 203 | } 204 | 205 | /// Creates a vertical flow with the given spacing and alignment. 206 | /// 207 | /// - Parameters: 208 | /// - horizonalAlignment: The guide for aligning the subviews horizontally. 209 | /// - horizonalSpacing: The distance between subviews on the horizontal axis. 210 | /// - verticalAlignment: The guide for aligning the subviews vertically. 211 | /// - verticalSpacing: The distance between subviews on the vertical axis. 212 | /// - justified: Whether the layout should fill the remaining 213 | /// available space in each column by stretching either items or spaces. 214 | /// - distributeItemsEvenly: Instead of prioritizing the first columns, this 215 | /// mode tries to distribute items more evenly by minimizing the empty 216 | /// spaces left in each column, while respecting their order. 217 | @inlinable 218 | public init( 219 | horizontalAlignment: HorizontalAlignment, 220 | verticalAlignment: VerticalAlignment, 221 | horizontalSpacing: CGFloat? = nil, 222 | verticalSpacing: CGFloat? = nil, 223 | justified: Bool = false, 224 | distributeItemsEvenly: Bool = false 225 | ) { 226 | self.init( 227 | horizontalAlignment: horizontalAlignment, 228 | verticalAlignment: verticalAlignment, 229 | horizontalSpacing: horizontalSpacing, 230 | verticalSpacing: verticalSpacing, 231 | justified: justified, 232 | distributeItemsEvenly: distributeItemsEvenly 233 | ) { 234 | EmptyView() 235 | } 236 | } 237 | 238 | @inlinable 239 | nonisolated public func sizeThatFits( 240 | proposal: ProposedViewSize, 241 | subviews: LayoutSubviews, 242 | cache: inout FlowLayoutCache 243 | ) -> CGSize { 244 | layout.sizeThatFits( 245 | proposal: proposal, 246 | subviews: subviews, 247 | cache: &cache 248 | ) 249 | } 250 | 251 | @inlinable 252 | nonisolated public func placeSubviews( 253 | in bounds: CGRect, 254 | proposal: ProposedViewSize, 255 | subviews: LayoutSubviews, 256 | cache: inout FlowLayoutCache 257 | ) { 258 | layout.placeSubviews( 259 | in: bounds, 260 | proposal: proposal, 261 | subviews: subviews, 262 | cache: &cache 263 | ) 264 | } 265 | 266 | @inlinable 267 | nonisolated public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { 268 | FlowLayoutCache(subviews, axis: .vertical) 269 | } 270 | 271 | @inlinable 272 | nonisolated public static var layoutProperties: LayoutProperties { 273 | VFlowLayout.layoutProperties 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /Tests/FlowTests/FlowTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import XCTest 3 | @testable import Flow 4 | 5 | final class FlowTests: XCTestCase { 6 | func test_HFlow_size_singleElement() throws { 7 | // Given 8 | let sut: FlowLayout = .horizontal(horizontalSpacing: 10, verticalSpacing: 20) 9 | 10 | // When 11 | let size = sut.sizeThatFits(proposal: 100×100, subviews: [50×50]) 12 | 13 | // Then 14 | XCTAssertEqual(size, 50×50) 15 | } 16 | 17 | func test_HFlow_size_multipleElements() throws { 18 | // Given 19 | let sut: FlowLayout = .horizontal(horizontalSpacing: 10, verticalSpacing: 20) 20 | 21 | // When 22 | let size = sut.sizeThatFits(proposal: 130×130, subviews: repeated(50×50, times: 3)) 23 | 24 | // Then 25 | XCTAssertEqual(size, 110×120) 26 | } 27 | 28 | func test_HFlow_size_justified() throws { 29 | // Given 30 | let sut: FlowLayout = .horizontal(horizontalSpacing: 0, verticalSpacing: 0, justified: true) 31 | 32 | // When 33 | let size = sut.sizeThatFits(proposal: 1000×1000, subviews: [50×50, 50×50]) 34 | 35 | // Then 36 | XCTAssertEqual(size, 1000×50) 37 | } 38 | 39 | func test_HFlow_layout_top() { 40 | // Given 41 | let sut: FlowLayout = .horizontal(verticalAlignment: .top, horizontalSpacing: 1, verticalSpacing: 1) 42 | 43 | // When 44 | let result = sut.layout([5×1, 5×3, 5×1, 5×1], in: 20×6) 45 | 46 | // Then 47 | XCTAssertEqual(render(result), """ 48 | +--------------------+ 49 | |XXXXX XXXXX XXXXX | 50 | | XXXXX | 51 | | XXXXX | 52 | | | 53 | |XXXXX | 54 | | | 55 | +--------------------+ 56 | """) 57 | } 58 | 59 | func test_HFlow_layout_top_and_leading() { 60 | // Given 61 | let sut: FlowLayout = .horizontal(horizontalAlignment: .leading, verticalAlignment: .top, horizontalSpacing: 1, verticalSpacing: 1) 62 | 63 | // When 64 | let result = sut.layout([5×1, 5×3, 5×1, 5×1], in: 20×6) 65 | 66 | // Then 67 | XCTAssertEqual(render(result), """ 68 | +--------------------+ 69 | |XXXXX XXXXX XXXXX | 70 | | XXXXX | 71 | | XXXXX | 72 | | | 73 | |XXXXX | 74 | | | 75 | +--------------------+ 76 | """) 77 | } 78 | 79 | func test_HFlow_layout_top_and_center() { 80 | // Given 81 | let sut: FlowLayout = .horizontal(horizontalAlignment: .center, verticalAlignment: .top, horizontalSpacing: 1, verticalSpacing: 1) 82 | 83 | // When 84 | let result = sut.layout([5×1, 5×3, 5×1, 5×1], in: 20×6) 85 | 86 | // Then 87 | XCTAssertEqual(render(result), """ 88 | +--------------------+ 89 | |XXXXX XXXXX XXXXX | 90 | | XXXXX | 91 | | XXXXX | 92 | | | 93 | | XXXXX | 94 | | | 95 | +--------------------+ 96 | """) 97 | } 98 | 99 | func test_HFlow_layout_top_and_trailing() { 100 | // Given 101 | let sut: FlowLayout = .horizontal(horizontalAlignment: .trailing, verticalAlignment: .top, horizontalSpacing: 1, verticalSpacing: 1) 102 | 103 | // When 104 | let result = sut.layout([5×1, 5×3, 5×1, 5×1], in: 20×6) 105 | 106 | // Then 107 | XCTAssertEqual(render(result), """ 108 | +--------------------+ 109 | |XXXXX XXXXX XXXXX | 110 | | XXXXX | 111 | | XXXXX | 112 | | | 113 | | XXXXX | 114 | | | 115 | +--------------------+ 116 | """) 117 | } 118 | 119 | func test_HFlow_layout_center() { 120 | // Given 121 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 1) 122 | 123 | // When 124 | let result = sut.layout([5×1, 5×3, 5×1, 5×1], in: 20×6) 125 | 126 | // Then 127 | XCTAssertEqual(render(result), """ 128 | +--------------------+ 129 | | XXXXX | 130 | |XXXXX XXXXX XXXXX | 131 | | XXXXX | 132 | | | 133 | |XXXXX | 134 | | | 135 | +--------------------+ 136 | """) 137 | } 138 | 139 | func test_HFlow_layout_bottom() { 140 | // Given 141 | let sut: FlowLayout = .horizontal(verticalAlignment: .bottom, horizontalSpacing: 1, verticalSpacing: 1) 142 | 143 | // When 144 | let result = sut.layout([5×1, 5×3, 5×1, 5×1], in: 20×6) 145 | 146 | // Then 147 | XCTAssertEqual(render(result), """ 148 | +--------------------+ 149 | | XXXXX | 150 | | XXXXX | 151 | |XXXXX XXXXX XXXXX | 152 | | | 153 | |XXXXX | 154 | | | 155 | +--------------------+ 156 | """) 157 | } 158 | 159 | func test_HFlow_default() { 160 | // Given 161 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0) 162 | 163 | // When 164 | let result = sut.layout(repeated(1×1, times: 15), in: 11×3) 165 | 166 | // Then 167 | XCTAssertEqual(render(result), """ 168 | +-----------+ 169 | |X X X X X X| 170 | |X X X X X X| 171 | |X X X | 172 | +-----------+ 173 | """) 174 | } 175 | 176 | func test_HFlow_distibuted() throws { 177 | // Given 178 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0, distributeItemsEvenly: true) 179 | 180 | // When 181 | let result = sut.layout(repeated(1×1, times: 13), in: 11×3) 182 | 183 | // Then 184 | XCTAssertEqual(render(result), """ 185 | +-----------+ 186 | |X X X X X | 187 | |X X X X | 188 | |X X X X | 189 | +-----------+ 190 | """) 191 | } 192 | 193 | func test_HFlow_justified_rigid() { 194 | // Given 195 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0, justified: true) 196 | 197 | // When 198 | let result = sut.layout([3×1, 3×1, 2×1], in: 9×2) 199 | 200 | // Then 201 | XCTAssertEqual(render(result), """ 202 | +---------+ 203 | |XXX XXX| 204 | |XX | 205 | +---------+ 206 | """) 207 | } 208 | 209 | func test_HFlow_justified_flexible() { 210 | // Given 211 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0, justified: true) 212 | 213 | // When 214 | let result = sut.layout([3×1, 3×1...inf×1, 2×1], in: 9×2) 215 | 216 | // Then 217 | XCTAssertEqual(render(result), """ 218 | +---------+ 219 | |XXX XXXXX| 220 | |XX | 221 | +---------+ 222 | """) 223 | } 224 | 225 | func test_VFlow_size_singleElement() throws { 226 | // Given 227 | let sut: FlowLayout = .vertical(horizontalSpacing: 20, verticalSpacing: 10) 228 | 229 | // When 230 | let size = sut.sizeThatFits(proposal: 100×100, subviews: [50×50]) 231 | 232 | // Then 233 | XCTAssertEqual(size, 50×50) 234 | } 235 | 236 | func test_VFlow_size_multipleElements() throws { 237 | // Given 238 | let sut: FlowLayout = .vertical(horizontalSpacing: 20, verticalSpacing: 10) 239 | 240 | // When 241 | let size = sut.sizeThatFits(proposal: 130×130, subviews: repeated(50×50, times: 3)) 242 | 243 | // Then 244 | XCTAssertEqual(size, 120×110) 245 | } 246 | 247 | func test_VFlow_layout_leading() { 248 | // Given 249 | let sut: FlowLayout = .vertical(horizontalAlignment: .leading, horizontalSpacing: 1, verticalSpacing: 1) 250 | 251 | // When 252 | let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) 253 | 254 | // Then 255 | XCTAssertEqual(render(result), """ 256 | +-----+ 257 | |X X| 258 | | | 259 | |XXX X| 260 | | | 261 | |X | 262 | +-----+ 263 | """) 264 | } 265 | func test_VFlow_layout_center() { 266 | // Given 267 | let sut: FlowLayout = .vertical(horizontalSpacing: 1, verticalSpacing: 1) 268 | 269 | // When 270 | let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) 271 | 272 | // Then 273 | XCTAssertEqual(render(result), """ 274 | +-----+ 275 | | X X| 276 | | | 277 | |XXX X| 278 | | | 279 | | X | 280 | +-----+ 281 | """) 282 | } 283 | 284 | func test_VFlow_layout_trailing() { 285 | // Given 286 | let sut: FlowLayout = .vertical(horizontalAlignment: .trailing, horizontalSpacing: 1, verticalSpacing: 1) 287 | 288 | // When 289 | let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) 290 | 291 | // Then 292 | XCTAssertEqual(render(result), """ 293 | +-----+ 294 | | X X| 295 | | | 296 | |XXX X| 297 | | | 298 | | X | 299 | +-----+ 300 | """) 301 | } 302 | 303 | func test_VFlow_default() { 304 | // Given 305 | let sut: FlowLayout = .vertical(horizontalSpacing: 0, verticalSpacing: 0) 306 | 307 | // When 308 | let result = sut.layout(repeated(1×1, times: 16), in: 6×3) 309 | 310 | // Then 311 | XCTAssertEqual(render(result), """ 312 | +------+ 313 | |XXXXXX| 314 | |XXXXX | 315 | |XXXXX | 316 | +------+ 317 | """) 318 | } 319 | 320 | func test_HFlow_text() { 321 | // Given 322 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0) 323 | 324 | // When 325 | let result = sut.layout([WrappingText(size: 6×1), 1×1, 1×1, 1×1], in: 5×3) 326 | 327 | // Then 328 | XCTAssertEqual(render(result), """ 329 | +-----+ 330 | |XXXXX| 331 | |XXXXX| 332 | |X X X| 333 | +-----+ 334 | """) 335 | } 336 | 337 | func test_HFlow_flexible() { 338 | // Given 339 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0) 340 | 341 | // When 342 | let result = sut.layout([1×1, 1×1, 1×1...10×1, 1×1, 1×1], in: 8×2) 343 | 344 | // Then 345 | XCTAssertEqual(render(result), """ 346 | +--------+ 347 | |X X XX X| 348 | |X | 349 | +--------+ 350 | """) 351 | } 352 | 353 | func test_HFlow_flexible_minimum() { 354 | // Given 355 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0) 356 | 357 | // When 358 | let result = sut.layout([1×1, 1×1, (1×1...10×1).flexibility(.minimum), 1×1, 1×1], in: 8×2) 359 | 360 | // Then 361 | XCTAssertEqual(render(result), """ 362 | +--------+ 363 | |X X X X | 364 | |X | 365 | +--------+ 366 | """) 367 | } 368 | 369 | func test_HFlow_flexible_natural() { 370 | // Given 371 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0) 372 | 373 | // When 374 | let result = sut.layout([1×1, 1×1, (1×1...10×1).flexibility(.natural), 1×1, 1×1], in: 8×2) 375 | 376 | // Then 377 | XCTAssertEqual(render(result), """ 378 | +--------+ 379 | |X X XX X| 380 | |X | 381 | +--------+ 382 | """) 383 | } 384 | 385 | func test_HFlow_flexible_maximum() { 386 | // Given 387 | let sut: FlowLayout = .horizontal(horizontalSpacing: 1, verticalSpacing: 0) 388 | 389 | // When 390 | let result = sut.layout([1×1, 1×1, (1×1...10×1).flexibility(.maximum), 1×1, 1×1], in: 8×3) 391 | 392 | // Then 393 | XCTAssertEqual(render(result), """ 394 | +--------+ 395 | |X X | 396 | |XXXXXXXX| 397 | |X X | 398 | +--------+ 399 | """) 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /Sources/Flow/Internal/Layout.swift: -------------------------------------------------------------------------------- 1 | import CoreFoundation 2 | import SwiftUI 3 | 4 | @usableFromInline 5 | struct FlowLayout: Sendable { 6 | @usableFromInline 7 | let axis: Axis 8 | @usableFromInline 9 | let itemSpacing: CGFloat? 10 | @usableFromInline 11 | let lineSpacing: CGFloat? 12 | @usableFromInline 13 | let reversedBreadth: Bool = false 14 | @usableFromInline 15 | let alternatingReversedBreadth: Bool = false 16 | @usableFromInline 17 | let reversedDepth: Bool = false 18 | @usableFromInline 19 | let justified: Bool 20 | @usableFromInline 21 | let distributeItemsEvenly: Bool 22 | @usableFromInline 23 | let alignmentOnBreadth: @Sendable (any Dimensions) -> CGFloat 24 | @usableFromInline 25 | let alignmentOnDepth: @Sendable (any Dimensions) -> CGFloat 26 | 27 | @inlinable 28 | init( 29 | axis: Axis, 30 | itemSpacing: CGFloat? = nil, 31 | lineSpacing: CGFloat? = nil, 32 | justified: Bool = false, 33 | distributeItemsEvenly: Bool = false, 34 | alignmentOnBreadth: @escaping @Sendable (any Dimensions) -> CGFloat, 35 | alignmentOnDepth: @escaping @Sendable (any Dimensions) -> CGFloat 36 | ) { 37 | self.axis = axis 38 | self.itemSpacing = itemSpacing 39 | self.lineSpacing = lineSpacing 40 | self.justified = justified 41 | self.distributeItemsEvenly = distributeItemsEvenly 42 | self.alignmentOnBreadth = alignmentOnBreadth 43 | self.alignmentOnDepth = alignmentOnDepth 44 | } 45 | 46 | private struct ItemWithSpacing { 47 | var item: T 48 | var size: Size 49 | var leadingSpace: CGFloat = 0 50 | } 51 | 52 | private typealias Item = (subview: any Subview, cache: FlowLayoutCache.SubviewCache) 53 | private typealias Line = [ItemWithSpacing] 54 | private typealias Lines = [ItemWithSpacing] 55 | 56 | @usableFromInline 57 | func sizeThatFits( 58 | proposal proposedSize: ProposedViewSize, 59 | subviews: some Subviews, 60 | cache: inout FlowLayoutCache 61 | ) -> CGSize { 62 | guard !subviews.isEmpty else { return .zero } 63 | 64 | let lines = calculateLayout(in: proposedSize, of: subviews, cache: cache) 65 | var size = lines 66 | .map(\.size) 67 | .reduce(.zero, breadth: max, depth: +) 68 | size.depth += lines.sum(of: \.leadingSpace) 69 | if justified { 70 | size.breadth = proposedSize.value(on: axis) 71 | } 72 | return CGSize(size: size, axis: axis) 73 | } 74 | 75 | @usableFromInline 76 | func placeSubviews( 77 | in bounds: CGRect, 78 | proposal: ProposedViewSize, 79 | subviews: some Subviews, 80 | cache: inout FlowLayoutCache 81 | ) { 82 | guard !subviews.isEmpty else { return } 83 | 84 | var bounds = bounds 85 | bounds.origin.replaceNaN(with: 0) 86 | var target = bounds.origin.size(on: axis) 87 | var reversedBreadth = self.reversedBreadth 88 | 89 | let lines = calculateLayout(in: proposal, of: subviews, cache: cache) 90 | 91 | for line in lines { 92 | adjust(&target, for: line, on: .vertical, reversed: reversedDepth) { target in 93 | target.breadth = reversedBreadth ? bounds.maximumValue(on: axis) : bounds.minimumValue(on: axis) 94 | 95 | for item in line.item { 96 | adjust(&target, for: item, on: .horizontal, reversed: reversedBreadth) { target in 97 | alignAndPlace(item, in: line, at: target) 98 | } 99 | } 100 | 101 | if alternatingReversedBreadth { 102 | reversedBreadth.toggle() 103 | } 104 | } 105 | } 106 | } 107 | 108 | @usableFromInline 109 | func makeCache(_ subviews: some Subviews) -> FlowLayoutCache { 110 | FlowLayoutCache(subviews, axis: axis) 111 | } 112 | 113 | private func adjust( 114 | _ target: inout Size, 115 | for item: ItemWithSpacing, 116 | on axis: Axis, 117 | reversed: Bool, 118 | body: (inout Size) -> Void 119 | ) { 120 | let leadingSpace = item.leadingSpace 121 | let size = item.size[axis] 122 | target[axis] += reversed ? -leadingSpace-size : leadingSpace 123 | body(&target) 124 | target[axis] += reversed ? 0 : size 125 | } 126 | 127 | private func alignAndPlace( 128 | _ item: Line.Element, 129 | in line: Lines.Element, 130 | at target: Size 131 | ) { 132 | var position = target 133 | let lineDepth = line.size.depth 134 | let size = Size(breadth: item.size.breadth, depth: lineDepth) 135 | let proposedSize = ProposedViewSize(size: size, axis: axis) 136 | let itemDepth = item.size.depth 137 | if itemDepth > 0 { 138 | let dimensions = item.item.subview.dimensions(proposedSize) 139 | let alignedPosition = alignmentOnDepth(dimensions) 140 | position.depth += (alignedPosition / itemDepth) * (lineDepth - itemDepth) 141 | if position.depth.isNaN { 142 | position.depth = .infinity 143 | } 144 | } 145 | let point = CGPoint(size: position, axis: axis) 146 | item.item.subview.place(at: point, anchor: .topLeading, proposal: proposedSize) 147 | } 148 | 149 | private func calculateLayout( 150 | in proposedSize: ProposedViewSize, 151 | of subviews: some Subviews, 152 | cache: FlowLayoutCache 153 | ) -> Lines { 154 | let items: LineBreakingInput = subviews.enumerated().map { offset, subview in 155 | let minValue: CGFloat 156 | let subviewCache = cache.subviewsCache[offset] 157 | if subviewCache.ideal.breadth <= proposedSize.value(on: axis) { 158 | minValue = subviewCache.ideal.breadth 159 | } else { 160 | minValue = subview.sizeThatFits(proposedSize).value(on: axis) 161 | } 162 | let maxValue = subviewCache.max.breadth 163 | let size = min(minValue, maxValue) ... max(minValue, maxValue) 164 | let spacing = itemSpacing ?? ( 165 | offset > cache.subviewsCache.startIndex 166 | ? cache.subviewsCache[offset - 1].spacing.distance(to: subviewCache.spacing, along: axis) 167 | : 0 168 | ) 169 | return .init( 170 | size: size, 171 | spacing: spacing, 172 | priority: subviewCache.priority, 173 | flexibility: subviewCache.layoutValues.flexibility, 174 | isLineBreakView: subviewCache.layoutValues.isLineBreak, 175 | shouldStartInNewLine: subviewCache.layoutValues.shouldStartInNewLine 176 | ) 177 | } 178 | 179 | let lineBreaker: any LineBreaking = if distributeItemsEvenly { 180 | KnuthPlassLineBreaker() 181 | } else { 182 | FlowLineBreaker() 183 | } 184 | 185 | let wrapped = lineBreaker.wrapItemsToLines( 186 | items: items, 187 | in: proposedSize.replacingUnspecifiedDimensions(by: .infinity).value(on: axis) 188 | ) 189 | 190 | var lines: Lines = wrapped.map { line in 191 | let items = line.map { item in 192 | Line.Element( 193 | item: (subview: subviews[item.index], cache: cache.subviewsCache[item.index]), 194 | size: subviews[item.index] 195 | .sizeThatFits(ProposedViewSize(size: Size(breadth: item.size, depth: .infinity), axis: axis)) 196 | .size(on: axis), 197 | leadingSpace: item.leadingSpace 198 | ) 199 | } 200 | var size = items 201 | .map(\.size) 202 | .reduce(.zero, breadth: +, depth: max) 203 | size.breadth += items.sum(of: \.leadingSpace) 204 | return Lines.Element( 205 | item: items, 206 | size: size, 207 | leadingSpace: 0 208 | ) 209 | } 210 | 211 | // TODO: account for manual line breaks 212 | 213 | updateSpacesForJustifiedLayout(in: &lines, proposedSize: proposedSize) 214 | updateLineSpacings(in: &lines) 215 | updateAlignment(in: &lines) 216 | return lines 217 | } 218 | 219 | private func updateSpacesForJustifiedLayout(in lines: inout Lines, proposedSize: ProposedViewSize) { 220 | guard justified else { return } 221 | for (lineIndex, line) in lines.enumerated() { 222 | let items = line.item 223 | let remainingSpace = proposedSize.value(on: axis) - items.sum { $0.size[axis] + $0.leadingSpace } 224 | for (itemIndex, item) in items.enumerated().dropFirst() { 225 | let distributedSpace = remainingSpace / Double(items.count - 1) 226 | lines[lineIndex].item[itemIndex].leadingSpace = item.leadingSpace + distributedSpace 227 | } 228 | } 229 | } 230 | 231 | private func updateLineSpacings(in lines: inout Lines) { 232 | if let lineSpacing { 233 | for index in lines.indices.dropFirst() { 234 | lines[index].leadingSpace = lineSpacing 235 | } 236 | } else { 237 | let lineSpacings = lines.map { line in 238 | line.item.reduce(into: ViewSpacing()) { $0.formUnion($1.item.cache.spacing) } 239 | } 240 | for (previous, index) in lines.indices.adjacentPairs() { 241 | let spacing = lineSpacings[index].distance(to: lineSpacings[previous], along: axis.perpendicular) 242 | lines[index].leadingSpace = spacing 243 | } 244 | } 245 | // remove space from empty lines (where the only item is a line break view) 246 | for index in lines.indices where lines[index].item.count == 1 && lines[index].item[0].item.cache.layoutValues.isLineBreak { 247 | lines[index].leadingSpace = 0 248 | } 249 | } 250 | 251 | private func updateAlignment(in lines: inout Lines) { 252 | let breadth = lines.map { $0.item.sum { $0.leadingSpace + $0.size.breadth } }.max() ?? 0 253 | for index in lines.indices where !lines[index].item.isEmpty { 254 | lines[index].item[0].leadingSpace += determineLeadingSpace(in: lines[index], breadth: breadth) 255 | } 256 | } 257 | 258 | private func determineLeadingSpace(in line: Lines.Element, breadth: CGFloat) -> CGFloat { 259 | guard let item = line.item.first(where: { $0.item.cache.ideal.breadth > 0 })?.item else { return 0 } 260 | let lineSize = line.item.sum { $0.leadingSpace + $0.size.breadth } 261 | let value = alignmentOnBreadth(item.subview.dimensions(.unspecified)) / item.cache.ideal.breadth 262 | let remainingSpace = breadth - lineSize 263 | return value * remainingSpace 264 | } 265 | } 266 | 267 | extension FlowLayout: Layout { 268 | @inlinable 269 | func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { 270 | makeCache(subviews) 271 | } 272 | 273 | @inlinable 274 | static func vertical( 275 | horizontalAlignment: HorizontalAlignment = .center, 276 | verticalAlignment: VerticalAlignment = .top, 277 | horizontalSpacing: CGFloat? = nil, 278 | verticalSpacing: CGFloat? = nil, 279 | justified: Bool = false, 280 | distributeItemsEvenly: Bool = false 281 | ) -> FlowLayout { 282 | self.init( 283 | axis: .vertical, 284 | itemSpacing: verticalSpacing, 285 | lineSpacing: horizontalSpacing, 286 | justified: justified, 287 | distributeItemsEvenly: distributeItemsEvenly, 288 | alignmentOnBreadth: { $0[verticalAlignment] }, 289 | alignmentOnDepth: { $0[horizontalAlignment] } 290 | ) 291 | } 292 | 293 | @inlinable 294 | static func horizontal( 295 | horizontalAlignment: HorizontalAlignment = .leading, 296 | verticalAlignment: VerticalAlignment = .center, 297 | horizontalSpacing: CGFloat? = nil, 298 | verticalSpacing: CGFloat? = nil, 299 | justified: Bool = false, 300 | distributeItemsEvenly: Bool = false 301 | ) -> FlowLayout { 302 | self.init( 303 | axis: .horizontal, 304 | itemSpacing: horizontalSpacing, 305 | lineSpacing: verticalSpacing, 306 | justified: justified, 307 | distributeItemsEvenly: distributeItemsEvenly, 308 | alignmentOnBreadth: { $0[horizontalAlignment] }, 309 | alignmentOnDepth: { $0[verticalAlignment] } 310 | ) 311 | } 312 | } 313 | 314 | extension Array where Element == Size { 315 | @inlinable 316 | func reduce( 317 | _ initial: Size, 318 | breadth: (CGFloat, CGFloat) -> CGFloat, 319 | depth: (CGFloat, CGFloat) -> CGFloat 320 | ) -> Size { 321 | reduce(initial) { result, size in 322 | Size( 323 | breadth: breadth(result.breadth, size.breadth), 324 | depth: depth(result.depth, size.depth) 325 | ) 326 | } 327 | } 328 | } 329 | 330 | private struct SubviewProperties { 331 | var indexInLine: Int 332 | var spacing: Double 333 | var cache: FlowLayoutCache.SubviewCache 334 | var flexibility: Double { cache.max.breadth - cache.ideal.breadth } 335 | } 336 | 337 | private extension CGPoint { 338 | mutating func replaceNaN(with value: CGFloat) { 339 | x.replaceNaN(with: value) 340 | y.replaceNaN(with: value) 341 | } 342 | } 343 | 344 | private extension CGFloat { 345 | mutating func replaceNaN(with value: CGFloat) { 346 | if isNaN { 347 | self = value 348 | } 349 | } 350 | } 351 | --------------------------------------------------------------------------------