SwiftUI Layout Testing¶
★★★★★ Intermediate
Automated verification of custom SwiftUI layout implementations by comparing against Apple's native rendering using property-based testing (fuzzing).
Approach¶
Generate random view configurations, render with both your custom layout engine and SwiftUI, then compare results:
- Define an enum of possible child view configurations
- Randomly generate view hierarchies
- Measure children using your implementation
- Measure children using SwiftUI (via
GeometryReader+ preferences) - Assert equality
Generating Random View Configurations¶
enum Frame: CustomStringConvertible {
case flexible // no frame modifier
case fixed(CGFloat) // .frame(width: N)
case min(CGFloat) // .frame(minWidth: N)
case max(CGFloat) // .frame(maxWidth: N)
case minMax(min: CGFloat, max: CGFloat) // .frame(minWidth: N, maxWidth: M)
static func random() -> Frame {
let randomWidth = { CGFloat(Int.random(in: 0...100)) }
switch Int.random(in: 0..<5) {
case 0: return .flexible
case 1: return .fixed(randomWidth())
case 2: return .min(randomWidth())
case 3: return .max(randomWidth())
default:
let maxW = randomWidth()
let minW = CGFloat.random(in: 0...maxW).rounded()
return .minMax(min: minW, max: maxW)
}
}
var view: AnyView {
let r = Rectangle()
switch self {
case .flexible: return AnyView(r)
case .fixed(let w): return AnyView(r.frame(width: w))
case .min(let w): return AnyView(r.frame(minWidth: w))
case .max(let w): return AnyView(r.frame(maxWidth: w))
case .minMax(let mn, let mx):
return AnyView(r.frame(minWidth: mn, maxWidth: mx))
}
}
}
Important: For .minMax, generate max first, then min in 0...max. Generating independently produces invalid frames where min > max.
Measuring SwiftUI Views¶
Use PreferenceKey to propagate child sizes up:
struct WidthKey: PreferenceKey {
static var defaultValue: [CGFloat] = []
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
// Wrap each child with a GeometryReader overlay
ForEach(views.indices, id: \.self) { i in
views[i]
.overlay(GeometryReader { proxy in
Color.clear.preference(key: WidthKey.self, value: [proxy.size.width])
})
}
.onPreferenceChange(WidthKey.self) { widths in
swiftUISizes = widths
}
Force rendering with UIHostingController:
let host = UIHostingController(rootView: swiftUIStack)
host.view.frame = CGRect(x: 0, y: 0, width: proposedWidth, height: 100)
_ = host.view.snapshotView(afterScreenUpdates: true)
Test Loop¶
func testHStackLayout() {
for _ in 0..<1000 {
let frames = (1...Int.random(in: 1...10)).map { _ in Frame.random() }
let proposedWidth = CGFloat.random(in: 0...500).rounded()
// Custom implementation
let customStack = HStack_(children: frames.map(\.view))
customStack.layout(proposedSize: CGSize(width: proposedWidth, height: 100))
let customWidths = customStack.sizes.map(\.width)
// SwiftUI reference
let swiftUIWidths = measureSwiftUI(frames: frames, proposedWidth: proposedWidth)
// Compare
XCTAssertEqual(customWidths, swiftUIWidths,
"Mismatch for frames: \(frames), proposed: \(proposedWidth)")
}
}
Platform Differences¶
Run tests on iOS, not macOS. Known macOS issues:
- HStack with centered content and oversized children renders subviews too large and off-center
- GeometryReader centers content on macOS but top-leading aligns on iOS
Set up both a macOS and iOS test target, but validate against iOS behavior for correctness.
Gotchas¶
CGFloat.greatestFiniteMagnitudeprecision loss: When comparing flexibility ranges,greatestFiniteMagnitude(10^308) loses precision in the fractional digits. Two views with different min-widths but the same max-width ofgreatestFiniteMagnitudewill compute identical flexibility values. Use a large-but-precise sentinel like1e15instead.- Rounding errors: Custom layout and SwiftUI may round to pixels differently. Use
accuracyparameter inXCTAssertEqualfor floating-point comparisons, or round both sides to the same precision before comparing. - Logging on failure: Print the
framesarray andproposedWidthin the assertion message. Without this, a fuzzing failure gives you sizes but no way to reproduce the input configuration.