Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions guides/components.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
3 changes: 2 additions & 1 deletion ios/MobNode.h
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions ios/MobNode.m
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
55 changes: 53 additions & 2 deletions ios/MobRootView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,11 @@
}
}

enum MobLayoutWeightAxis {
case horizontal
case vertical
}

extension View {
@ViewBuilder
func ifLet<T>(_ value: T?, transform: (Self, T) -> some View) -> some View {
Expand DownExpand Up@@ -241,13 +246,21 @@

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.
Expand All@@ -272,7 +285,9 @@
}
}()
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
Expand DownExpand Up@@ -513,6 +528,12 @@
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.
Expand All@@ -524,6 +545,36 @@
}
}

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
Expand DownExpand Up@@ -1149,7 +1200,7 @@
// no manual frame management required.
private class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }

Check warning on line 1203 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions/ Native formatters (clang-format + swiftlint)

Force casts should be avoided (force_cast)
}

private struct MobCameraPreviewView: UIViewRepresentable {
Expand DownExpand Up@@ -1969,4 +2020,4 @@
}
}
}
}

Check warning on line 2023 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions/ Native formatters (clang-format + swiftlint)

File should contain 2000 lines or less: currently contains 2023 (file_length)
4 changes: 4 additions & 0 deletions ios/mob_nif.m
Original file line numberDiff line numberDiff line change
Expand Up@@ -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];
Expand Down
29 changes: 29 additions & 0 deletions test/mob/native_layout_weight_test.exs
Original file line numberDiff line numberDiff line change
@@ -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
Loading