Once the mirror was scanning end-to-end and the iPhone app was actually receiving scan zips from the mock cloud, the project shifted from “can I even get a 3D body out of this” to “the 3D body is here and the UI around it is broken.” A specific symptom: when scans existed, the four sub-tabs across the top of the Body tab (KEY STATS, MEASURE, ZOOM, VIEWS) were getting clipped off both screen edges. Pre-scan UI was perfect. The first and last sub-tab labels would visually lose letters off the edges. The body avatar itself rendered correctly.
This post is about chasing that bug for several rounds, being wrong about the cause twice, building a reproduction harness in the simulator, and finding the real fix.
What the screenshot showed
The visible symptom on an iPhone 17 Pro Max:
- KEY STATS: partially clipped on the left edge (K mostly gone)
- MEASURE: fully visible, pink accent underline centered
- ZOOM: fully visible
- VIEWS: partially clipped on the right edge (S mostly gone)
- The 3D body avatar: centered, well-sized, no clipping
- The bottom system tab bar (Body / Compare / Scan / More): fine
The visual asymmetry mattered. The body model fit. The system tab bar fit. Only the per-Body-tab chrome (sub-tab labels, and bits of the per-scan toolbar) overflowed.
Wrong fix #1: sizeThatFits → nil
My first instinct was the SCNView wrapped via UIViewRepresentable. BodyModelSceneView.sizeThatFits(_:uiView:context:) was returning nil, which I had remembered as “use the parent’s proposed size verbatim.” I was wrong about what nil means there.
Returning nil from a SwiftUI UIViewRepresentable.sizeThatFits actually means “defer to the UIView’s own intrinsicContentSize or sizeThatFits(_:).” SCNView’s intrinsicContentSize is noIntrinsicMetric in theory, but in practice the SwiftUI bridge can pick up an earlier-recorded layer backing size before the first layout pass clamps it. So I changed it to explicitly return the proposed size:
func sizeThatFits(_ proposal: ProposedViewSize, uiView: SCNView, context: Context) -> CGSize? { CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)}This is the canonical “I am exactly the size you propose, don’t ask the UIView” pattern used in Apple sample code. It’s defensively correct. But, as the harness eventually showed, it wasn’t the cause of the bug, only a defensive nicety.
The lab
This is where the post pivots from “guess and install” to “reproduce in a controlled surface.” NakedMirror3DLab is a small auxiliary iOS target that already existed for iterating on the 3D rendering pipeline against bundled fixture scans, with its own in-memory ModelContainer and shared BodyModelView.swift source. I added a second harness inside it: LayoutLabRootView.
LayoutLabRootView is a structural replica of the production Body tab: same sub-tab enum, same composition (HStack with stat cards + BodyModelView for KEY STATS; VStack with BodyModelView + horizontal scroll for MEASURE), same nesting depth. The differences:
- It only renders against a single bundled fixture scan, in-memory.
- It draws a 2-pt red border around the
topTabBarand 1-pt yellow borders around each tab cell, so the actual layout bounds are visible. - It overlays a monospaced text readout in the corner showing measured widths.
- It defaults to the most-affected sub-tab (
.measure) on launch so a single screenshot is diagnostic.
If a SwiftUI sizing problem reproduces in the simulator against a bundled fixture, the lab is enough. No phone install needed, no production state to manage, ~5-second iteration loop.
The first lab build had no fix applied. Lab screenshot: red border extended past the iPhone’s right edge. VIEWS label entirely off-screen. Body avatar visibly clipped at the right edge. The bug reproduced.
flowchart LR
A[Build lab with no fix] --> B[Screenshot: border past right edge]
B --> C[Apply sizeThatFits -> proposal fix]
C --> D[Rebuild lab, screenshot again]
D --> E{Border still past edge?}
E -->|Yes| F[Fix #1 ruled out, keep looking]
F --> G[Inspect ZStack / body-bg image]
G --> H[Apply GeometryReader + fixed frame]
H --> I[Rebuild lab, screenshot again]
I --> J{Border at screen width?}
J -->|Yes| K[Ship to production, confirm on simulator]Wrong diagnosis confirmed
With the sizeThatFits → proposal change applied, I rebuilt the lab and screenshotted again. No visible change. Red border still past the right edge. Body still clipped on the right. The fix that I had been about to ship to production didn’t actually address the bug, even though I had been confident about it.
That’s the value the lab paid for in two hours of work. Without it, I would have shipped v0.8.3 with that fix, installed it, watched the bug persist, and been back in another debug round with no faster feedback loop than “rebuild, AirDrop, install, take a screenshot.”
The real culprit
With the lab telling me the SCNView intrinsic size wasn’t the inflator, the next suspect was the navy background. BodyModelView is structured as a ZStack:
ZStack { Color(red: 0.06, green: 0.10, blue: 0.20) // base if let img = UIImage(named: "body-bg") { // gradient PNG Image(uiImage: img).resizable().scaledToFill() } BodyModelSceneView(scan: scan, ...).id(scan.id) // SCNView}.frame(maxWidth: .infinity, maxHeight: .infinity).clipped()body-bg.png is the polished navy backdrop the production look depends on. Pixel size: 1500 × 2668. Aspect ratio 0.56, much taller than the proposed frame.
I had been writing off .scaledToFill() because, in isolation, .scaledToFill() doesn’t change the layout size; it only changes how pixels are drawn within the frame. That’s true for the Image’s layout size. What I hadn’t reasoned through: ZStack’s natural width is the maximum of its children’s natural widths. With .frame(maxWidth: .infinity, maxHeight: .infinity), the ZStack proposes “fill available space”, but the maxWidth is a cap, not a contract. When a child claims more natural space than the proposal grants, the ZStack can negotiate upward, and the parent HStack/VStack absorbs the inflation.
Visually: the body-bg PNG, sized to a tall-and-narrow proposed frame via .scaledToFill, demands additional horizontal extent to maintain its aspect ratio. The ZStack inflates to match. The HStack containing it absorbs. The VStack containing that absorbs. The topTabBar HStack, whose four equal-width tabs use .frame(maxWidth: .infinity), sized itself to whatever the VStack said it had to fill, and that turned out to be larger than the screen. The sub-tab labels go off both edges.
flowchart LR
A["body-bg.png 1500x2668<br/>aspect ratio 0.56"] -->|.scaledToFill demands extra width| B[ZStack]
B -->|inflates to fit child| C[Parent HStack/VStack]
C -->|absorbs inflation| D["topTabBar (4 equal-width tabs)"]
D -->|sized wider than the screen| E[Sub-tab labels clipped off both edges]The body avatar looked centered the whole time because SceneKit renders within its own SCNView bounds and the SCNView was inheriting the inflated bounds with the body still centered inside them. The visual centeredness disguised the fact that the frame was wider than the screen.
The fix
GeometryReader { geo in ZStack { Color(red: 0.06, green: 0.10, blue: 0.20) if let img = UIImage(named: "body-bg") { Image(uiImage: img).resizable().scaledToFill() } BodyModelSceneView(...).id(scan.id) } .frame(width: geo.size.width, height: geo.size.height) .clipped()}Two non-obvious bits:
GeometryReaderclaims the parent’s proposed size and reports it asgeo.size. It doesn’t negotiate; it accepts..frame(width: geo.size.width, height: geo.size.height): note the fixedwidth:andheight:parameters, notmaxWidth:/maxHeight:. This is the explicit “I am exactly this big, no more, no less” pattern. A fixed frame cannot be inflated by a greedy child.
Lab confirmation: red border sits at screen width. All four tabs visible. topnav = screen = 402pt on the iPhone 17 Pro simulator. The body avatar fits its column properly. Production build passed on the simulator. The change shipped as v0.8.3.
Why this took as long as it did
The bug had been on the project’s radar for several iterations across multiple sessions. Each iteration tried something layout-y (refactor BodyView’s outer structure, switch ZStack to .background, swap .frame(maxWidth: .infinity) ordering) without ever pinning down which view was actually inflating. The lab was the missing piece. It let me hold one variable fixed (the BodyModelView contents) and revert the parent composition until the bug behavior stopped, and only then ask “what about BodyModelView itself is doing this?”
NakedMirror3DLab already existed for material/lighting/camera iteration on the 3D rendering pipeline. Adding LayoutLabRootView to it generalizes the harness into a layout iteration surface as well. The structural pattern (a separate tiny app target that shares the production view source, runs against bundled fixtures with no network or biometric or BLE dependencies) pays for itself the first time it catches a wrong diagnosis before a production ship.
What’s next
The 3D model now arrives, the cleanup pipeline is on the server (v5, mock_cloud.py), and the layout no longer breaks once scans land. The remaining gaps are UX-faithfulness to the official Naked iOS app: the current iOS app is custom-styled (navy + pink accent) and missing several APK-faithful screens: onboarding splash, scan-capture progress view, full settings/profile flow, the comparison view’s heatmap mode. Those are the next session’s work. The infrastructure question (can the device produce a scan, can the iPhone render it, can both sides communicate) is answered. The product-feel question is open.
Follow along
Procedure 17-AThe story above has a wrong diagnosis in it on purpose. Here’s the clean version if you’re chasing a similar SwiftUI layout overflow:
- 1Build a layout reproduction harness before guessing at a fix. A small auxiliary app target that shares your production view source, renders against one bundled fixture, and draws visible borders around the containers you suspect turns “rebuild, install, screenshot” into a five-second simulator loop.
- 2Add visible borders and a size readout to the harness. A 2pt border around the suspect container plus a monospaced text overlay showing measured widths makes an invisible layout bug visible immediately.
- 3Don’t trust
.scaledToFill()to be layout-neutral. It only controls how pixels are drawn inside a frame. If the image’s natural aspect ratio doesn’t match the frame it’s embedded in, aZStack(or any container that proposes.infinitysizing to its children) can be pushed wider by that child’s intrinsic size. - 4Use
GeometryReaderplus a fixed.frame(width:height:)to stop the inflation.GeometryReaderreports (doesn’t negotiate) the parent’s proposed size. Passing that size as an explicit, fixed frame (notmaxWidth/maxHeight) means a greedy child can no longer inflate the container around it. - 5Confirm in the harness before shipping. Rebuild the lab, check the border sits at the expected width, and only then push the fix to the production app.
Agent prompts Procedure 17-A as prompts for a coding agent · 5 prompts
Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.