The previous post got the palette right. This one rebuilds the Compare tab to actually match the APK’s structure and behavior, working from two source files in the decoded APK: res/layout/layout_compare.xml for the shape, and smali_classes2/com/nakedlabs/.../CompareCardsAdapter.smali + .../managers/CompareStatsManager*.smali for the row-tap-to-model-zoom binding.
The structural shape
Stripped of ConstraintLayout boilerplate, the APK Compare is this:
┌────────────────────────────────────────┐│ model_view (height = 360dp, fixed) │ ← two avatars side-by-side├────────────────┬───────────────────────┤│ ▼ date_before │ ▼ date_after │ ← two date dropdowns├────────────────┴───────────────────────┤│ stats_recycler (vertical scroll) ││ ┌──────────┬───────────┬─────────┐ ││ │ 76.0 cm │ WAIST ▼ │ 78.1 cm │ │ ← 3 equal-weight columns│ │ │ +2.1 cm │ │ ││ └──────────┴───────────┴─────────┘ ││ ... │└────────────────────────────────────────┘height_container_compare_view = 360.0dp is in res/values/dimens.xml. Picked up as a literal .frame(height: 360) on iOS: the model row is fixed-height, not flexible, and the stats list fills the rest. This is one of those details you’d never guess from screenshots; you have to read the XML.
The per-row layout (item_stat_compare.xml) uses android:weightSum="3.0" with three children each android:layout_weight="1.0". SwiftUI’s Spacer()-driven equal-distribution via .frame(maxWidth: .infinity) on each column gives the same behavior. Inside the middle column, the stat name (15sp, ALL CAPS, with a drawableEnd="@drawable/ic_arrow_down" chevron) sits above a delta value+unit row in @color/gray_compare_difference (which is #8014131b, half-opacity bottom_bar).
The smali find
I’d assumed the Compare zoom was scroll-driven: scroll the stats list, the centered row’s region animates the models. The smali tells a different story:
.class public interface abstract Lcom/nakedlabs/.../CompareCardsAdapter$CompareItemListener; # virtual methods.method public abstract onItemClick(Landroid/view/View;I)V.end method.class public interface abstract Lcom/nakedlabs/.../managers/CompareStatsManager$CompareManagerListener; # virtual methods.method public abstract onUserBandChanged(Ljava/lang/String;)V.end methodThat’s the entire interface surface for the Compare→model interaction:
sequenceDiagram
participant Tap as Tap on stat row
participant Adapter as CompareCardsAdapter
participant Manager as CompareStatsManager
participant Activity as BodyModelActivity
Tap->>Adapter: onItemClick(view, position)
Adapter->>Manager: look up band name at position
Manager->>Activity: onUserBandChanged("WAIST")
Activity->>Activity: update both model_view cameras to focus regionNo scroll listener. No fling detection. Just onItemClick.
The scroll-feel I remembered is probably the SnappingRecyclerView (a custom class used elsewhere in the app for the bottom MEASURE cards in the Body tab: bottom_cards is a com.nakedlabs.android.nakedfit.ui.components.SnappingRecyclerView). Inside the Compare layout stats_recycler is a plain androidx.recyclerview.widget.RecyclerView with a plain LinearLayoutManager, no snapping. So the impression was from a different surface in the same app.
The iOS port is simpler than the original instinct: a LazyVStack of CompareStatRows, each with .onTapGesture. Tapping sets a shared @State selected: CompareRowKind?, which both BodyModelView instances read as their selectedPart, and the existing camera animation I’d built for the Body tab’s MEASURE sub-tab does the rest.
sequenceDiagram
participant Row as Stat row (tap)
participant State as @State selected
participant ModelA as BodyModelView (before)
participant ModelB as BodyModelView (after)
Row->>State: onTapGesture sets selected = CompareRowKind
State->>ModelA: selectedPart updated
State->>ModelB: selectedPart updated
Note over ModelA,ModelB: existing MEASURE camera animation<br/>eases into the region centroidZero new geometry code, zero new SceneKit work. Reusing what’s already there is the whole point of porting carefully.
The enum
CompareStatType has 17 cases in the APK:
BODY_FAT, WEIGHT, LEAN_MASS, FAT_MASS, WAIST_TO_HIP,WAIST, HIPS, CHEST, STOMACH, SHOULDER,UPPER_ARM_LEFT, UPPER_ARM_RIGHT,UPPER_THIGH_LEFT, UPPER_THIGH_RIGHT,LOWER_THIGH_LEFT, LOWER_THIGH_RIGHT,CALF_LEFT, CALF_RIGHTiOS already had a BodyPart enum with all the circumference regions. The composition stats (BODY_FAT, WEIGHT, LEAN_MASS, FAT_MASS) live on Scan directly. WAIST_TO_HIP is the only derived one (waist_circumference / hip_circumference) and gets its own row when both numbers are available.
So the iOS model is CompareRowKind:
enum CompareRowKind: Hashable { case bodyFat, weight, leanMass, fatMass, waistToHip case region(BodyPart) /// Which region the dual model camera should focus on for this row. var regionForFocus: String? { switch self { case .bodyFat, .weight, .leanMass, .fatMass: return nil // no specific body region case .waistToHip: return BodyPart.waist.rawValue // emphasize the waist ring case .region(let part): return part.rawValue } }}A CompareRow wraps the kind with before / after values, a format closure, the unit label, and a DeltaDirection enum (.lowerIsBetter / .higherIsBetter / .neutral) that drives the green / red / gray delta-text coloring. Body fat dropping is green. Lean mass going up is green. Weight is neutral (no green/red, losing weight isn’t categorically “better” for a body-comp app). Limb circumferences are neutral. Waist/hips/stomach circumferences are .lowerIsBetter because the typical user goal is dropping them, but this is opinionated and easy to flip.
The @MainActor papercut
The factory methods on CompareRow call Units.weightValue(_:) and Units.circumferenceValue(_:). Both read from ProfileStore.shared.profile?.unitSystem, which is @MainActor-isolated. Swift Concurrency requires every static factory that calls them to also be @MainActor:
@MainActor static func weight(before: Double, after: Double) -> CompareRow { ... }The compiler error is loud and useful (“call to main actor-isolated static method ‘weightUnit()’ in a synchronous nonisolated context”), but the fix annotation is unergonomic. There’s no equivalent of @inheritActor for static methods, so each factory needs its own @MainActor. Calling code (orderedRows() inside a SwiftUI View) is already @MainActor by SwiftUI default, so once the factories are annotated, the call site Just Works.
The cleaner alternative would be to pre-format the strings at row-construction time and store them as let beforeText: String, afterText: String instead of let before: Double?, after: Double? plus format: (Double) -> String. Less actor surface, less work per render. Phase C can swap that out if it becomes a hotspot.
The History tab compare-sheet plumbing
The History tab has a “Compare first to latest” button that opens ScanComparisonView as a sheet, pre-bound to the oldest and newest scans. The new ScanComparisonView signature takes allScans + two Binding<String?> for the IDs, not two Scan objects directly, because the dropdowns let the user re-pick within the sheet. A small bridge view HistoryComparePresenter adapts the old “open with these two scans” call site to the new “here are all scans, start with these two IDs” shape:
.sheet(item: $comparePair) { pair in HistoryComparePresenter(scans: scans, initialBeforeId: pair.a.id, initialAfterId: pair.b.id)}The presenter holds the @State for the two IDs and seeds them from the pair .onAppear. Once the sheet is open the user can swap either side via the date dropdowns. The History “first to latest” button just provides the starting selection.
What this commit does NOT do
A handful of intentional omissions worth naming, so they’re not surprises in Phase C+:
- No snapping on the stats list. Plain LazyVStack scroll. The APK doesn’t snap here either (per the smali:
LinearLayoutManager, no SnapHelper). If the feel ends up off, aScrollViewReader.scrollToafter row-tap would add center-anchoring without changing the underlying interaction. - No expanded-row variant. The APK has
item_bottom_double_card_expanded.xmlfor an “expand the card to see a history graph” pattern on the Body tab. Compare doesn’t have it: the per-row dropdown chevron in the center column is a hint, but tapping it just toggles selection (and model focus); it doesn’t expand a sub-panel. Adding that would be Phase C territory. - No multi-scan-per-day picker. If the user has 3 scans on the same day the date dropdown shows three identically-labeled entries with different times. APK has
layout_mutiple_scan_picker.xmlfor a richer picker; iOS just lists them with time suffixes for now. - No heatmap. The APK doesn’t have a per-vertex distance heatmap either. That was an aspirational iOS-only feature noted in earlier comments. Removed the “Phase 6 polish” comment; it’s not what the APK does.
Phase C polishes the Body tab itself: KEY STATS gets inline sparklines, MEASURE gets expanded cards, symmetric regions (arms, thighs, calves) get L/R split cards. Phase D rebuilds the scan flow. Phase E is the first-scan walkthrough. Phase F is profile/settings/history polish. Six phases total; A and B are in, four to go.
Follow along
Procedure 19-AThis post is mostly a straight port, but it has one real correction in it (the scroll-vs-tap assumption). If you’re porting an Android RecyclerView-driven interaction to SwiftUI from a decompiled APK:
- 1Read the layout XML for the structural shape first (
res/layout/<screen>.xmland the row layout it includes), before writing any SwiftUI. Note any literal, non-screenshot-visible constants (fixed heights, weight sums) inres/values/dimens.xml. - 2Don’t assume the interaction model. Read the smali. Decompiled
.smaliinterface definitions (*Listener.smali) tell you exactly which callback fires on which gesture. A tap-drivenonItemClickand a scroll-driven snap listener look similar from a demo video; they are not the same code path. - 3Check whether a
RecyclerViewactually snaps before building snap-to-center behavior: a plainLinearLayoutManagerwith noSnapHelpermeans ordinary scrolling, not a carousel. - 4Reuse existing camera/animation code on the iOS side rather than building new geometry for a “new” interaction, when the underlying focus-a-region behavior already exists elsewhere in the app.
- 5Isolate
@MainActor-isolated reads behind small static factories on your row/view-model type, so the actor-isolation requirement doesn’t spread through your call sites. - 6Write down what you’re deliberately not porting yet (snapping, expanded rows, multi-scan-per-day pickers, heatmaps) so later phases don’t rediscover them as surprises.
Agent prompts Procedure 19-A as prompts for a coding agent · 6 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.