├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------