diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 9f9a0fd..1eaf79a 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -8,23 +8,26 @@ on: jobs: build: - runs-on: macos-14 - + runs-on: macos-26 + steps: - uses: maxim-lobanov/setup-xcode@v1.1 with: - xcode-version: "16.0" - - uses: actions/checkout@v2 + xcode-version: "26.4" + - uses: actions/checkout@v4 - name: xcodebuild - run: xcodebuild -scheme WrapLayout -sdk iphoneos -destination 'platform=iOS Simulator,name=iPhone 8,OS=16.2' + run: xcodebuild -scheme WrapLayout -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' test: - runs-on: macos-14 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1.1 with: - xcode-version: "16.0" - - uses: actions/checkout@v2 - + xcode-version: "26.4" + - uses: actions/checkout@v4 + - name: Test - run: xcodebuild -scheme WrapLayout -resultBundlePath results/WrapLayout.xcresult test -destination 'platform=iOS Simulator,name=iPhone 8,OS=16.2' -derivedDataPath ./DerivedData | xcpretty + shell: bash + run: | + set -o pipefail + xcodebuild -scheme WrapLayout -resultBundlePath results/WrapLayout.xcresult test -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -derivedDataPath ./DerivedData | xcpretty diff --git a/Sources/WrapLayout/WrapLayout.swift b/Sources/WrapLayout/WrapLayout.swift index 8ef2fb3..6669705 100644 --- a/Sources/WrapLayout/WrapLayout.swift +++ b/Sources/WrapLayout/WrapLayout.swift @@ -2,6 +2,20 @@ import SwiftUI public struct WrapLayout: Layout { + /// Horizontal alignment of each line within the layout's available width. + public enum LineHorizontalAlignment: Sendable, Hashable { + case leading + case center + case trailing + } + + /// Vertical alignment of elements within a single line. + public enum LineVerticalAlignment: Sendable, Hashable { + case top + case center + case bottom + } + public struct CacheStorage { struct CalculatedElement { @@ -10,42 +24,36 @@ public struct WrapLayout: Layout { } struct Line { - var width: CGFloat = 0 var height: CGFloat = 0 - var elements: [CalculatedElement] = [] } var lines: [Line] = [] func calculateSize(verticalSpacing: CGFloat) -> CGSize { - - // get a lenght from the longest line. - let maxWidth = lines.max(by: { $0.width < $1.width })?.width ?? 0 - - // get a total height by all lines. - let totalHeight: CGFloat = lines.reduce(0) { partialResult, line in - partialResult + line.height - } - - // total spacing from each line. - let verticalSpacing: CGFloat = (CGFloat(max(0, (lines.count - 1))) * verticalSpacing) - - return .init(width: maxWidth, height: totalHeight + verticalSpacing) - + let maxWidth = lines.lazy.map(\.width).max() ?? 0 + let totalHeight = lines.reduce(0) { $0 + $1.height } + let totalVerticalSpacing = CGFloat(max(0, lines.count - 1)) * verticalSpacing + return CGSize(width: maxWidth, height: totalHeight + totalVerticalSpacing) } } public let horizontalSpacing: CGFloat public let verticalSpacing: CGFloat + public let lineHorizontalAlignment: LineHorizontalAlignment + public let lineVerticalAlignment: LineVerticalAlignment public init( horizontalSpacing: CGFloat = 4, - verticalSpacing: CGFloat = 4 + verticalSpacing: CGFloat = 4, + lineHorizontalAlignment: LineHorizontalAlignment = .leading, + lineVerticalAlignment: LineVerticalAlignment = .top ) { self.horizontalSpacing = horizontalSpacing self.verticalSpacing = verticalSpacing + self.lineHorizontalAlignment = lineHorizontalAlignment + self.lineVerticalAlignment = lineVerticalAlignment } public func makeCache(subviews: Subviews) -> CacheStorage { @@ -58,60 +66,49 @@ public struct WrapLayout: Layout { cache: inout CacheStorage ) -> CGSize { - let bounds = CGRect( - origin: .zero, - size: .init( - width: proposal.width ?? .infinity, - height: proposal.height ?? .infinity - ) - ) + let maxWidth = proposal.width ?? .infinity + let maxHeight = proposal.height ?? .infinity - // purge current cache cache.lines = [] - var offsetX: Double = 0 - var currentLine: CacheStorage.Line = .init() + var currentLine = CacheStorage.Line() - for (_, view) in subviews.enumerated() { + for view in subviews { - let calculatedSize = view.sizeThatFits( - .init( - width: bounds.width, - height: bounds.height - ) - ) + let size = view.sizeThatFits(.init(width: maxWidth, height: maxHeight)) - if (offsetX + calculatedSize.width) >= bounds.width { - // line break - currentLine.width = offsetX + // Width of the current line if we appended this element to it. + let candidateWidth: CGFloat + if currentLine.elements.isEmpty { + candidateWidth = size.width + } else { + candidateWidth = currentLine.width + horizontalSpacing + size.width + } - offsetX = 0 + // Break to a new line only when the current line already has at least + // one element. A single element wider than maxWidth still occupies its + // own line rather than being skipped. + if !currentLine.elements.isEmpty, candidateWidth > maxWidth { cache.lines.append(currentLine) - currentLine = .init() + currentLine = CacheStorage.Line() } - let calculatedElement = CacheStorage.CalculatedElement( - element: view, - size: calculatedSize - ) - - currentLine.elements.append(calculatedElement) + let element = CacheStorage.CalculatedElement(element: view, size: size) - // move cursor including spacing - offsetX += calculatedSize.width + horizontalSpacing - - if currentLine.height < calculatedElement.size.height { - currentLine.height = calculatedElement.size.height + if currentLine.elements.isEmpty { + currentLine.width = size.width + } else { + currentLine.width += horizontalSpacing + size.width } - + currentLine.height = max(currentLine.height, size.height) + currentLine.elements.append(element) } - currentLine.width = offsetX - cache.lines.append(currentLine) - - let size = cache.calculateSize(verticalSpacing: verticalSpacing) + if !currentLine.elements.isEmpty { + cache.lines.append(currentLine) + } - return size + return cache.calculateSize(verticalSpacing: verticalSpacing) } public func placeSubviews( @@ -122,30 +119,215 @@ public struct WrapLayout: Layout { ) { var cursorY: CGFloat = 0 - var cursorX: CGFloat = 0 for line in cache.lines { + let remainingWidth = max(bounds.width - line.width, 0) + + let lineOffsetX: CGFloat + switch lineHorizontalAlignment { + case .leading: + lineOffsetX = 0 + case .center: + lineOffsetX = remainingWidth / 2 + case .trailing: + lineOffsetX = remainingWidth + } + + var cursorX: CGFloat = bounds.minX + lineOffsetX + for element in line.elements { + let elementY: CGFloat + switch lineVerticalAlignment { + case .top: + elementY = bounds.minY + cursorY + case .center: + elementY = bounds.minY + cursorY + (line.height - element.size.height) / 2 + case .bottom: + elementY = bounds.minY + cursorY + (line.height - element.size.height) + } + element.element.place( - at: .init( - x: bounds.minX + cursorX, - y: bounds.minY + cursorY - ), + at: .init(x: cursorX, y: elementY), anchor: .topLeading, proposal: .init(width: element.size.width, height: element.size.height) ) cursorX += element.size.width + horizontalSpacing - } - cursorX = 0 cursorY += line.height + verticalSpacing + } + } +} + +#if DEBUG +private struct WrapLayoutPreviewTag: View { + let text: String + let color: Color + + init(_ text: String, color: Color = .blue) { + self.text = text + self.color = color + } + + var body: some View { + Text(text) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(color.opacity(0.2)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(color, lineWidth: 1) + ) + } +} + +private struct WrapLayoutPreviewContainer: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + content() + .background(Color.gray.opacity(0.15)) + } + } +} + +private let _previewTags = [ + "SwiftUI", "Layout", "Wrap", "Alignment", "iOS", + "Demo", "Preview", "Tags", "Center", "Trailing", +] + +@available(iOS 17.0, *) +#Preview("leading (default)") { + ScrollView { + WrapLayoutPreviewContainer(title: "leading (default)") { + WrapLayout(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(_previewTags, id: \.self) { WrapLayoutPreviewTag($0) } + } } + .padding() + } +} +@available(iOS 17.0, *) +#Preview("center") { + ScrollView { + WrapLayoutPreviewContainer(title: "center") { + WrapLayout( + horizontalSpacing: 8, + verticalSpacing: 8, + lineHorizontalAlignment: .center + ) { + ForEach(_previewTags, id: \.self) { WrapLayoutPreviewTag($0, color: .purple) } + } + } + .padding() } +} +@available(iOS 17.0, *) +#Preview("trailing") { + ScrollView { + WrapLayoutPreviewContainer(title: "trailing") { + WrapLayout( + horizontalSpacing: 8, + verticalSpacing: 8, + lineHorizontalAlignment: .trailing + ) { + ForEach(_previewTags, id: \.self) { WrapLayoutPreviewTag($0, color: .orange) } + } + } + .padding() + } } + +@available(iOS 17.0, *) +#Preview("vertical: center") { + ScrollView { + WrapLayoutPreviewContainer(title: "vertical: center (mixed heights)") { + WrapLayout( + horizontalSpacing: 8, + verticalSpacing: 8, + lineVerticalAlignment: .center + ) { + Text("Short").font(.caption) + Text("Medium").font(.body) + Text("Tall").font(.largeTitle) + Text("Mid").font(.title3) + Text("xs").font(.caption2) + Text("Big").font(.title) + } + } + .padding() + } +} + +@available(iOS 17.0, *) +#Preview("vertical: bottom") { + ScrollView { + WrapLayoutPreviewContainer(title: "vertical: bottom (mixed heights)") { + WrapLayout( + horizontalSpacing: 8, + verticalSpacing: 8, + lineVerticalAlignment: .bottom + ) { + Text("Short").font(.caption) + Text("Medium").font(.body) + Text("Tall").font(.largeTitle) + Text("Mid").font(.title3) + Text("xs").font(.caption2) + Text("Big").font(.title) + } + } + .padding() + } +} + +@available(iOS 17.0, *) +#Preview("combined alignments") { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + WrapLayoutPreviewContainer(title: "center + vertical center") { + WrapLayout( + horizontalSpacing: 8, + verticalSpacing: 8, + lineHorizontalAlignment: .center, + lineVerticalAlignment: .center + ) { + Text("A").font(.caption) + Text("Bb").font(.body) + Text("CcC").font(.largeTitle) + Text("Dd").font(.title3) + Text("E").font(.caption2) + } + } + + WrapLayoutPreviewContainer(title: "trailing + bottom") { + WrapLayout( + horizontalSpacing: 8, + verticalSpacing: 8, + lineHorizontalAlignment: .trailing, + lineVerticalAlignment: .bottom + ) { + ForEach(_previewTags.prefix(6), id: \.self) { + WrapLayoutPreviewTag($0, color: .green) + } + } + } + } + .padding() + } +} + +#endif diff --git a/Tests/WrapLayoutTests/WrapLayoutTests.swift b/Tests/WrapLayoutTests/WrapLayoutTests.swift index 121f1ef..68b4737 100644 --- a/Tests/WrapLayoutTests/WrapLayoutTests.swift +++ b/Tests/WrapLayoutTests/WrapLayoutTests.swift @@ -6,28 +6,45 @@ import SnapshotTesting final class WrapLayoutTests: XCTestCase { - override class func setUp() { - isRecording = false - } + private static let data = [ + "πŸš• A", + "Hello", + "☠️", + "危険: ζŠΌγ™γͺ", + "πŸ”˜", + "🍫: Chocolate", + "B", + "πŸ•", + ] func testWrapping() throws { struct Content: View { - private let data = [ - "πŸš• A", - "Hello", - "☠️", - "危険: ζŠΌγ™γͺ", - "πŸ”˜", - "🍫: Chocolate", - "B", - "πŸ•", - ] - var body: some View { WrapLayout(horizontalSpacing: 4, verticalSpacing: 16) { - ForEach(data, id: \.self) { item in + ForEach(WrapLayoutTests.data, id: \.self) { item in + Text(item) + } + } + .background(Color.gray) + .frame(width: 200) + } + } + + assertSnapshot(matching: Content(), as: .image) + } + + func testCenterAligned() throws { + + struct Content: View { + var body: some View { + WrapLayout( + horizontalSpacing: 4, + verticalSpacing: 8, + lineHorizontalAlignment: .center + ) { + ForEach(WrapLayoutTests.data, id: \.self) { item in Text(item) } } @@ -37,6 +54,74 @@ final class WrapLayoutTests: XCTestCase { } assertSnapshot(matching: Content(), as: .image) + } + func testTrailingAligned() throws { + + struct Content: View { + var body: some View { + WrapLayout( + horizontalSpacing: 4, + verticalSpacing: 8, + lineHorizontalAlignment: .trailing + ) { + ForEach(WrapLayoutTests.data, id: \.self) { item in + Text(item) + } + } + .background(Color.gray) + .frame(width: 200) + } + } + + assertSnapshot(matching: Content(), as: .image) + } + + func testVerticalCenterAligned() throws { + + struct Content: View { + var body: some View { + WrapLayout( + horizontalSpacing: 4, + verticalSpacing: 8, + lineVerticalAlignment: .center + ) { + Text("Short") + Text("Tall") + .font(.largeTitle) + Text("Mid") + .font(.title2) + Text("X") + } + .background(Color.gray) + .frame(width: 240) + } + } + + assertSnapshot(matching: Content(), as: .image) + } + + func testVerticalBottomAligned() throws { + + struct Content: View { + var body: some View { + WrapLayout( + horizontalSpacing: 4, + verticalSpacing: 8, + lineVerticalAlignment: .bottom + ) { + Text("Short") + Text("Tall") + .font(.largeTitle) + Text("Mid") + .font(.title2) + Text("X") + } + .background(Color.gray) + .frame(width: 240) + } + } + + assertSnapshot(matching: Content(), as: .image) } } diff --git a/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testCenterAligned.1.png b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testCenterAligned.1.png new file mode 100644 index 0000000..e9809af Binary files /dev/null and b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testCenterAligned.1.png differ diff --git a/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testTrailingAligned.1.png b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testTrailingAligned.1.png new file mode 100644 index 0000000..260761f Binary files /dev/null and b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testTrailingAligned.1.png differ diff --git a/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testVerticalBottomAligned.1.png b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testVerticalBottomAligned.1.png new file mode 100644 index 0000000..c84b4a2 Binary files /dev/null and b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testVerticalBottomAligned.1.png differ diff --git a/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testVerticalCenterAligned.1.png b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testVerticalCenterAligned.1.png new file mode 100644 index 0000000..3592fcb Binary files /dev/null and b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testVerticalCenterAligned.1.png differ diff --git a/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testWrapping.1.png b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testWrapping.1.png index 5323fb3..531a0f1 100644 Binary files a/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testWrapping.1.png and b/Tests/WrapLayoutTests/__Snapshots__/WrapLayoutTests/testWrapping.1.png differ diff --git a/_WrapLayoutDemo/WrapLayoutDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/_WrapLayoutDemo/WrapLayoutDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8fe9fb0..f58d688 100644 --- a/_WrapLayoutDemo/WrapLayoutDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/_WrapLayoutDemo/WrapLayoutDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "placement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sampettersson/Placement", - "state" : { - "revision" : "2dc510fb7d36dd49dc3857c95d69653cc3836a40", - "version" : "1.4.1" - } - }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl",