diff --git a/Sources/Flow/Internal/Layout.swift b/Sources/Flow/Internal/Layout.swift index 09fc41fb..6f68ec87 100644 --- a/Sources/Flow/Internal/Layout.swift +++ b/Sources/Flow/Internal/Layout.swift @@ -118,8 +118,14 @@ struct FlowLayout: Sendable { of subviews: some Subviews, cache: FlowLayoutCache ) -> Lines { - let sizes = cache.subviewsCache.map(\.ideal) - let spacings = if let itemSpacing { + let sizes: [Size] = zip(cache.subviewsCache, subviews).map { cache, subview in + if cache.ideal.fits(in: proposedSize) { + cache.ideal + } else { + subview.sizeThatFits(proposedSize).size(on: axis) + } + } + let spacings: [CGFloat] = if let itemSpacing { [0] + Array(repeating: itemSpacing, count: subviews.count - 1) } else { [0] + cache.subviewsCache.adjacentPairs().map { lhs, rhs in @@ -133,7 +139,7 @@ struct FlowLayout: Sendable { FlowLineBreaker() } - let breakpoints = lineBreaker.wrapItemsToLines( + let breakpoints: [Int] = lineBreaker.wrapItemsToLines( sizes: sizes.map(\.breadth), spacings: spacings, in: proposedSize.replacingUnspecifiedDimensions(by: .infinity).value(on: axis) @@ -185,6 +191,8 @@ struct FlowLayout: Sendable { let sumOfIdeal = subviewsInPriorityOrder.sum { $0.spacing + $0.cache.ideal.breadth } var remainingSpace = proposedSize.value(on: axis) - sumOfIdeal + guard remainingSpace > 0 else { return } + if justification.isStretchingItems { let sumOfMax = subviewsInPriorityOrder.sum { $0.spacing + $0.cache.max.breadth } let potentialGrowth = sumOfMax - sumOfIdeal diff --git a/Sources/Flow/Internal/Size.swift b/Sources/Flow/Internal/Size.swift index de7f47f9..57cdf4f8 100644 --- a/Sources/Flow/Internal/Size.swift +++ b/Sources/Flow/Internal/Size.swift @@ -34,6 +34,17 @@ struct Size: Sendable { case .vertical: \.depth } } + + @usableFromInline + func fits(in proposedSize: ProposedViewSize) -> Bool { + if let proposedWidth = proposedSize.width, self[.horizontal] > proposedWidth { + return false + } + if let proposedHeight = proposedSize.height, self[.vertical] > proposedHeight { + return false + } + return true + } } extension Axis { diff --git a/Tests/FlowTests/FlowTests.swift b/Tests/FlowTests/FlowTests.swift index 8bb623ad..9bdd7a6d 100644 --- a/Tests/FlowTests/FlowTests.swift +++ b/Tests/FlowTests/FlowTests.swift @@ -325,4 +325,21 @@ final class FlowTests: XCTestCase { +------+ """) } + + func test_HFlow_text() { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0) + + // When + let result = sut.layout([WrappingText(size: 6×1), 1×1, 1×1, 1×1], in: 5×3) + + // Then + XCTAssertEqual(render(result), """ + +-----+ + |XXXXX| + |XXXXX| + |X X X| + +-----+ + """) + } } diff --git a/Tests/FlowTests/Utils/TestSubview.swift b/Tests/FlowTests/Utils/TestSubview.swift index b1530046..468a8c0d 100644 --- a/Tests/FlowTests/Utils/TestSubview.swift +++ b/Tests/FlowTests/Utils/TestSubview.swift @@ -2,7 +2,7 @@ import SwiftUI import XCTest @testable import Flow -final class TestSubview: Flow.Subview, CustomStringConvertible { +class TestSubview: Flow.Subview, CustomStringConvertible { var spacing = ViewSpacing() var priority: Double = 1 var placement: (position: CGPoint, size: CGSize)? @@ -58,7 +58,22 @@ final class TestSubview: Flow.Subview, CustomStringConvertible { } } -extension [TestSubview]: Subviews {} +final class WrappingText: TestSubview { + override func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + let area = idealSize.width * idealSize.height + if let proposedWidth = proposal.width, idealSize.width > proposedWidth { + let height = (Int(1)...).first { area <= proposedWidth * CGFloat($0) }! + return CGSize(width: proposedWidth, height: CGFloat(height)) + } + if let proposedHeight = proposal.height, idealSize.height > proposedHeight { + let width = (Int(1)...).first { area <= proposedHeight * CGFloat($0) }! + return CGSize(width: CGFloat(width), height: proposedHeight) + } + return super.sizeThatFits(proposal) + } +} + +extension [TestSubview]: Flow.Subviews {} typealias LayoutDescription = (subviews: [TestSubview], reportedSize: CGSize) @@ -92,6 +107,8 @@ func render(_ layout: LayoutDescription, border: Bool = true) -> String { struct Point: Hashable { let x, y: Int } + let width = Int(layout.reportedSize.width) + let height = Int(layout.reportedSize.height) var positions: Set = [] for view in layout.subviews { @@ -101,14 +118,13 @@ func render(_ layout: LayoutDescription, border: Bool = true) -> String { 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") + precondition(x >= 0 && x < width && y >= 0 && y < height, "Out of bounds") } } } 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"