From b7592dfff1097778f53883ef068a6e2ed03d147f Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 26 Jun 2024 18:45:00 +0200 Subject: [PATCH 1/5] Proper justification --- Sources/Flow/Example/ContentView.swift | 45 ++-- Sources/Flow/Internal/Layout.swift | 98 +++++--- Tests/FlowTests/FlowTests.swift | 308 +++++++++++------------- Tests/FlowTests/Utils/Operators.swift | 28 +++ Tests/FlowTests/Utils/TestSubview.swift | 147 +++++++++++ 5 files changed, 409 insertions(+), 217 deletions(-) create mode 100644 Tests/FlowTests/Utils/Operators.swift create mode 100644 Tests/FlowTests/Utils/TestSubview.swift diff --git a/Sources/Flow/Example/ContentView.swift b/Sources/Flow/Example/ContentView.swift index 996c836d..2a34c295 100644 --- a/Sources/Flow/Example/ContentView.swift +++ b/Sources/Flow/Example/ContentView.swift @@ -1,6 +1,6 @@ import SwiftUI -@available(macOS 14.0, *) +@available(macOS 13.0, *) struct ContentView: View { @State private var axis: Axis = .horizontal @State private var contents: Contents = .boxes @@ -68,10 +68,8 @@ struct ContentView: View { } Section(header: Text("Alignment")) { switch axis { - case .horizontal: - picker($verticalAlignment) - case .vertical: - picker($horizontalAlignment) + case .horizontal: picker($verticalAlignment) + case .vertical: picker($horizontalAlignment) } } Section(header: Text("Spacing")) { @@ -79,7 +77,7 @@ struct ContentView: View { stepper("Line", $lineSpacing) } Section(header: Text("Justification")) { - picker($justified) + picker($justified, style: .radioGroup) } } .listStyle(.sidebar) @@ -103,27 +101,29 @@ struct ContentView: View { private func stepper(_ title: String, _ selection: Binding) -> some View { HStack { - Toggle(isOn: Binding(get: { selection.wrappedValue != nil }, - set: { selection.wrappedValue = $0 ? 8 : nil }).animation()) { + Toggle(isOn: Binding( + get: { selection.wrappedValue != nil }, + set: { selection.wrappedValue = $0 ? 8 : nil }).animation() + ) { Text(title) } if let value = selection.wrappedValue { Text("\(value.formatted())") - Stepper("", value: Binding(get: { value }, - set: { selection.wrappedValue = $0 }).animation(), step: 4) + Stepper("", value: Binding( + get: { value }, + set: { selection.wrappedValue = $0 } + ).animation(), step: 4) } }.fixedSize() } - private func picker(_ selection: Binding) -> some View where Value: Hashable & CaseIterable & CustomStringConvertible, Value.AllCases: RandomAccessCollection { + private func picker(_ selection: Binding, style: some PickerStyle = .segmented) -> some View where Value: Hashable & CaseIterable & CustomStringConvertible, Value.AllCases: RandomAccessCollection { Picker("", selection: selection.animation()) { ForEach(Value.allCases, id: \.self) { value in Text(value.description).tag(value) } } - #if !os(watchOS) - .pickerStyle(.segmented) - #endif + .pickerStyle(style) } private var layout: AnyLayout { @@ -157,25 +157,26 @@ enum Contents: String, CustomStringConvertible, CaseIterable { var description: String { rawValue } } +@available(macOS 13.0, *) enum Justified: String, CustomStringConvertible, CaseIterable { case none case stretchItems case stretchSpaces + case stretchItemsAndSpaces var description: String { rawValue } var justification: Justification? { switch self { - case .none: nil - case .stretchItems: .stretchItems - case .stretchSpaces: .stretchSpaces + case .none: nil + case .stretchItems: .stretchItems + case .stretchSpaces: .stretchSpaces + case .stretchItemsAndSpaces: .stretchItemsAndSpaces } } } -@available(macOS 14.0, *) -struct SwiftUIView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } +@available(macOS 13.0, *) +#Preview { + ContentView() } diff --git a/Sources/Flow/Internal/Layout.swift b/Sources/Flow/Internal/Layout.swift index b3f23a28..7323ff60 100644 --- a/Sources/Flow/Internal/Layout.swift +++ b/Sources/Flow/Internal/Layout.swift @@ -76,22 +76,7 @@ struct FlowLayout { var target = bounds.origin.size(on: axis) let originalBreadth = target.breadth let lines = calculateLayout(in: proposal, of: subviews) - for var line in lines { - if let justification { - let remainingSpace = bounds.size.value(on: axis) - line.size.breadth - switch justification { - case .stretchItems: - let distributedSpace = remainingSpace / CGFloat(line.item.count) - for index in line.item.indices { - line.item[index].size.breadth += distributedSpace - } - case .stretchSpaces: - let distributedSpace = remainingSpace / CGFloat(line.item.count - 1) - for index in line.item.indices.dropFirst() { - line.item[index].spacing += distributedSpace - } - } - } + for line in lines { if reversedDepth { target.depth -= line.size.depth } @@ -134,15 +119,7 @@ struct FlowLayout { var lines: Lines = [] let proposedBreadth = proposedSize.replacingUnspecifiedDimensions().value(on: axis) for (index, subview) in subviews.enumerated() { - var size = subview.sizeThatFits(proposedSize).size(on: axis) - if case .stretchItems = justification { - let ideal = subview.dimensions(.unspecified).size(on: axis).breadth - let max = subview.dimensions(.infinity).size(on: axis).breadth - let isFlexible = max - ideal > 0 - if isFlexible { - size.breadth = ideal - } - } + let size = subview.dimensions(.unspecified).size(on: axis) if let lastIndex = lines.indices.last { let spacing = self.itemSpacing(toPrevious: index, subviews: subviews) let additionalBreadth = spacing + size.breadth @@ -153,6 +130,10 @@ struct FlowLayout { } lines.append(.init(subview, size: size)) } + // update flexible items in each line to stretch + for index in lines.indices { + updateFlexibleItems(in: &lines[index], proposedSize: proposedSize) + } // adjust spacings on the perpendicular axis let lineSpacings = lines.map { line in line.item.reduce(into: ViewSpacing()) { $0.formUnion($1.item.spacing) } @@ -171,6 +152,58 @@ struct FlowLayout { guard index != subviews.startIndex else { return 0 } return self.itemSpacing ?? subviews[index.advanced(by: -1)].spacing.distance(to: subviews[index].spacing, along: axis) } + + private func updateFlexibleItems(in line: inout ItemWithSpacing, proposedSize: ProposedViewSize) { + guard let justification else { return } + // TODO: cache these (ordered) properties + let subviewsInPriorityOrder = line.item.enumerated().map { offset, subview in + SubviewProperties( + indexInLine: offset, + priority: subview.item.priority, + spacing: subview.spacing, + min: subview.item.dimensions(.unspecified).value(on: axis), + max: subview.item.dimensions(.infinity).value(on: axis) + ) + }.sorted(using: [KeyPathComparator(\.priority), KeyPathComparator(\.flexibility), KeyPathComparator(\.min)]) + + let sumOfMin = subviewsInPriorityOrder.map { $0.spacing + $0.min }.reduce(into: 0, +=) + var remainingSpace = proposedSize.value(on: axis) - sumOfMin + let count = line.item.count + + if case .stretchSpaces = justification { + let distributedSpace = remainingSpace / Double(count - 1) + for index in line.item.indices.dropFirst() { + line.item[index].spacing += distributedSpace + remainingSpace -= distributedSpace + } + } else { + let sumOfMax = subviewsInPriorityOrder.map { $0.spacing + $0.max }.reduce(into: 0, +=) + let potentialGrowth = sumOfMax - sumOfMin + if potentialGrowth <= remainingSpace { + for subview in subviewsInPriorityOrder { + line.item[subview.indexInLine].size.breadth = subview.max + remainingSpace -= subview.flexibility + } + } else { + var remainingItemCount = count + for subview in subviewsInPriorityOrder { + let offer = remainingSpace / Double(remainingItemCount) + let actual = min(subview.flexibility, offer) + remainingSpace -= actual + remainingItemCount -= 1 + line.item[subview.indexInLine].size.breadth += actual + } + } + if case .stretchItemsAndSpaces = justification { + let distributedSpace = remainingSpace / Double(count - 1) + for index in line.item.indices.dropFirst() { + line.item[index].spacing += distributedSpace + remainingSpace -= distributedSpace + } + } + } + line.size.breadth = proposedSize.value(on: axis) - remainingSpace + } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) @@ -217,6 +250,7 @@ extension LayoutSubviews: Subviews {} @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) protocol Subview { var spacing: ViewSpacing { get } + var priority: Double { get } func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize func dimensions(_ proposal: ProposedViewSize) -> Dimensions func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) @@ -248,8 +282,8 @@ extension Dimensions { func value(on axis: Axis) -> CGFloat { switch axis { - case .horizontal: width - case .vertical: height + case .horizontal: width + case .vertical: height } } } @@ -302,4 +336,14 @@ private extension Array where Element == Size { public enum Justification { case stretchItems case stretchSpaces + case stretchItemsAndSpaces +} + +struct SubviewProperties { + var indexInLine: Int + var priority: Double + var spacing: Double + var min: Double + var max: Double + var flexibility: Double { max - min } } diff --git a/Tests/FlowTests/FlowTests.swift b/Tests/FlowTests/FlowTests.swift index b4dc5841..9e3f0ad0 100644 --- a/Tests/FlowTests/FlowTests.swift +++ b/Tests/FlowTests/FlowTests.swift @@ -6,32 +6,46 @@ import XCTest final class FlowTests: XCTestCase { func test_HFlow_size_singleElement() throws { // Given - let views = [ - TestSubview(width: 50, height: 50) - ] let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 10, lineSpacing: 20) // When - let size = sut.sizeThatFits(proposal: ProposedViewSize(width: 100, height: 100), subviews: views) + let size = sut.sizeThatFits(proposal: 100×100, subviews: [50×50]) // Then - XCTAssertEqual(size, CGSize(width: 50, height: 50)) + XCTAssertEqual(size, 50×50) } func test_HFlow_size_multipleElements() throws { // Given - let views = [ - TestSubview(width: 50, height: 50), - TestSubview(width: 50, height: 50), - TestSubview(width: 50, height: 50) - ] let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 10, lineSpacing: 20) // When - let size = sut.sizeThatFits(proposal: ProposedViewSize(width: 130, height: 130), subviews: views) + let size = sut.sizeThatFits(proposal: 130×130, subviews: repeated(50×50, times: 3)) // Then - XCTAssertEqual(size, CGSize(width: 110, height: 120)) + XCTAssertEqual(size, 110×120) + } + + func test_HFlow_size_justifiedSpaces() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 0, lineSpacing: 0, justification: .stretchSpaces) + + // When + let size = sut.sizeThatFits(proposal: 1000×1000, subviews: [50×50, 50×50]) + + // Then + XCTAssertEqual(size, 1000×50) + } + + func test_HFlow_size_justifiedItems() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 0, lineSpacing: 0, justification: .stretchItems) + + // When + let size = sut.sizeThatFits(proposal: 1000×1000, subviews: [50×1...100×1]) + + // Then + XCTAssertEqual(size, 100×1) } func test_HFlow_layout_top() { @@ -99,7 +113,7 @@ final class FlowTests: XCTestCase { let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0) // When - let result = sut.layout(Array(repeating: 1×1, count: 15), in: 11×3) + let result = sut.layout(repeated(1×1, times: 15), in: 11×3) // Then XCTAssertEqual(render(result), """ @@ -111,6 +125,118 @@ final class FlowTests: XCTestCase { """) } + func test_HFlow_justifiedSpaces_rigid() { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchSpaces) + + // When + let result = sut.layout([3×1, 3×1, 2×1], in: 9×2) + + // Then + XCTAssertEqual(render(result), """ + +---------+ + |XXX XXX| + |XX | + +---------+ + """) + } + + func test_HFlow_justifiedSpaces_flexible() { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchSpaces) + + // When + let result = sut.layout([3×1, 3×1...inf×1, 2×1], in: 9×2) + + // Then + XCTAssertEqual(render(result), """ + +---------+ + |XXX XXX| + |XX | + +---------+ + """) + } + + func test_HFlow_justifiedItems_rigid() { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItems) + + // When + let result = sut.layout([3×1, 3×1, 2×1], in: 9×2) + + // Then + XCTAssertEqual(render(result), """ + +---------+ + |XXX XXX | + |XX | + +---------+ + """) + } + + func test_HFlow_justifiedItems_flexible() { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItems) + + // When + let result = sut.layout([3×1...4×1, 3×1...inf×1, 2×1...5×1], in: 9×2) + + // Then + XCTAssertEqual(render(result), """ + +---------+ + |XXXX XXXX| + |XXXXX | + +---------+ + """) + } + + func test_HFlow_justifiedItemsAndSpaces_rigid() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItemsAndSpaces) + + // When + let result = sut.layout([1×1, 4×1, 3×1, 2×1, 2×1, 3×1], in: 12×2) + + // Then + XCTAssertEqual(render(result), """ + +------------+ + |X XXXX XXX| + |XX XX XXX| + +------------+ + """) + } + + func test_HFlow_justifiedItemsAndSpaces_flexible() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItemsAndSpaces) + + // When + let result = sut.layout([1×1, 2×1...5×1, 1×1...inf×1, 2×1, 5×1...inf×1, 5×1...inf×1], in: 13×2) + + // Then + XCTAssertEqual(render(result), """ + +-------------+ + |X XXXX XXX XX| + |XXXXXX XXXXXX| + +-------------+ + """) + } + + func test_HFlow_justifiedItemsAndSpaces_strethBoth() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItemsAndSpaces) + + // When + let result = sut.layout([4×1...5×1, 4×1...5×1, 4×1...5×1, 4×1...5×1, 4×1...5×1], in: 15×2) + + // Then + XCTAssertEqual(render(result), """ + +---------------+ + |XXXX XXXX XXXXX| + |XXXXX XXXXX| + +---------------+ + """) + } + func test_VFlow_layout_leading() { // Given let sut: FlowLayout = .vertical(alignment: .leading, itemSpacing: 1, lineSpacing: 1) @@ -172,7 +298,7 @@ final class FlowTests: XCTestCase { let sut: FlowLayout = .vertical(alignment: .center, itemSpacing: 0, lineSpacing: 0) // When - let result = sut.layout(Array(repeating: 1×1, count: 17), in: 6×3) + let result = sut.layout(repeated(1×1, times: 17), in: 6×3) // Then XCTAssertEqual(render(result), """ @@ -183,158 +309,4 @@ final class FlowTests: XCTestCase { +------+ """) } - - func test_HFlow_justifiedItems() { - // Given - let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItems) - - // When - let result = sut.layout([3×1, 3×1, 2×1], in: 9×2, flexible: true) - - // Then - XCTAssertEqual(render(result), """ - +---------+ - |XXXX XXXX| - |XXXXXXXXX| - +---------+ - """) - } - - func test_HFlow_justifiedSpaces() { - // Given - let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchSpaces) - - // When - let result = sut.layout([3×1, 3×1, 2×1], in: 9×2, flexible: true) - - // Then - XCTAssertEqual(render(result), """ - +---------+ - |XXX XXX| - |XX | - +---------+ - """) - } -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -private extension FlowLayout { - func layout(_ views: [CGSize], in bounds: CGSize, flexible: Bool = false) -> (subviews: [TestSubview], size: CGSize) { - let subviews = views.map { TestSubview(width: $0.width, height: $0.height, flexible: flexible) } - let size = sizeThatFits( - proposal: ProposedViewSize(width: bounds.width, height: bounds.height), - subviews: subviews - ) - placeSubviews( - in: CGRect(origin: .zero, size: bounds), - proposal: ProposedViewSize(width: size.width, height: size.height), - subviews: subviews - ) - return (subviews, bounds) - } -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -private func render(_ layout: (subviews: [TestSubview], size: CGSize), border: Bool = true) -> String { - struct Point: Hashable { - let x, y: Int - } - - var positions: Set = [] - for view in layout.subviews { - if let point = view.placement { - for y in Int(point.y) ..< Int(point.y + view.size.height) { - for x in Int(point.x) ..< Int(point.x + view.size.width) { - positions.insert(Point(x: x, y: y)) - } - } - } - } - let width = Int(layout.size.width) - let height = Int(layout.size.height) - var result = "" - if border { - result += "+" + String(repeating: "-", count: width) + "+\n" - } - for y in 0 ... height - 1 { - if border { - result += "|" - } - for x in 0 ... width - 1 { - result += positions.contains(Point(x: x, y: y)) ? "X" : " " - } - if border { - result += "|" - } else { - result = result.trimmingCharacters(in: .whitespaces) - } - result += "\n" - } - if border { - result += "+" + String(repeating: "-", count: width) + "+\n" - } - return result.trimmingCharacters(in: .newlines) -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -private final class TestSubview: Subview, CustomStringConvertible { - var spacing = ViewSpacing() - var placement: CGPoint? - var size: CGSize - var flexible: Bool - - init(width: CGFloat, height: CGFloat, flexible: Bool = false) { - size = .init(width: width, height: height) - self.flexible = flexible - } - - func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { - size - } - - func dimensions(_ proposal: ProposedViewSize) -> Dimensions { - TestDimensions(width: size.width, height: size.height) - } - - func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) { - placement = position - if flexible, let width = proposal.width { - size.width = width - } - if flexible, let height = proposal.height { - size.height = height - } - } - - var description: String { - "origin: \((placement?.x).map { "\($0)" } ?? "nil")×\((placement?.y).map { "\($0)" } ?? "nil"), size: \(size.width)×\(size.height)" - } -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension [TestSubview]: Subviews {} - -private struct TestDimensions: Dimensions { - let width, height: CGFloat - - subscript(guide: HorizontalAlignment) -> CGFloat { - switch guide { - case .center: 0.5 * width - case .trailing: width - default: 0 - } - } - - subscript(guide: VerticalAlignment) -> CGFloat { - switch guide { - case .center: 0.5 * height - case .bottom: height - default: 0 - } - } -} - -infix operator ×: MultiplicationPrecedence -private func × (lhs: CGFloat, rhs: CGFloat) -> CGSize { - CGSize(width: lhs, height: rhs) } diff --git a/Tests/FlowTests/Utils/Operators.swift b/Tests/FlowTests/Utils/Operators.swift new file mode 100644 index 00000000..de1cab84 --- /dev/null +++ b/Tests/FlowTests/Utils/Operators.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Foundation + +infix operator ×: MultiplicationPrecedence + +func × (lhs: CGFloat, rhs: CGFloat) -> CGSize { + CGSize(width: lhs, height: rhs) +} + +func × (lhs: CGFloat, rhs: CGFloat) -> TestSubview { + .init(size: .init(width: lhs, height: rhs)) +} + +func × (lhs: CGFloat, rhs: CGFloat) -> ProposedViewSize { + .init(width: lhs, height: rhs) +} + +infix operator ...: RangeFormationPrecedence + +func ... (lhs: CGSize, rhs: CGSize) -> TestSubview { + TestSubview(minSize: lhs, idealSize: lhs, maxSize: rhs) +} + +let inf: CGFloat = .infinity + +func repeated(_ factory: @autoclosure () -> T, times: Int) -> [T] { + (1...times).map { _ in factory() } +} diff --git a/Tests/FlowTests/Utils/TestSubview.swift b/Tests/FlowTests/Utils/TestSubview.swift new file mode 100644 index 00000000..22c95ec0 --- /dev/null +++ b/Tests/FlowTests/Utils/TestSubview.swift @@ -0,0 +1,147 @@ +import SwiftUI +import XCTest +@testable import Flow + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +final class TestSubview: Subview, CustomStringConvertible { + var spacing = ViewSpacing() + var priority: Double = 1 + var placement: (position: CGPoint, size: CGSize)? + var minSize: CGSize + var idealSize: CGSize + var maxSize: CGSize + + init(size: CGSize) { + minSize = size + idealSize = size + maxSize = size + } + + init(minSize: CGSize, idealSize: CGSize, maxSize: CGSize) { + self.minSize = minSize + self.idealSize = idealSize + self.maxSize = maxSize + } + + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + switch proposal { + case .zero: + minSize + case .unspecified: + idealSize + case .infinity: + maxSize + default: + CGSize( + width: min(max(minSize.width, proposal.width ?? idealSize.width), maxSize.width), + height: min(max(minSize.height, proposal.height ?? idealSize.height), maxSize.height) + ) + } + } + + func dimensions(_ proposal: ProposedViewSize) -> Dimensions { + let size = switch proposal { + case .zero: minSize + case .unspecified: idealSize + case .infinity: maxSize + default: sizeThatFits(proposal) + } + return TestDimensions(width: size.width, height: size.height) + } + + func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) { + let size = sizeThatFits(proposal) + placement = (position, size) + } + + var description: String { + "origin: \((placement?.position.x).map { "\($0)" } ?? "nil")×\((placement?.position.y).map { "\($0)" } ?? "nil"), size: \(idealSize.width)×\(idealSize.height)" + } +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension [TestSubview]: Subviews {} + +typealias LayoutDescription = (subviews: [TestSubview], reportedSize: CGSize) + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension FlowLayout { + func layout(_ subviews: [TestSubview], in bounds: CGSize) -> LayoutDescription { + let size = sizeThatFits( + proposal: ProposedViewSize(width: bounds.width, height: bounds.height), + subviews: subviews + ) + placeSubviews( + in: CGRect(origin: .zero, size: bounds), + proposal: ProposedViewSize(width: size.width, height: size.height), + subviews: subviews + ) + return (subviews, bounds) + } +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +func render(_ layout: LayoutDescription, border: Bool = true) -> String { + struct Point: Hashable { + let x, y: Int + } + + var positions: Set = [] + for view in layout.subviews { + if let placement = view.placement { + let point = placement.position + for y in Int(point.y) ..< Int(point.y + placement.size.height) { + for x in Int(point.x) ..< Int(point.x + placement.size.width) { + let result = positions.insert(Point(x: x, y: y)) + precondition(result.inserted, "Boxes should not overlap") + } + } + } else { + fatalError("Should be placed") + } + } + let width = Int(layout.reportedSize.width) + let height = Int(layout.reportedSize.height) + var result = "" + if border { + result += "+" + String(repeating: "-", count: width) + "+\n" + } + for y in 0 ... height - 1 { + if border { + result += "|" + } + for x in 0 ... width - 1 { + result += positions.contains(Point(x: x, y: y)) ? "X" : " " + } + if border { + result += "|" + } else { + result = result.trimmingCharacters(in: .whitespaces) + } + result += "\n" + } + if border { + result += "+" + String(repeating: "-", count: width) + "+\n" + } + return result.trimmingCharacters(in: .newlines) +} + +private struct TestDimensions: Dimensions { + let width, height: CGFloat + + subscript(guide: HorizontalAlignment) -> CGFloat { + switch guide { + case .center: 0.5 * width + case .trailing: width + default: 0 + } + } + + subscript(guide: VerticalAlignment) -> CGFloat { + switch guide { + case .center: 0.5 * height + case .bottom: height + default: 0 + } + } +} From 7908ca596d893a1816317c8f273ca0f122edab98 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 26 Jun 2024 18:45:00 +0200 Subject: [PATCH 2/5] Cache --- Sources/Flow/HFlow.swift | 8 +- Sources/Flow/Internal/HFlowLayout.swift | 8 +- Sources/Flow/Internal/Layout.swift | 160 +++++++----------------- Sources/Flow/Internal/Protocols.swift | 48 +++++++ Sources/Flow/Internal/VFlowLayout.swift | 8 +- Sources/Flow/Support.swift | 35 ++++++ Sources/Flow/VFlow.swift | 8 +- Tests/FlowTests/Utils/TestSubview.swift | 12 +- 8 files changed, 163 insertions(+), 124 deletions(-) create mode 100644 Sources/Flow/Internal/Protocols.swift create mode 100644 Sources/Flow/Support.swift diff --git a/Sources/Flow/HFlow.swift b/Sources/Flow/HFlow.swift index 96f090db..c9cdda89 100644 --- a/Sources/Flow/HFlow.swift +++ b/Sources/Flow/HFlow.swift @@ -136,7 +136,7 @@ extension HFlow: Layout where Content == EmptyView { } } - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits( proposal: proposal, subviews: subviews, @@ -144,7 +144,7 @@ extension HFlow: Layout where Content == EmptyView { ) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews( in: bounds, proposal: proposal, @@ -153,6 +153,10 @@ extension HFlow: Layout where Content == EmptyView { ) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .horizontal) + } + public static var layoutProperties: LayoutProperties { HFlowLayout.layoutProperties } diff --git a/Sources/Flow/Internal/HFlowLayout.swift b/Sources/Flow/Internal/HFlowLayout.swift index 07401668..13842dfc 100644 --- a/Sources/Flow/Internal/HFlowLayout.swift +++ b/Sources/Flow/Internal/HFlowLayout.swift @@ -21,14 +21,18 @@ public struct HFlowLayout { @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) extension HFlowLayout: Layout { - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .horizontal) + } + public static var layoutProperties: LayoutProperties { var properties = LayoutProperties() properties.stackOrientation = .horizontal diff --git a/Sources/Flow/Internal/Layout.swift b/Sources/Flow/Internal/Layout.swift index 7323ff60..90b9b9ac 100644 --- a/Sources/Flow/Internal/Layout.swift +++ b/Sources/Flow/Internal/Layout.swift @@ -26,31 +26,28 @@ struct FlowLayout { self.spacing = spacing } - init(_ item: some Subview, size: Size) where T == [ItemWithSpacing] { + init(_ item: Item, size: Size) where T == [ItemWithSpacing] { self.init([.init(item, size: size)], size: size) } - mutating func append( - _ item: some Subview, - size: Size, - spacing: CGFloat - ) - where T == [ItemWithSpacing] { + mutating func append(_ item: Item, size: Size, spacing: CGFloat) where Self == ItemWithSpacing { self.item.append(.init(item, size: size, spacing: spacing)) self.size = Size(breadth: self.size.breadth + spacing + size.breadth, depth: max(self.size.depth, size.depth)) } } - private typealias Line = [ItemWithSpacing] + private typealias Item = (subview: Subview, cache: FlowLayoutCache.SubviewCache) + private typealias Line = [ItemWithSpacing] private typealias Lines = [ItemWithSpacing] func sizeThatFits( proposal proposedSize: ProposedViewSize, - subviews: some Subviews + subviews: some Subviews, + cache: inout FlowLayoutCache ) -> CGSize { guard !subviews.isEmpty else { return .zero } - let lines = calculateLayout(in: proposedSize, of: subviews) + let lines = calculateLayout(in: proposedSize, of: subviews, cache: &cache) let spacings = lines.map(\.spacing).reduce(into: 0, +=) let size = lines .map(\.size) @@ -62,7 +59,8 @@ struct FlowLayout { func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, - subviews: some Subviews + subviews: some Subviews, + cache: inout FlowLayoutCache ) { guard !subviews.isEmpty else { return } @@ -75,7 +73,7 @@ struct FlowLayout { } var target = bounds.origin.size(on: axis) let originalBreadth = target.breadth - let lines = calculateLayout(in: proposal, of: subviews) + let lines = calculateLayout(in: proposal, of: subviews, cache: &cache) for line in lines { if reversedDepth { target.depth -= line.size.depth @@ -98,6 +96,10 @@ struct FlowLayout { } } + func makeCache(_ subviews: some Subviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: axis) + } + private func alignAndPlace( _ item: Line.Element, in line: Lines.Element, @@ -107,28 +109,34 @@ struct FlowLayout { let proposedSize = ProposedViewSize(size: Size(breadth: item.size.breadth, depth: line.size.depth), axis: axis) let depth = item.size.depth if depth > 0 { - placement.depth += (align(item.item.dimensions(proposedSize)) / depth) * (line.size.depth - depth) + placement.depth += (align(item.item.subview.dimensions(proposedSize)) / depth) * (line.size.depth - depth) } - item.item.place(at: .init(size: placement, axis: axis), anchor: .topLeading, proposal: proposedSize) + item.item.subview.place(at: .init(size: placement, axis: axis), anchor: .topLeading, proposal: proposedSize) } private func calculateLayout( in proposedSize: ProposedViewSize, - of subviews: some Subviews + of subviews: some Subviews, + cache: inout FlowLayoutCache ) -> Lines { var lines: Lines = [] let proposedBreadth = proposedSize.replacingUnspecifiedDimensions().value(on: axis) for (index, subview) in subviews.enumerated() { let size = subview.dimensions(.unspecified).size(on: axis) + let cached = if cache.subviewsCache.indices.contains(index) { + cache.subviewsCache[index] + } else { + FlowLayoutCache.SubviewCache(subview, axis: axis) + } if let lastIndex = lines.indices.last { let spacing = self.itemSpacing(toPrevious: index, subviews: subviews) let additionalBreadth = spacing + size.breadth if lines[lastIndex].size.breadth + additionalBreadth <= proposedBreadth { - lines[lastIndex].append(subview, size: size, spacing: spacing) + lines[lastIndex].append((subview, cached), size: size, spacing: spacing) continue } } - lines.append(.init(subview, size: size)) + lines.append(.init((subview: subview, cache: cached), size: size)) } // update flexible items in each line to stretch for index in lines.indices { @@ -136,7 +144,7 @@ struct FlowLayout { } // adjust spacings on the perpendicular axis let lineSpacings = lines.map { line in - line.item.reduce(into: ViewSpacing()) { $0.formUnion($1.item.spacing) } + line.item.reduce(into: ViewSpacing()) { $0.formUnion($1.item.cache.spacing) } } for index in lines.indices.dropFirst() { let spacing = self.lineSpacing ?? lineSpacings[index].distance(to: lineSpacings[index.advanced(by: -1)], along: axis.perpendicular) @@ -155,19 +163,12 @@ struct FlowLayout { private func updateFlexibleItems(in line: inout ItemWithSpacing, proposedSize: ProposedViewSize) { guard let justification else { return } - // TODO: cache these (ordered) properties let subviewsInPriorityOrder = line.item.enumerated().map { offset, subview in - SubviewProperties( - indexInLine: offset, - priority: subview.item.priority, - spacing: subview.spacing, - min: subview.item.dimensions(.unspecified).value(on: axis), - max: subview.item.dimensions(.infinity).value(on: axis) - ) - }.sorted(using: [KeyPathComparator(\.priority), KeyPathComparator(\.flexibility), KeyPathComparator(\.min)]) + SubviewProperties(indexInLine: offset, spacing: subview.spacing, cache: subview.item.cache) + }.sorted(using: [KeyPathComparator(\.cache.priority), KeyPathComparator(\.flexibility), KeyPathComparator(\.cache.ideal)]) - let sumOfMin = subviewsInPriorityOrder.map { $0.spacing + $0.min }.reduce(into: 0, +=) - var remainingSpace = proposedSize.value(on: axis) - sumOfMin + let sumOfIdeal = subviewsInPriorityOrder.map { $0.spacing + $0.cache.ideal }.reduce(into: 0, +=) + var remainingSpace = proposedSize.value(on: axis) - sumOfIdeal let count = line.item.count if case .stretchSpaces = justification { @@ -177,11 +178,11 @@ struct FlowLayout { remainingSpace -= distributedSpace } } else { - let sumOfMax = subviewsInPriorityOrder.map { $0.spacing + $0.max }.reduce(into: 0, +=) - let potentialGrowth = sumOfMax - sumOfMin + let sumOfMax = subviewsInPriorityOrder.map { $0.spacing + $0.cache.max }.reduce(into: 0, +=) + let potentialGrowth = sumOfMax - sumOfIdeal if potentialGrowth <= remainingSpace { for subview in subviewsInPriorityOrder { - line.item[subview.indexInLine].size.breadth = subview.max + line.item[subview.indexInLine].size.breadth = subview.cache.max remainingSpace -= subview.flexibility } } else { @@ -207,7 +208,11 @@ struct FlowLayout { } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension FlowLayout { +extension FlowLayout: Layout { + func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + makeCache(subviews) + } + static func vertical( alignment: HorizontalAlignment, itemSpacing: CGFloat?, @@ -241,74 +246,6 @@ extension FlowLayout { } } -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -protocol Subviews: RandomAccessCollection where Element: Subview, Index == Int {} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension LayoutSubviews: Subviews {} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -protocol Subview { - var spacing: ViewSpacing { get } - var priority: Double { get } - func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize - func dimensions(_ proposal: ProposedViewSize) -> Dimensions - func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension LayoutSubview: Subview { - func dimensions(_ proposal: ProposedViewSize) -> Dimensions { - dimensions(in: proposal) - } -} - -protocol Dimensions { - var width: CGFloat { get } - var height: CGFloat { get } - - subscript(guide: HorizontalAlignment) -> CGFloat { get } - subscript(guide: VerticalAlignment) -> CGFloat { get } -} -extension ViewDimensions: Dimensions {} - -extension Dimensions { - func size(on axis: Axis) -> Size { - Size( - breadth: value(on: axis), - depth: value(on: axis.perpendicular) - ) - } - - func value(on axis: Axis) -> CGFloat { - switch axis { - case .horizontal: width - case .vertical: height - } - } -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension FlowLayout: Layout { - typealias Cache = Void - func sizeThatFits( - proposal proposedSize: ProposedViewSize, - subviews: LayoutSubviews, - cache: inout Cache - ) -> CGSize { - sizeThatFits(proposal: proposedSize, subviews: subviews) - } - - func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: LayoutSubviews, - cache: inout Cache - ) { - placeSubviews(in: bounds, proposal: proposal, subviews: subviews) - } -} - private extension CGRect { mutating func reverseOrigin(along axis: Axis) { switch axis { @@ -327,23 +264,18 @@ private extension Array where Element == Size { depth: (CGFloat, CGFloat) -> CGFloat ) -> Size { reduce(initial) { result, size in - Size(breadth: breadth(result.breadth, size.breadth), - depth: depth(result.depth, size.depth)) + Size( + breadth: breadth(result.breadth, size.breadth), + depth: depth(result.depth, size.depth) + ) } } } -public enum Justification { - case stretchItems - case stretchSpaces - case stretchItemsAndSpaces -} - -struct SubviewProperties { +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +private struct SubviewProperties { var indexInLine: Int - var priority: Double var spacing: Double - var min: Double - var max: Double - var flexibility: Double { max - min } + var cache: FlowLayoutCache.SubviewCache + var flexibility: Double { cache.max - cache.ideal } } diff --git a/Sources/Flow/Internal/Protocols.swift b/Sources/Flow/Internal/Protocols.swift new file mode 100644 index 00000000..9dd516d1 --- /dev/null +++ b/Sources/Flow/Internal/Protocols.swift @@ -0,0 +1,48 @@ +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +protocol Subviews: RandomAccessCollection where Element: Subview, Index == Int {} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension LayoutSubviews: Subviews {} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +protocol Subview { + var spacing: ViewSpacing { get } + var priority: Double { get } + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize + func dimensions(_ proposal: ProposedViewSize) -> Dimensions + func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension LayoutSubview: Subview { + func dimensions(_ proposal: ProposedViewSize) -> Dimensions { + dimensions(in: proposal) + } +} + +protocol Dimensions { + var width: CGFloat { get } + var height: CGFloat { get } + + subscript(guide: HorizontalAlignment) -> CGFloat { get } + subscript(guide: VerticalAlignment) -> CGFloat { get } +} +extension ViewDimensions: Dimensions {} + +extension Dimensions { + func size(on axis: Axis) -> Size { + Size( + breadth: value(on: axis), + depth: value(on: axis.perpendicular) + ) + } + + func value(on axis: Axis) -> CGFloat { + switch axis { + case .horizontal: width + case .vertical: height + } + } +} diff --git a/Sources/Flow/Internal/VFlowLayout.swift b/Sources/Flow/Internal/VFlowLayout.swift index a1e9dc36..38447686 100644 --- a/Sources/Flow/Internal/VFlowLayout.swift +++ b/Sources/Flow/Internal/VFlowLayout.swift @@ -21,14 +21,18 @@ public struct VFlowLayout { @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) extension VFlowLayout: Layout { - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .vertical) + } + public static var layoutProperties: LayoutProperties { var properties = LayoutProperties() properties.stackOrientation = .vertical diff --git a/Sources/Flow/Support.swift b/Sources/Flow/Support.swift new file mode 100644 index 00000000..895003c7 --- /dev/null +++ b/Sources/Flow/Support.swift @@ -0,0 +1,35 @@ +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public enum Justification { + case stretchItems + case stretchSpaces + case stretchItemsAndSpaces +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public struct FlowLayoutCache { + struct SubviewCache { + var priority: Double + var spacing: ViewSpacing + var min: Double + var ideal: Double + var max: Double + + init(_ subview: some Subview, axis: Axis) { + priority = subview.priority + spacing = subview.spacing + min = subview.dimensions(.zero).value(on: axis) + ideal = subview.dimensions(.unspecified).value(on: axis) + max = subview.dimensions(.infinity).value(on: axis) + } + } + + let subviewsCache: [SubviewCache] + + init(_ subviews: some Subviews, axis: Axis) { + subviewsCache = subviews.map { + SubviewCache($0, axis: axis) + } + } +} diff --git a/Sources/Flow/VFlow.swift b/Sources/Flow/VFlow.swift index 3dea99b5..52842aa8 100644 --- a/Sources/Flow/VFlow.swift +++ b/Sources/Flow/VFlow.swift @@ -140,7 +140,7 @@ extension VFlow: Layout where Content == EmptyView { } } - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits( proposal: proposal, subviews: subviews, @@ -148,7 +148,7 @@ extension VFlow: Layout where Content == EmptyView { ) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews( in: bounds, proposal: proposal, @@ -157,6 +157,10 @@ extension VFlow: Layout where Content == EmptyView { ) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .vertical) + } + public static var layoutProperties: LayoutProperties { VFlowLayout.layoutProperties } diff --git a/Tests/FlowTests/Utils/TestSubview.swift b/Tests/FlowTests/Utils/TestSubview.swift index 22c95ec0..f4e79356 100644 --- a/Tests/FlowTests/Utils/TestSubview.swift +++ b/Tests/FlowTests/Utils/TestSubview.swift @@ -67,17 +67,25 @@ typealias LayoutDescription = (subviews: [TestSubview], reportedSize: CGSize) @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) extension FlowLayout { func layout(_ subviews: [TestSubview], in bounds: CGSize) -> LayoutDescription { + var cache = makeCache(subviews) let size = sizeThatFits( proposal: ProposedViewSize(width: bounds.width, height: bounds.height), - subviews: subviews + subviews: subviews, + cache: &cache ) placeSubviews( in: CGRect(origin: .zero, size: bounds), proposal: ProposedViewSize(width: size.width, height: size.height), - subviews: subviews + subviews: subviews, + cache: &cache ) return (subviews, bounds) } + + func sizeThatFits(proposal: ProposedViewSize, subviews: [TestSubview]) -> CGSize { + var cache = makeCache(subviews) + return sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) + } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) From 59796726fc1e43e561b9d4fe5a4e92d4b9631182 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 26 Jun 2024 18:45:00 +0200 Subject: [PATCH 3/5] Distribute evenly --- Sources/Flow/Example/ContentView.swift | 22 ++-- Sources/Flow/HFlow.swift | 12 +- Sources/Flow/Internal/HFlowLayout.swift | 6 +- Sources/Flow/Internal/Layout.swift | 147 +++++++++++++++++------- Sources/Flow/Internal/VFlowLayout.swift | 6 +- Sources/Flow/Support.swift | 12 +- Sources/Flow/VFlow.swift | 18 ++- 7 files changed, 153 insertions(+), 70 deletions(-) diff --git a/Sources/Flow/Example/ContentView.swift b/Sources/Flow/Example/ContentView.swift index 2a34c295..08813dc3 100644 --- a/Sources/Flow/Example/ContentView.swift +++ b/Sources/Flow/Example/ContentView.swift @@ -11,6 +11,7 @@ struct ContentView: View { @State private var justified: Justified = .none @State private var horizontalAlignment: HAlignment = .center @State private var verticalAlignment: VAlignment = .center + @State private var distibuteItemsEvenly: Bool = false private let texts = "This is a long text that wraps nicely in flow layout".components(separatedBy: " ").map { string in AnyView(Text(string)) } @@ -76,12 +77,13 @@ struct ContentView: View { stepper("Item", $itemSpacing) stepper("Line", $lineSpacing) } - Section(header: Text("Justification")) { + Section(header: Text("Extras")) { picker($justified, style: .radioGroup) + Toggle("Distibute evenly", isOn: $distibuteItemsEvenly.animation()) } } .listStyle(.sidebar) - .frame(minWidth: 280) + .frame(minWidth: 250) .navigationTitle("Flow Layout") .padding() } detail: { @@ -96,7 +98,7 @@ struct ContentView: View { .frame(maxWidth: width, maxHeight: height) .border(.red) } - .frame(minWidth: 600, minHeight: 500) + .frame(minWidth: 600, minHeight: 600) } private func stepper(_ title: String, _ selection: Binding) -> some View { @@ -134,7 +136,8 @@ struct ContentView: View { alignment: verticalAlignment.value, itemSpacing: itemSpacing, rowSpacing: lineSpacing, - justification: justified.justification + justification: justified.justification, + distibuteItemsEvenly: distibuteItemsEvenly ) ) case .vertical: @@ -143,7 +146,8 @@ struct ContentView: View { alignment: horizontalAlignment.value, itemSpacing: itemSpacing, columnSpacing: lineSpacing, - justification: justified.justification + justification: justified.justification, + distibuteItemsEvenly: distibuteItemsEvenly ) ) } @@ -159,10 +163,10 @@ enum Contents: String, CustomStringConvertible, CaseIterable { @available(macOS 13.0, *) enum Justified: String, CustomStringConvertible, CaseIterable { - case none - case stretchItems - case stretchSpaces - case stretchItemsAndSpaces + case none = "no justification" + case stretchItems = "stretch items" + case stretchSpaces = "stretch spaces" + case stretchItemsAndSpaces = "stretch both" var description: String { rawValue } diff --git a/Sources/Flow/HFlow.swift b/Sources/Flow/HFlow.swift index c9cdda89..cca55758 100644 --- a/Sources/Flow/HFlow.swift +++ b/Sources/Flow/HFlow.swift @@ -40,6 +40,7 @@ public struct HFlow: View { itemSpacing: CGFloat? = nil, rowSpacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { content = contentBuilder() @@ -47,7 +48,8 @@ public struct HFlow: View { alignment: alignment, itemSpacing: itemSpacing, rowSpacing: rowSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } @@ -65,6 +67,7 @@ public struct HFlow: View { alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { self.init( @@ -72,6 +75,7 @@ public struct HFlow: View { itemSpacing: spacing, rowSpacing: spacing, justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly, content: contentBuilder ) } @@ -105,13 +109,15 @@ extension HFlow: Layout where Content == EmptyView { alignment: VerticalAlignment = .center, itemSpacing: CGFloat? = nil, rowSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { self.init( alignment: alignment, itemSpacing: itemSpacing, rowSpacing: rowSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { EmptyView() } diff --git a/Sources/Flow/Internal/HFlowLayout.swift b/Sources/Flow/Internal/HFlowLayout.swift index 13842dfc..02109cf7 100644 --- a/Sources/Flow/Internal/HFlowLayout.swift +++ b/Sources/Flow/Internal/HFlowLayout.swift @@ -8,13 +8,15 @@ public struct HFlowLayout { alignment: VerticalAlignment = .center, itemSpacing: CGFloat? = nil, rowSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { layout = .horizontal( alignment: alignment, itemSpacing: itemSpacing, lineSpacing: rowSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } } diff --git a/Sources/Flow/Internal/Layout.swift b/Sources/Flow/Internal/Layout.swift index 90b9b9ac..bf8e0a6c 100644 --- a/Sources/Flow/Internal/Layout.swift +++ b/Sources/Flow/Internal/Layout.swift @@ -9,29 +9,16 @@ struct FlowLayout { var reversedBreadth: Bool = false var reversedDepth: Bool = false var justification: Justification? + var distibuteItemsEvenly: Bool let align: (Dimensions) -> CGFloat private struct ItemWithSpacing { var item: T var size: Size - var spacing: CGFloat - - private init( - _ item: T, - size: Size, - spacing: CGFloat = 0 - ) { - self.item = item - self.size = size - self.spacing = spacing - } - - init(_ item: Item, size: Size) where T == [ItemWithSpacing] { - self.init([.init(item, size: size)], size: size) - } + var spacing: CGFloat = 0 mutating func append(_ item: Item, size: Size, spacing: CGFloat) where Self == ItemWithSpacing { - self.item.append(.init(item, size: size, spacing: spacing)) + self.item.append(.init(item: item, size: size, spacing: spacing)) self.size = Size(breadth: self.size.breadth + spacing + size.breadth, depth: max(self.size.depth, size.depth)) } } @@ -47,7 +34,7 @@ struct FlowLayout { ) -> CGSize { guard !subviews.isEmpty else { return .zero } - let lines = calculateLayout(in: proposedSize, of: subviews, cache: &cache) + let lines = calculateLayout(in: proposedSize, of: subviews, cache: cache) let spacings = lines.map(\.spacing).reduce(into: 0, +=) let size = lines .map(\.size) @@ -73,7 +60,7 @@ struct FlowLayout { } var target = bounds.origin.size(on: axis) let originalBreadth = target.breadth - let lines = calculateLayout(in: proposal, of: subviews, cache: &cache) + let lines = calculateLayout(in: proposal, of: subviews, cache: cache) for line in lines { if reversedDepth { target.depth -= line.size.depth @@ -117,32 +104,104 @@ struct FlowLayout { private func calculateLayout( in proposedSize: ProposedViewSize, of subviews: some Subviews, - cache: inout FlowLayoutCache + cache: FlowLayoutCache ) -> Lines { var lines: Lines = [] let proposedBreadth = proposedSize.replacingUnspecifiedDimensions().value(on: axis) - for (index, subview) in subviews.enumerated() { - let size = subview.dimensions(.unspecified).size(on: axis) - let cached = if cache.subviewsCache.indices.contains(index) { - cache.subviewsCache[index] - } else { - FlowLayoutCache.SubviewCache(subview, axis: axis) + + let sizes = cache.subviewsCache.map(\.ideal) + let spacings = if let itemSpacing { + [0] + Array(repeating: itemSpacing, count: subviews.count - 1) + } else { + [0] + zip(cache.subviewsCache, cache.subviewsCache.dropFirst()).map { lhs, rhs in + lhs.spacing.distance(to: rhs.spacing, along: axis) } + } + + for index in subviews.indices { + let (subview, size, spacing, cache) = (subviews[index], sizes[index], spacings[index], cache.subviewsCache[index]) if let lastIndex = lines.indices.last { - let spacing = self.itemSpacing(toPrevious: index, subviews: subviews) let additionalBreadth = spacing + size.breadth if lines[lastIndex].size.breadth + additionalBreadth <= proposedBreadth { - lines[lastIndex].append((subview, cached), size: size, spacing: spacing) + lines[lastIndex].append((subview, cache), size: size, spacing: spacing) continue } } - lines.append(.init((subview: subview, cache: cached), size: size)) + lines.append(.init(item: [.init(item: (subview: subview, cache: cache), size: size)], size: size)) } - // update flexible items in each line to stretch + distributeItems(in: &lines, proposedSize: proposedSize, subviews: subviews, sizes: sizes, spacings: spacings, cache: cache) + updateFlexibleItems(in: &lines, proposedSize: proposedSize) + updateLineSpacings(in: &lines) + return lines + } + + private func distributeItems( + in lines: inout Lines, + proposedSize: ProposedViewSize, + subviews: some Subviews, + sizes: [Size], + spacings: [CGFloat], + cache: FlowLayoutCache + ) { + guard distibuteItemsEvenly else { return } + + // Knuth-Plass Line Breaking Algorithm + let proposedBreadth = proposedSize.replacingUnspecifiedDimensions().value(on: axis) + let count = sizes.count + var costs: [CGFloat] = Array(repeating: CGFloat.infinity, count: count + 1) + var breaks: [Int?] = Array(repeating: nil, count: count + 1) + + costs[0] = 0 + + for end in 0 ..< count { + var totalBreadth: CGFloat = 0 + for start in stride(from: end, through: 0, by: -1) { + let size = sizes[start].breadth + let spacing = start > 0 && start < end ? spacings[start] : 0 + totalBreadth += size + spacing + if totalBreadth > proposedBreadth { + break + } + let penalty = abs(totalBreadth - (proposedBreadth / CGFloat(end - start + 1))) + let cost = costs[start] + pow(proposedBreadth - totalBreadth, 2) + penalty + if cost < costs[end + 1] { + costs[end + 1] = cost + breaks[end + 1] = start + } + } + } + + var breakpoints: [Int] = [] + var i = count + while let breakPoint = breaks[i], breakPoint >= 0 { + breakpoints.insert(i, at: 0) + i = breakPoint + } + breakpoints.insert(0, at: 0) + + var newLines: Lines = [] + for (start, end) in zip(breakpoints, breakpoints.dropFirst()) { + var line: ItemWithSpacing = .init(item: [], size: .zero) + for index in start..( @@ -161,13 +219,12 @@ struct FlowLayout { return self.itemSpacing ?? subviews[index.advanced(by: -1)].spacing.distance(to: subviews[index].spacing, along: axis) } - private func updateFlexibleItems(in line: inout ItemWithSpacing, proposedSize: ProposedViewSize) { - guard let justification else { return } + private func updateFlexibleItems(in line: inout ItemWithSpacing, proposedSize: ProposedViewSize, justification: Justification) { let subviewsInPriorityOrder = line.item.enumerated().map { offset, subview in SubviewProperties(indexInLine: offset, spacing: subview.spacing, cache: subview.item.cache) - }.sorted(using: [KeyPathComparator(\.cache.priority), KeyPathComparator(\.flexibility), KeyPathComparator(\.cache.ideal)]) + }.sorted(using: [KeyPathComparator(\.cache.priority), KeyPathComparator(\.flexibility), KeyPathComparator(\.cache.ideal.breadth)]) - let sumOfIdeal = subviewsInPriorityOrder.map { $0.spacing + $0.cache.ideal }.reduce(into: 0, +=) + let sumOfIdeal = subviewsInPriorityOrder.map { $0.spacing + $0.cache.ideal.breadth }.reduce(into: 0, +=) var remainingSpace = proposedSize.value(on: axis) - sumOfIdeal let count = line.item.count @@ -178,11 +235,11 @@ struct FlowLayout { remainingSpace -= distributedSpace } } else { - let sumOfMax = subviewsInPriorityOrder.map { $0.spacing + $0.cache.max }.reduce(into: 0, +=) + let sumOfMax = subviewsInPriorityOrder.map { $0.spacing + $0.cache.max.breadth }.reduce(into: 0, +=) let potentialGrowth = sumOfMax - sumOfIdeal if potentialGrowth <= remainingSpace { for subview in subviewsInPriorityOrder { - line.item[subview.indexInLine].size.breadth = subview.cache.max + line.item[subview.indexInLine].size.breadth = subview.cache.max.breadth remainingSpace -= subview.flexibility } } else { @@ -217,13 +274,15 @@ extension FlowLayout: Layout { alignment: HorizontalAlignment, itemSpacing: CGFloat?, lineSpacing: CGFloat?, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) -> FlowLayout { .init( axis: .vertical, itemSpacing: itemSpacing, lineSpacing: lineSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { $0[alignment] } @@ -233,13 +292,15 @@ extension FlowLayout: Layout { alignment: VerticalAlignment, itemSpacing: CGFloat?, lineSpacing: CGFloat?, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) -> FlowLayout { .init( axis: .horizontal, itemSpacing: itemSpacing, lineSpacing: lineSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { $0[alignment] } @@ -277,5 +338,5 @@ private struct SubviewProperties { var indexInLine: Int var spacing: Double var cache: FlowLayoutCache.SubviewCache - var flexibility: Double { cache.max - cache.ideal } + var flexibility: Double { cache.max.breadth - cache.ideal.breadth } } diff --git a/Sources/Flow/Internal/VFlowLayout.swift b/Sources/Flow/Internal/VFlowLayout.swift index 38447686..913aafa8 100644 --- a/Sources/Flow/Internal/VFlowLayout.swift +++ b/Sources/Flow/Internal/VFlowLayout.swift @@ -8,13 +8,15 @@ public struct VFlowLayout { alignment: HorizontalAlignment = .center, itemSpacing: CGFloat? = nil, columnSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { layout = .vertical( alignment: alignment, itemSpacing: itemSpacing, lineSpacing: columnSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } } diff --git a/Sources/Flow/Support.swift b/Sources/Flow/Support.swift index 895003c7..b334625b 100644 --- a/Sources/Flow/Support.swift +++ b/Sources/Flow/Support.swift @@ -12,16 +12,16 @@ public struct FlowLayoutCache { struct SubviewCache { var priority: Double var spacing: ViewSpacing - var min: Double - var ideal: Double - var max: Double + var min: Size + var ideal: Size + var max: Size init(_ subview: some Subview, axis: Axis) { priority = subview.priority spacing = subview.spacing - min = subview.dimensions(.zero).value(on: axis) - ideal = subview.dimensions(.unspecified).value(on: axis) - max = subview.dimensions(.infinity).value(on: axis) + min = subview.dimensions(.zero).size(on: axis) + ideal = subview.dimensions(.unspecified).size(on: axis) + max = subview.dimensions(.infinity).size(on: axis) } } diff --git a/Sources/Flow/VFlow.swift b/Sources/Flow/VFlow.swift index 52842aa8..8f89f48d 100644 --- a/Sources/Flow/VFlow.swift +++ b/Sources/Flow/VFlow.swift @@ -40,6 +40,7 @@ public struct VFlow: View { itemSpacing: CGFloat? = nil, columnSpacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { content = contentBuilder() @@ -47,7 +48,8 @@ public struct VFlow: View { alignment: alignment, itemSpacing: itemSpacing, columnSpacing: columnSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } @@ -65,6 +67,7 @@ public struct VFlow: View { alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { self.init( @@ -72,6 +75,7 @@ public struct VFlow: View { itemSpacing: spacing, columnSpacing: spacing, justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly, content: contentBuilder ) } @@ -105,13 +109,15 @@ extension VFlow: Layout where Content == EmptyView { alignment: HorizontalAlignment = .center, itemSpacing: CGFloat? = nil, columnSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { self.init( alignment: alignment, itemSpacing: itemSpacing, columnSpacing: columnSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { EmptyView() } @@ -129,12 +135,14 @@ extension VFlow: Layout where Content == EmptyView { public init( alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { self.init( alignment: alignment, spacing: spacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { EmptyView() } From c576defee9e4a551480a1d735e23a76b143fbefd Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 26 Jun 2024 18:45:00 +0200 Subject: [PATCH 4/5] Fixed reversing lines --- Sources/Flow/Internal/Layout.swift | 46 ++++++++++++++---------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/Sources/Flow/Internal/Layout.swift b/Sources/Flow/Internal/Layout.swift index bf8e0a6c..758a9d6e 100644 --- a/Sources/Flow/Internal/Layout.swift +++ b/Sources/Flow/Internal/Layout.swift @@ -7,6 +7,7 @@ struct FlowLayout { var itemSpacing: CGFloat? var lineSpacing: CGFloat? var reversedBreadth: Bool = false + var alternatingReversedBreadth: Bool = false var reversedDepth: Bool = false var justification: Justification? var distibuteItemsEvenly: Bool @@ -51,35 +52,43 @@ struct FlowLayout { ) { guard !subviews.isEmpty else { return } - var bounds = bounds - if reversedBreadth { - bounds.reverseOrigin(along: axis) - } - if reversedDepth { - bounds.reverseOrigin(along: axis.perpendicular) - } + var reversedBreadth = self.reversedBreadth var target = bounds.origin.size(on: axis) - let originalBreadth = target.breadth let lines = calculateLayout(in: proposal, of: subviews, cache: cache) for line in lines { if reversedDepth { - target.depth -= line.size.depth + target.depth -= line.size.depth + line.spacing + } else { + target.depth += line.spacing + } + if reversedBreadth { + target.breadth = switch axis { + case .horizontal: bounds.maxX + case .vertical: bounds.maxY + } + } else { + target.breadth = switch axis { + case .horizontal: bounds.minX + case .vertical: bounds.minY + } } - target.depth += line.spacing for item in line.item { if reversedBreadth { - target.breadth -= item.size.breadth + target.breadth -= item.size.breadth + item.spacing + } else { + target.breadth += item.spacing } - target.breadth += item.spacing alignAndPlace(item, in: line, at: target) if !reversedBreadth { target.breadth += item.size.breadth } } + if alternatingReversedBreadth { + reversedBreadth.toggle() + } if !reversedDepth { target.depth += line.size.depth } - target.breadth = originalBreadth } } @@ -307,17 +316,6 @@ extension FlowLayout: Layout { } } -private extension CGRect { - mutating func reverseOrigin(along axis: Axis) { - switch axis { - case .horizontal: - origin.x = maxX - case .vertical: - origin.y = maxY - } - } -} - private extension Array where Element == Size { func reduce( _ initial: Size, From cc9a7228c17e36bdb7a754f299793811e2aebdeb Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 26 Jun 2024 18:45:00 +0200 Subject: [PATCH 5/5] Tests --- Tests/FlowTests/FlowTests.swift | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/Tests/FlowTests/FlowTests.swift b/Tests/FlowTests/FlowTests.swift index 9e3f0ad0..43420b75 100644 --- a/Tests/FlowTests/FlowTests.swift +++ b/Tests/FlowTests/FlowTests.swift @@ -108,7 +108,7 @@ final class FlowTests: XCTestCase { """) } - func test_HFlow_layout() { + func test_HFlow_default() { // Given let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0) @@ -125,6 +125,23 @@ final class FlowTests: XCTestCase { """) } + func test_HFlow_distibuted() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, distibuteItemsEvenly: true) + + // When + let result = sut.layout(repeated(1×1, times: 13), in: 11×3) + + // Then + XCTAssertEqual(render(result), """ + +-----------+ + |X X X X X | + |X X X X | + |X X X X | + +-----------+ + """) + } + func test_HFlow_justifiedSpaces_rigid() { // Given let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchSpaces) @@ -293,18 +310,18 @@ final class FlowTests: XCTestCase { """) } - func test_VFlow_layout() { + func test_VFlow_default() { // Given let sut: FlowLayout = .vertical(alignment: .center, itemSpacing: 0, lineSpacing: 0) // When - let result = sut.layout(repeated(1×1, times: 17), in: 6×3) + let result = sut.layout(repeated(1×1, times: 16), in: 6×3) // Then XCTAssertEqual(render(result), """ +------+ |XXXXXX| - |XXXXXX| + |XXXXX | |XXXXX | +------+ """)