diff --git a/guides/components.md b/guides/components.md index 46afd4c4..0df96aec 100644 --- a/guides/components.md +++ b/guides/components.md @@ -199,6 +199,10 @@ cancel_tap = {self(), :cancel} """ ``` +A single weighted child expands into the parent's remaining main-axis space. Multiple +weighted children divide that space evenly on iOS; Android additionally honors unequal +numeric ratios. Weight values must be positive. Use equal weights for cross-platform layouts. + ### `:box` A single-child container. Use it to add background, padding, or corner radius to a child: diff --git a/ios/MobNode.h b/ios/MobNode.h index ec830e07..5082c9f8 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -173,7 +173,8 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, strong, nullable) UIColor *color; // track / indicator color // Layout behaviour -@property(nonatomic) BOOL fillWidth; // fill parent width (default NO; button default YES) +@property(nonatomic) CGFloat layoutWeight; // positive = expand on a row/column's main axis +@property(nonatomic) BOOL fillWidth; // fill parent width (default NO; button default YES) @property(nonatomic) BOOL fillHeight; // fill parent height (default NO) — used for full-screen overlays/dialogs @property(nonatomic) CGFloat cornerRadius; // rounded corners in pt (default 0) diff --git a/ios/MobNode.m b/ios/MobNode.m index e9f442c7..9bc81320 100644 --- a/ios/MobNode.m +++ b/ios/MobNode.m @@ -35,6 +35,7 @@ - (instancetype)init { _contentModeStr = @"fit"; _fixedWidth = 0.0; _fixedHeight = 0.0; + _layoutWeight = 0.0; _fillWidth = NO; _cornerRadius = 0.0; _nativeViewHandle = -1; // -1 = no native component slot assigned (MOB-100) diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 26a20068..32397c48 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -47,6 +47,11 @@ public final class MobNativeViewRegistry { } } +enum MobLayoutWeightAxis { + case horizontal + case vertical +} + extension View { @ViewBuilder func ifLet(_ value: T?, transform: (Self, T) -> some View) -> some View { @@ -241,13 +246,21 @@ extension MobNode { struct MobNodeView: View { let node: MobNode + private let layoutWeightAxis: MobLayoutWeightAxis? + + init(node: MobNode, layoutWeightAxis: MobLayoutWeightAxis? = nil) { + self.node = node + self.layoutWeightAxis = layoutWeightAxis + } var body: some View { Group { switch node.nodeType { case .column: VStack(alignment: .leading, spacing: 0) { - ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } + ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in + MobNodeView(node: child, layoutWeightAxis: .vertical) + } } // fill_height: true lets a column flex to fill its parent so children // with Spacer() or fill_height of their own can pin to the bottom. @@ -272,7 +285,9 @@ struct MobNodeView: View { } }() HStack(alignment: alignment, spacing: 0) { - ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } + ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in + MobNodeView(node: child, layoutWeightAxis: .horizontal) + } } // Without maxWidth: .infinity an HStack hugs its content. // Flex Spacers inside then have nothing to expand into and @@ -513,6 +528,12 @@ struct MobNodeView: View { EmptyView() } } + // Weight sizing must wrap the node's own visual decoration. Applying + // the frame from the parent after MobNodeView has painted its + // background leaves an expanded transparent region around a + // content-sized fill. Repaint that decoration on the weighted frame + // so iOS matches Compose's weight-before-nodeModifier ordering. + .modifier(MobLayoutWeight(node: node, axis: layoutWeightAxis)) // Per-node offset — applied uniformly to every node type. Default is // (0, 0) which is a no-op. Used by SquareTriangle's hexagonal // snowflake to position rings absolutely within a center-aligned box. @@ -524,6 +545,36 @@ struct MobNodeView: View { } } +private struct MobLayoutWeight: ViewModifier { + let node: MobNode + let axis: MobLayoutWeightAxis? + + @ViewBuilder + func body(content: Content) -> some View { + if node.layoutWeight > 0, let axis { + switch axis { + case .horizontal: + decorate(content.frame(maxWidth: .infinity, alignment: .leading)) + case .vertical: + decorate(content.frame(maxHeight: .infinity, alignment: .top)) + } + } else { + content + } + } + + private func decorate(_ content: some View) -> some View { + content + .mobBoxBackground(node: node) + .overlay( + RoundedRectangle(cornerRadius: node.cornerRadius) + .stroke(node.borderColor.map { Color($0) } ?? Color.clear, + lineWidth: node.borderWidth) + .allowsHitTesting(false) + ) + } +} + // MobFrameTracker — for any node with an :id, set it as the accessibility // identifier and report the element's global frame (logical points) to the C // registry as it lays out / moves. Untagged nodes pass through untouched, so diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 9253e4d9..6a857d2b 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -1073,6 +1073,10 @@ static void mob_send_change_float(int handle, double value) { if (fixedHeight) node.fixedHeight = [fixedHeight doubleValue]; + id layoutWeight = props[@"weight"]; + if (layoutWeight) + node.layoutWeight = [layoutWeight doubleValue]; + id cornerRadius = props[@"corner_radius"]; if (cornerRadius) node.cornerRadius = [cornerRadius doubleValue]; diff --git a/test/mob/native_layout_weight_test.exs b/test/mob/native_layout_weight_test.exs new file mode 100644 index 00000000..3b9297a1 --- /dev/null +++ b/test/mob/native_layout_weight_test.exs @@ -0,0 +1,29 @@ +# These source-contract tests guard native SwiftUI behavior that Elixir cannot execute. +# credo:disable-for-this-file Jump.CredoChecks.VacuousTest +defmodule Mob.NativeLayoutWeightTest do + use ExUnit.Case, async: true + + @ios Path.expand("../../ios", __DIR__) + + test "iOS parses layout weight at the native boundary" do + header = File.read!(Path.join(@ios, "MobNode.h")) + implementation = File.read!(Path.join(@ios, "MobNode.m")) + nif = File.read!(Path.join(@ios, "mob_nif.m")) + + assert header =~ "CGFloat layoutWeight" + assert implementation =~ "_layoutWeight = 0.0" + assert nif =~ ~s|props[@"weight"]| + assert nif =~ "node.layoutWeight = [layoutWeight doubleValue]" + end + + test "iOS expands weighted children on their parent's main axis" do + source = File.read!(Path.join(@ios, "MobRootView.swift")) + + assert source =~ "MobNodeView(node: child, layoutWeightAxis: .vertical)" + assert source =~ "MobNodeView(node: child, layoutWeightAxis: .horizontal)" + assert source =~ ".modifier(MobLayoutWeight(node: node, axis: layoutWeightAxis))" + assert source =~ "frame(maxHeight: .infinity, alignment: .top)" + assert source =~ "frame(maxWidth: .infinity, alignment: .leading)" + assert source =~ ".mobBoxBackground(node: node)" + end +end