cache: inout Cache, subviews: Subviews) {
cache.itemSizes = subviews.map {
$0.sizeThatFits(ProposedViewSize(width: minItemWidth, height: nil))
}
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let availableWidth = proposal.width ?? .infinity
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var rowHeight: CGFloat = 0
var rowCount = 0
for size in cache.itemSizes {
if currentX + size.width > availableWidth || rowCount >= maxItemsPerRow {
currentX = 0
currentY += rowHeight + spacing
rowHeight = 0
rowCount = 0
}
currentX += size.width + spacing
rowHeight = max(rowHeight, size.height)
rowCount += 1
}
return CGSize(width: availableWidth, height: currentY + rowHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
guard !subviews.isEmpty else { return }
let availableWidth = bounds.width
var currentX = bounds.minX
var currentY = bounds.minY
var rowHeight: CGFloat = 0
var rowCount = 0
for (index, subview) in subviews.enumerated() {
let size = cache.itemSizes[index]
if currentX + size.width > availableWidth || rowCount >= maxItemsPerRow {
currentX = bounds.minX
currentY += rowHeight + spacing
rowHeight = 0
rowCount = 0
}
subview.place(
at: CGPoint(x: currentX, y: currentY),
proposal: ProposedViewSize(size),
anchor: .topLeading
)
currentX += size.width + spacing
rowHeight = max(rowHeight, size.height)
rowCount += 1
}
}
static var layoutProperties: LayoutProperties {
var properties = LayoutProperties()
properties.stackOrientation = .horizontal
return properties
}
}
**Usage Example:**
```swift
struct FlexRowDemo: View {
var body: some View {
AdaptiveFlexRow(spacing: 12, minItemWidth: 80, maxItemsPerRow: 3) {
ForEach(0..<10) { i in
Rectangle()
.fill(Color.blue.opacity(0.2))
.frame(height: 40 + CGFloat(i % 3) * 20)
.overlay(Text("\(i)").foregroundColor(.primary))
}
}
.padding()
.background(Color.gray.opacity(0.1))
}
}
Pitfall Guide
| Symptom | Root Cause | Fix |
|---|
| Infinite layout loop | sizeThatFits calls subview.sizeThatFits with an unconstrained proposal, triggering parent re-proposal | Clamp proposals explicitly; avoid ProposedViewSize(width: nil, height: nil) without fallback bounds |
| Subviews misaligned or clipped | placeSubviews uses .topLeading but parent expects .center or dynamic anchors | Match anchor to content alignment; use subview.alignmentGuides for precise positioning |
| Performance drops with 50+ items | Cache invalidation on every state change; updateCache runs unnecessarily | Guard cache updates: if cache.itemSizes.count != subviews.count { update }; use stable Identifiable subviews |
| Dynamic Type breaks layout | Hardcoded minItemWidth ignores @Environment(\.sizeCategory) | Read sizeCategory in sizeThatFits and scale dimensions using UIFontMetrics |
Layout not respecting safe areas | Custom layout ignores LayoutValues.safeAreaInsets | Apply bounds.inset(by: ...) or respect LayoutProperties().safeArea in iOS 17+ |
Debugging Workflow:
- Enable
Debug > View Debugging > Enable Layout Inspector in Xcode 15+.
- Wrap your layout in
#Preview with \.sizeCategory environment overrides to catch overflow.
- Use
Logger().debug("Layout pass #\(passCount)") inside sizeThatFits to track re-evaluation frequency.
- Profile with Instruments > Time Profiler; filter for
swift_applyLayout and UIView.layoutSubviews to isolate hot paths.
- Use
#if DEBUG to inject overlay(Rectangle().stroke(Color.red, lineWidth: 1)) on subviews to visualize proposal bounds.
Production Bundle
1. Caching Strategy for Large Lists
func updateCache(_ cache: inout Cache, subviews: Subviews) {
// Only rebuild when structure changes, not on every state update
if cache.itemSizes.count != subviews.count {
cache.itemSizes = subviews.map {
$0.sizeThatFits(ProposedViewSize(width: minItemWidth, height: nil))
}
}
}
2. Accessibility & Dynamic Type Support
struct AccessibleFlexRow: Layout {
@Environment(\.sizeCategory) var sizeCategory
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let scale = UIFontMetrics.default.scaledValue(for: 1.0, withSizeCategory: sizeCategory)
let adaptiveSpacing = spacing * scale
// Apply scale to minItemWidth and spacing calculations before layout math
}
}
3. Testing Strategy
- Unit Tests: Verify
sizeThatFits returns expected dimensions for fixed proposals and edge cases (empty subviews, overflow, max row limits).
- UI Tests: Assert visibility with
XCUIApplication().windows["LayoutTest"].staticTexts to ensure no clipping occurs at boundary widths.
- Preview Testing: Use
#Preview with .environment(\.sizeCategory, .accessibilityExtraExtraLarge) and .environment(\.layoutDirection, .rightToLeft) to validate RTL and accessibility compliance.
4. Performance Metrics to Monitor
- Layout passes/frame: Target < 5 for complex views
- Frame time: Target < 16.6ms (60fps) or < 8.3ms (120fps)
- Cache hit rate: > 90% during scroll/rotation
- Memory footprint: Cache struct should remain < 1KB per 100 items
5. Deployment Checklist