diff --git a/Example/HostingExample/ViewController.swift b/Example/HostingExample/ViewController.swift index 144135306..95151bc66 100644 --- a/Example/HostingExample/ViewController.swift +++ b/Example/HostingExample/ViewController.swift @@ -66,6 +66,8 @@ class ViewController: NSViewController { struct ContentView: View { var body: some View { - TransactionExample() + FlowLayoutDemo() + .frame(width: 500) + .padding() } } diff --git a/Example/SharedExample/Layout/FlowLayout.swift b/Example/SharedExample/Layout/FlowLayout.swift new file mode 100644 index 000000000..92b3b2052 --- /dev/null +++ b/Example/SharedExample/Layout/FlowLayout.swift @@ -0,0 +1,204 @@ +// +// FlowLayout.swift +// SharedExample + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct FlowLayout: Layout { + enum HorizontalAlignment { + case leading, center, trailing + } + + struct Cache { + var frames: [CGRect] = [] + var containerSize: CGSize = .zero + var proposalWidth: CGFloat? = nil + } + + private let spacing: CGFloat + private let rowSpacing: CGFloat + private let alignment: HorizontalAlignment + private let maxRowWidth: CGFloat? + + init(spacing: CGFloat = 8, + rowSpacing: CGFloat = 8, + alignment: HorizontalAlignment = .leading, + maxRowWidth: CGFloat? = nil) { + self.spacing = spacing + self.rowSpacing = rowSpacing + self.alignment = alignment + self.maxRowWidth = maxRowWidth + } + + func makeCache(subviews: Subviews) -> Cache { Cache() } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.frames.removeAll(keepingCapacity: true) + } + + func sizeThatFits(proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache) -> CGSize { + let proposedWidth = maxRowWidth ?? proposal.width ?? .greatestFiniteMagnitude + + let result = layoutFrames(for: subviews, inWidth: proposedWidth, proposal: proposal) + cache.frames = result.frames + cache.containerSize = result.size + cache.proposalWidth = proposedWidth + return result.size + } + + func placeSubviews(in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache) { + let actualWidth = maxRowWidth ?? bounds.width + if cache.frames.isEmpty || cache.proposalWidth != actualWidth { + let result = layoutFrames(for: subviews, inWidth: actualWidth, proposal: proposal) + cache.frames = result.frames + cache.containerSize = result.size + cache.proposalWidth = actualWidth + } + + var lineStartIndex = 0 + while lineStartIndex < cache.frames.count { + let y = cache.frames[lineStartIndex].origin.y + var lineEndIndex = lineStartIndex + while lineEndIndex + 1 < cache.frames.count && + abs(cache.frames[lineEndIndex + 1].origin.y - y) < 0.5 { + lineEndIndex += 1 + } + + let lineFrames = cache.frames[lineStartIndex...lineEndIndex] + let lineWidth = lineFrames.last!.maxX - lineFrames.first!.minX + let free = actualWidth - lineWidth + let xOffset: CGFloat + switch alignment { + case .leading: xOffset = 0 + case .center: xOffset = max(0, free / 2) + case .trailing: xOffset = max(0, free) + } + + for (idx, frame) in zip(lineStartIndex...lineEndIndex, lineFrames) { + let adjusted = frame.offsetBy(dx: xOffset, dy: 0) + subviews[idx].place(at: CGPoint(x: bounds.minX + adjusted.minX, + y: bounds.minY + adjusted.minY), + proposal: .unspecified) + } + + lineStartIndex = lineEndIndex + 1 + } + } + + private func layoutFrames(for subviews: Subviews, + inWidth containerWidth: CGFloat, + proposal: ProposedViewSize) -> (frames: [CGRect], size: CGSize) { + var frames: [CGRect] = [] + var cursorX: CGFloat = 0 + var cursorY: CGFloat = 0 + var lineHeight: CGFloat = 0 + + func newLine() { + cursorX = 0 + cursorY += lineHeight + rowSpacing + lineHeight = 0 + } + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + let viewSize = CGSize(width: min(size.width, containerWidth), height: size.height) + + if cursorX > 0, cursorX + viewSize.width > containerWidth { + newLine() + } + + let frame = CGRect(x: cursorX, y: cursorY, width: viewSize.width, height: viewSize.height) + frames.append(frame) + + cursorX += viewSize.width + if subview != subviews.last { + cursorX += spacing + } + lineHeight = max(lineHeight, viewSize.height) + } + + let totalHeight = cursorY + lineHeight + let totalWidth = containerWidth.isFinite ? containerWidth : (frames.last?.maxX ?? 0) + return (frames, CGSize(width: totalWidth, height: totalHeight)) + } +} + +struct FlowLayoutDemo: View { + @State private var alignIndex: Int = 0 + private let alignments: [FlowLayout.HorizontalAlignment] = [.leading, .center, .trailing] + + private let tags: [String] = [ + "SwiftUI", "AttributeGraph", "DisplayList", "Transactions", + "Layout Protocol", "FlowLayout", "ZStack", "HStack", "VStack", + "Observation", "Preview", "GeometryReader", "AnyLayout", "ViewThatFits" + ] + + var body: some View { + VStack(spacing: 16) { +// header + + FlowLayout(spacing: 8, + rowSpacing: 10, + alignment: alignments[alignIndex], + maxRowWidth: nil) { +// ForEach(tags, id: \.self) { tag in +// TagChip(text: tag) +// } + TagChip(text: tags[0]) + TagChip(text: tags[1]) + } + .padding() +// .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + +// GroupBox("Fixed width container (320)") { +// FlowLayout(spacing: 8, rowSpacing: 8, alignment: .center, maxRowWidth: 320) { +// ForEach(tags, id: \.self) { TagChip(text: $0) } +// } +// .padding(.vertical, 8) +// } + } + .padding() + .animation(.snappy, value: alignIndex) + } + +// private var header: some View { +// HStack { +// Text("FlowLayout Demo") +// .font(.title2).bold() +// +// Spacer() +// +// Picker("Alignment", selection: $alignIndex) { +// Text("Leading").tag(0) +// Text("Center").tag(1) +// Text("Trailing").tag(2) +// } +// .pickerStyle(.segmented) +// .frame(maxWidth: 280) +// } +// } +} + +private struct TagChip: View { + let text: String + var body: some View { +// Text(text) +// .font(.callout) +// .padding(.horizontal, 12) +// .padding(.vertical, 6) +// .background(Color.accentColor.opacity(0.12), in: Capsule()) +// .overlay { +// Capsule().strokeBorder(Color.accentColor.opacity(0.35)) +// } + Color.red + } +} diff --git a/Sources/OpenSwiftUICore/Layout/Layout.swift b/Sources/OpenSwiftUICore/Layout/Layout.swift index 2f45d4f98..053eef84c 100644 --- a/Sources/OpenSwiftUICore/Layout/Layout.swift +++ b/Sources/OpenSwiftUICore/Layout/Layout.swift @@ -3,7 +3,7 @@ // OpenSwiftUICore // // Audited for iOS 18.0 -// Status: WIP +// Status: Complete // ID: 57DDCF0A00C1B77B475771403C904EF9 (SwiftUICore) #if canImport(Darwin) @@ -15,7 +15,7 @@ public import Foundation package import OpenGraphShims import OpenSwiftUI_SPI -// MARK: - Layout [WIP] +// MARK: - Layout /// A type that defines the geometry of a collection of views. /// @@ -709,7 +709,18 @@ extension Layout { subviews: Subviews, cache: inout Cache ) -> CGFloat? { - _openSwiftUIUnimplementedFailure() + guard let alignmentData, alignmentData.pointee.type == .alignment else { + return nil + } + let function = alignmentData.pointee.function + let key = guide.key + let viewSize = alignmentData.pointee.viewSize + let data = alignmentData.pointee.ptr + return function.defaultAlignment( + key, + size: viewSize, + data: data + ) } public func explicitAlignment( @@ -719,11 +730,39 @@ extension Layout { subviews: Subviews, cache: inout Cache ) -> CGFloat? { - _openSwiftUIUnimplementedFailure() + guard let alignmentData, alignmentData.pointee.type == .alignment else { + return nil + } + let function = alignmentData.pointee.function + let key = guide.key + let viewSize = alignmentData.pointee.viewSize + let data = alignmentData.pointee.ptr + return function.defaultAlignment( + key, + size: viewSize, + data: data + ) } public func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing { - _openSwiftUIUnimplementedFailure() + guard !subviews.isEmpty else { + return .zero + } + var spacing = Spacing(minima: [:]) + let subviewsLayoutDirection = subviews.layoutDirection + guard subviews.count != 0 else { + return ViewSpacing(spacing, layoutDirection: subviewsLayoutDirection) + } + var layoutDirection: LayoutDirection! = nil + for subview in subviews { + let subviewSpacing = subview.proxy.spacing() + layoutDirection = layoutDirection ?? subviewsLayoutDirection + spacing.incorporate( + .init(.all, layoutDirection: layoutDirection), + of: subviewSpacing + ) + } + return ViewSpacing(spacing, layoutDirection: layoutDirection) } } @@ -1213,15 +1252,14 @@ struct ViewLayoutEngine: DefaultAlignmentFunction, LayoutEngine where L: Layo let proposal = parentSize.proposal let frame = CGRect(origin: origin, size: parentSize.value) var data = PlacementData( - unknown: false, geometrys: Array(repeating: ViewGeometry.invalidValue, count: count), invalidCount: 0, frame: frame, layoutDirection: layoutDirection ) withUnsafeMutablePointer(to: &data) { data in - let oldData = threadLayoutData - threadLayoutData = data + let oldData = placementData + placementData = data layout.placeSubviews( in: frame, proposal: ProposedViewSize(proposal), @@ -1232,7 +1270,7 @@ struct ViewLayoutEngine: DefaultAlignmentFunction, LayoutEngine where L: Layo ), cache: &cache ) - threadLayoutData = oldData + placementData = oldData guard data.pointee.invalidCount != count else { return } @@ -1253,6 +1291,7 @@ struct ViewLayoutEngine: DefaultAlignmentFunction, LayoutEngine where L: Layo } } + // 6.5.4 mutating func explicitAlignment(_ k: AlignmentKey, at viewSize: ViewSize) -> CGFloat? { if cachedAlignmentSize != viewSize { cachedAlignmentSize = viewSize @@ -1260,9 +1299,42 @@ struct ViewLayoutEngine: DefaultAlignmentFunction, LayoutEngine where L: Layo cachedAlignment = .init() } let key = ObjectIdentifier(k.id) + // NOTE: Not using get API here to avoid conflict access guard let value = cachedAlignment.find(key) else { - let value = withUnsafePointer(to: self) { ptr in - Self.defaultAlignment(k, size: viewSize, data: UnsafeMutableRawPointer(mutating: ptr)) + let value = withUnsafeMutablePointer(to: &self) { ptr in + let alignmentData = AlignmentData( + function: Self.self, + ptr: ptr, + viewSize: viewSize + ) + return alignmentData.asCurrent { + switch k.axis { + case .horizontal: + ptr.pointee.layout.explicitAlignment( + of: HorizontalAlignment(k.id), + in: .init(origin: .zero, size: viewSize.value), + proposal: .init(viewSize.proposal), + subviews: .init( + context: ptr.pointee.proxies.context, + storage: .direct(ptr.pointee.proxies.attributes), + layoutDirection: ptr.pointee.layoutDirection + ), + cache: &ptr.pointee.cache + ) + case .vertical: + ptr.pointee.layout.explicitAlignment( + of: VerticalAlignment(k.id), + in: .init(origin: .zero, size: viewSize.value), + proposal: .init(viewSize.proposal), + subviews: .init( + context: ptr.pointee.proxies.context, + storage: .direct(ptr.pointee.proxies.attributes), + layoutDirection: ptr.pointee.layoutDirection + ), + cache: &ptr.pointee.cache + ) + } + } } cachedAlignment.put(key, value: value) return value @@ -1635,9 +1707,9 @@ public struct LayoutSubview: Equatable { } package func place(in geometry: ViewGeometry, layoutDirection: LayoutDirection = .leftToRight) { - let layoutData = threadLayoutData! - Swift.precondition(!layoutData.pointee.unknown) - layoutData.pointee.setGeometry( + let placementData = placementData! + Swift.precondition(placementData.pointee.type == .placement) + placementData.pointee.setGeometry( geometry, at: numericCast(index), layoutDirection: layoutDirection @@ -1648,20 +1720,21 @@ public struct LayoutSubview: Equatable { @available(*, unavailable) extension LayoutSubview: Sendable {} +private enum LayoutDataType { + case placement + case alignment +} + // MARK: - PlacementData [6.4.41] private struct PlacementData { - var unknown: Bool // FIXME - + let type: LayoutDataType = .placement var geometrys: [ViewGeometry] - var invalidCount: Int + let frame: CGRect + let layoutDirection: LayoutDirection - var frame: CGRect - - var layoutDirection: LayoutDirection - - static var current: UnsafeMutablePointer? { threadLayoutData } + static var current: UnsafeMutablePointer? { placementData } mutating func setGeometry(_ geometry: ViewGeometry, at index: Int, layoutDirection: LayoutDirection) { if geometrys[index].isInvalid { @@ -1674,10 +1747,8 @@ private struct PlacementData { } } -// MARK: - threadLayoutData - @_transparent -private var threadLayoutData: UnsafeMutablePointer? { +private var placementData: UnsafeMutablePointer? { get { _threadLayoutData()?.assumingMemoryBound(to: PlacementData.self) } @@ -1686,6 +1757,36 @@ private var threadLayoutData: UnsafeMutablePointer? { } } +// MARK: - AlignmentData [6.5.4] + +private struct AlignmentData { + let type: LayoutDataType = .alignment + let function: any DefaultAlignmentFunction.Type + let ptr: UnsafeMutableRawPointer + let viewSize: ViewSize + + static var current: UnsafeMutablePointer? { alignmentData } + + func asCurrent(do body: () throws -> Result) rethrows -> Result { + try withUnsafePointer(to: self) { ptr in + let oldData = alignmentData + defer { alignmentData = oldData } + alignmentData = .init(mutating: ptr) + return try body() + } + } +} + +@_transparent +private var alignmentData: UnsafeMutablePointer? { + get { + _threadLayoutData()?.assumingMemoryBound(to: AlignmentData.self) + } + set { + _setThreadLayoutData(newValue) + } +} + // MARK: - LayoutValueKey /// A key for accessing a layout value of a layout container's subviews.