Skip to content

fix(ios): honor layout weight in stacks - #98

Merged
GenericJam merged 1 commit into
masterfrom
fix/ios-layout-weight-parity
Aug 30, 2026
Merged

fix(ios): honor layout weight in stacks#98
GenericJam merged 1 commit into
masterfrom
fix/ios-layout-weight-parity

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Summary

  • parse weight into the iOS native node model
  • expand weighted row and column children on their parent main axis
  • document cross-platform equal-weight behavior and add source-contract coverage

Verification

  • 1,203 tests pass (38 excluded)
  • mix credo --strict
  • mix compile --warnings-as-errors
  • Erlang, Objective-C, and Swift formatting/lint gates
  • physical iPhone build/install and short-screen bottom-navigation layout check

@GenericJam

Copy link
Copy Markdown
OwnerAuthor

Validated on device. The change works — with a correctly built binary, weight distributes on both axes: a row's two weight: 1 children each take half the width, and a fill_height column's two weight: 1 children split the remaining height. The prop reaches native correctly ("weight":1 on the JSON wire via Mob.Renderer's catch-all, and the parse site at mob_nif.m:1076 is inside mob_node_from_dictbefore its recursion into children). Non-weighted children are untouched by the weight > 0 guard.

One finding worth addressing before people build weighted panels on this.

The weight frame sits on the wrong side of the child's background

Ran the same probe on both platforms — two weighted panels with backgrounds, in a row and in a column.

Android: each panel's background fills its half — full-width colour bars in the row, half-height blocks in the column.

iOS with this PR: the panels are positioned at the same half-points, but their backgrounds hug their text inside an expanded, invisible frame.

The cause is modifier ordering. Android passes the weight Modifier as the base of the child's chain — RenderNodeInner does modifier.then(nodeModifier(node.props)), and nodeModifier applies background, padding and corner radius — so styling covers the weighted area. This PR applies the frame from the parent, outside MobNodeView:

MobNodeView(node: child).mobColumnWeight(child.layoutWeight)

MobNodeView applies .padding() then .background() internally (lines 258–259, 293–294, …), so the weight frame lands after them.

Worth noting MobRootView.swift already establishes the opposite convention for a node's own fill — line 257 puts .frame(maxWidth: .infinity, maxHeight: fillHeight ? .infinity : nil)before.padding() and .background(), with a comment explaining why. So the current placement is inconsistent with both Android and with this file's own handling of the same concept.

This matters because weighted layout's common case is colored panels splitting a screen: correct on Android, wrong on iOS, and silent.

Two minor notes

weight: 0. A no-op on iOS (weight > 0 guard); Compose's Modifier.weight requires > 0 and throws. Since this PR adds the first documentation this prop has ever had, that'd be a good place to say weight must be positive.

The tests can't see this. They're grep-based source-contract tests asserting the strings exist, so they pass whether or not the layout behaves — green CI says nothing about whether weight works. That's inherent to testing native layout from Elixir and matches the existing native_sheet_test.exs convention, so not a blocker; just means the device check is load-bearing.

Unrelated gotcha found while verifying

mix mob.deploy --native takes native sources from mob_dir in mob.exs, which is independent of the path: dep in mix.exs. With them pointing at different trees you get Elixir from one and .m/.swift from the other, while the build prints ✓ iOS native build complete and exits 0. Cost me several rounds of wrong conclusions here. Caught it with a control on the installed binary — fillHeight/cornerRadius/fixedHeight each appeared 3×, layoutWeight 0×. Filing separately.

@GenericJam
GenericJamforce-pushed the fix/ios-layout-weight-parity branch from 4c4663a to 18872a1CompareAugust 29, 2026 17:57
@GenericJam

Copy link
Copy Markdown
OwnerAuthor

Adversarially reviewed and device-verified on a pool iPhone 17 Pro sim (harness app with :mob path-dep on this branch, verified via Mob.Test.element_frames + real UITouch injection). Approve.

Verified numerically: single weighted child expands to fill (342 = 402−60 remainder); two weighted children split evenly per the updated docs (201/201 — iOS deliberately doesn't do Android's numeric ratios, and guides/components.md now says so accurately); weighted middle child pins trailing footer flush to container bottom; weighted :spacer works (the generic mob_node_from_dict parse covers every node type); the expanded frame is genuinely tappable to its far edge and the frame registry reports expanded geometry (modifier ordering weight → offset → FrameTracker is right). Host suite 1203 passed, credo --strict clean, warnings-as-errors clean. Android untouched (ios/* + docs + test only).

Coexistence check for downstream consumers: a weighted child alongside a size-less expanding Spacer does NOT double-expand — the Spacer collapses to its ~8pt minLength. Safe to ship before consumers remove their workarounds.

Non-blocking follow-ups worth tracking: (1) the new test pins source strings rather than behavior — an :on_device frame-assertion test would survive refactors; (2) content inside the expanded frame is hardcoded top/leading, so a weighted Button's label leading-pins on iOS where Compose centers it — cosmetic parity nuance; (3) weighted nodes with translucent backgrounds or borders get double-painted decoration (intrinsic + expanded) — invisible for opaque fills, visible artifact for translucent/bordered; (4) weight: 0/negative unvalidated (Android throws at runtime). Also found pre-existing and unrelated: MobBox ignores height unless width is also set (fixedHeight only applied inside the fixedWidth > 0 branch, MobRootView.swift ~764-776) — filing separately.

Merging.

@GenericJam
GenericJam merged commit 848eabd into masterAug 30, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@GenericJam