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
66 changes: 66 additions & 0 deletions examples/gallery/view.ml
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,69 @@ let card_section model_source : t =
]
]

let inset_rows n =
List.init n (fun i ->
card ~padding:12
[ text ~value:(Printf.sprintf "Inset demo row %d" (i + 1)) [] ])

let edge_inset_section model_source send : t =
let checked = model_source >|= Model.checked in
section "Edge Inset"
[ switch_ ~text:"Show pinned bar" ~checked:(reactive checked)
~on_toggle:(on_toggle send (fun v -> Model.SetChecked v)) []
; edge_inset ~edge:`top ~background:"bar" ~max_width:560 ~height:240
~visible:(reactive checked)
[ scroll ~height:200 [ list ~gap:8 ~cross:`stretch (inset_rows 12) ]
; row ~padding:8 ~gap:8 ~cross:`center
[ text ~value:"Pinned top bar" []
; spacer []
; text ~value:"edge-inset · top" ~foreground:"muted-foreground" []
]
]
; edge_inset ~edge:`bottom ~background:"bar" ~max_width:560 ~height:180
[ scroll ~height:150 [ list ~gap:8 ~cross:`stretch (inset_rows 10) ]
; row ~padding:8 ~gap:8 ~cross:`center
[ text ~value:"Pinned bottom bar" []
; spacer []
; text ~value:"edge-inset · bottom" ~foreground:"muted-foreground" []
]
]
]

let overlay_section : t =
section "Overlay"
[ overlay ~max_width:560
[ card ~width:560 ~height:160 ~padding:16
[ column ~gap:8
[ text ~value:"Base card — this content sizes the overlay" []
; paragraph
~value:"Floating children do not shift this layout." []
]
]
; align `top_trailing
(card ~padding:8 [ text ~value:"Float · top-trailing" [] ])
; align `bottom_leading
(card ~padding:8 [ text ~value:"Float · bottom-leading" [] ])
]
]

let view_that_fits_section : t =
section "View That Fits"
[ paragraph
~value:"Widen the window for the expanded layout; shrink it below ~700pt wide for the compact fallback."
[]
; view_that_fits
[ card ~width:640 ~padding:16
[ row ~gap:8 ~cross:`center
[ text ~value:"EXPANDED — wide layout" []
; spacer []
; text ~value:"640pt" ~foreground:"muted-foreground" []
]
]
; text ~value:"COMPACT — narrow fallback" ~foreground:"accent" []
]
]

let alert_section : t =
section "Alert"
[ alert ~text:"Sync paused" ~variant:`secondary
Expand Down Expand Up @@ -1577,6 +1640,9 @@ let view context model_source send : t =
; row_section
; column_section
; grid_section
; edge_inset_section model_source send
; overlay_section
; view_that_fits_section
; text_section
; spacer_section
; toggle_button_section model_source send
Expand Down
170 changes: 168 additions & 2 deletions platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,9 @@ enum LUIDirectRevisionObservationPolicy {

enum LUIUnmodifiedNodePolicy {
static func bypassesSurface(kind: LUINodeKind) -> Bool {
kind == .spacer
// edge-inset bypasses the container surface: its surface props style
// the pinned region instead (LUIEdgeInsetView applies the modifier).
kind == .spacer || kind == .edgeInset
}
}

Expand Down Expand Up @@ -619,6 +621,12 @@ private struct LUINodeView: View {
return AnyView(LUIGridView(model: model, backend: backend))
case .stack, .panel, .card:
return AnyView(LUIStackView(model: model, backend: backend))
case .edgeInset:
return AnyView(LUIEdgeInsetView(model: model, backend: backend))
case .overlay:
return AnyView(LUIOverlayView(model: model, backend: backend))
case .viewThatFits:
return AnyView(LUIViewThatFitsView(model: model, backend: backend))
case .alert:
return AnyView(LUIAlertView(model: model, backend: backend))
case .bubble:
Expand Down Expand Up @@ -1687,6 +1695,160 @@ private struct LUIStackView: View {
}
}

/// Pins every child after the first to a screen edge via `.safeAreaInset`
/// while the first child fills the view and scrolls beneath. The node's
/// surface props style the pinned region (the container bypasses the surface
/// modifier), so e.g. background "bar" gives a material chrome bar.
private struct LUIEdgeInsetView: View {
let model: LUINodeModel
let backend: LUIAppleBackend

private var edgeName: String {
model.property(.edge)?.stringValue ?? "top"
}

private var isSideEdge: Bool {
edgeName == "leading" || edgeName == "trailing"
}

private var isVisible: Bool {
model.property(.visible)?.boolValue ?? true
}

private var spacing: CGFloat {
CGFloat(model.property(.gap)?.intValue ?? 0)
}

var body: some View {
Group {
if isSideEdge {
content
.safeAreaInset(
edge: edgeName == "trailing"
? HorizontalEdge.trailing : .leading,
spacing: spacing
) {
pinnedRegion
}
} else {
content
.safeAreaInset(
edge: edgeName == "bottom" ? VerticalEdge.bottom : .top,
spacing: spacing
) {
pinnedRegion
}
}
}
.animation(.default, value: isVisible)
}

@ViewBuilder
private var content: some View {
if let contentID = model.children.first {
LUIAnyNodeView(nodeID: contentID, backend: backend).equatable()
}
}

@ViewBuilder
private var pinnedRegion: some View {
// `visible` toggles the pinned region in place so the safe-area
// insertion animates instead of snapping.
if isVisible {
if isSideEdge {
HStack(spacing: 0) {
pinnedViews(Array(model.children.dropFirst()))
}
.frame(maxHeight: .infinity)
// Hug along the edge: an expansive pinned child (e.g. overlay)
// would otherwise eat the entire safe-area inset region.
.fixedSize(horizontal: true, vertical: false)
.modifier(LUISurfaceModifier(model: model, backend: backend))
} else {
VStack(spacing: 0) {
pinnedViews(Array(model.children.dropFirst()))
}
.frame(maxWidth: .infinity)
.fixedSize(horizontal: false, vertical: true)
.modifier(LUISurfaceModifier(model: model, backend: backend))
}
}
}

private func pinnedViews(_ ids: [Int]) -> some View {
ForEach(ids, id: \.self) { childID in
LUIAnyNodeView(nodeID: childID, backend: backend).equatable()
}
}
}

/// Renders the children after the first floating over the first child
/// (`base`) without affecting its layout. Each overlay child positions
/// itself by its own `alignment` property, falling back to the overlay's
/// `alignment` (default `.center`).
private struct LUIOverlayView: View {
let model: LUINodeModel
let backend: LUIAppleBackend

private func alignment(of childID: Int) -> Alignment {
Self.alignment(
backend.model(id: childID)?.property(.alignment)?.stringValue
?? model.property(.alignment)?.stringValue
)
}

private static func alignment(_ name: String?) -> Alignment {
switch name {
case "top-leading": .topLeading
case "top": .top
case "top-trailing": .topTrailing
case "leading": .leading
case "trailing": .trailing
case "bottom-leading": .bottomLeading
case "bottom": .bottom
case "bottom-trailing": .bottomTrailing
default: .center
}
}

var body: some View {
let base: AnyView
if let baseID = model.children.first {
base = AnyView(LUIAnyNodeView(nodeID: baseID, backend: backend).equatable())
} else {
base = AnyView(EmptyView())
}
return model.children.dropFirst().reduce(base) { content, childID in
AnyView(
content.overlay(alignment: alignment(of: childID)) {
LUIAnyNodeView(nodeID: childID, backend: backend).equatable()
}
)
}
}
}

/// Renders the first child that fits along `orientation`
/// (`ViewThatFits(in:)`; default horizontal).
private struct LUIViewThatFitsView: View {
let model: LUINodeModel
let backend: LUIAppleBackend

private var axis: Axis.Set {
model.property(.orientation)?.stringValue == "vertical"
? .vertical
: .horizontal
}

var body: some View {
ViewThatFits(in: axis) {
ForEach(model.children, id: \.self) { childID in
LUIAnyNodeView(nodeID: childID, backend: backend).equatable()
}
}
}
}

private struct LUIAnchoredComboboxMenuHost<Content: View>: View {
let model: LUINodeModel?
let backend: LUIAppleBackend
Expand Down Expand Up @@ -5739,7 +5901,11 @@ private struct LUIBackgroundStyleModifier: ViewModifier {
@ViewBuilder
func body(content: Content) -> some View {
let glassShape: AnyShape = isPill ? AnyShape(Capsule()) : shape
if name == "glass", !suppressesOwnGlass {
if name == "bar" {
// System chrome material (`.background(.bar)`), for edge-pinned
// bars that should pick up the platform bar treatment.
content.background(.bar, in: shape)
Comment on lines +5904 to +5907

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Define the new bar background on every backend

The public edge_inset API advertises ~background:"bar", but only this Swift branch recognizes that new name. The Flutter color resolver falls back to transparent, Qt's palette falls back to transparent, WinUI's theme resolver returns no brush, and the web emits the invalid CSS color bar, so the advertised pinned chrome has no background on every non-Apple backend; each backend needs a suitable bar-surface fallback.

Useful? React with 👍 / 👎.

} else if name == "glass", !suppressesOwnGlass {
if #available(iOS 26.0, macOS 26.0, *) {
content.glassEffect(.regular.interactive(), in: glassShape)
} else {
Expand Down
38 changes: 36 additions & 2 deletions platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ enum LUIWireValue: Decodable, Equatable {
case .grow:
guard let value = doubleValue else { return false }
return value.isFinite && value >= 0
case .edge:
guard let value = stringValue else { return false }
return Self.edges.contains(value)
case .visible:
return boolValue != nil
case .alignment:
guard let value = stringValue else { return false }
return Self.overlayAlignments.contains(value)
case .gap, .padding:
return intValue != nil
}
Expand All @@ -232,6 +240,16 @@ enum LUIWireValue: Decodable, Equatable {

private static let textAlignments: Set<String> = ["start", "center", "end"]

private static let edges: Set<String> = [
"top", "bottom", "leading", "trailing",
]

private static let overlayAlignments: Set<String> = [
"top-leading", "top", "top-trailing",
"leading", "center", "trailing",
"bottom-leading", "bottom", "bottom-trailing",
]

private static let toolbarPlacements: Set<String> = [
"automatic", "bottom", "navigation", "principal", "primary-action",
"secondary-action", "status", "confirmation-action", "cancellation-action",
Expand Down Expand Up @@ -680,6 +698,10 @@ struct LUIRetainedTree {

private static func supports(_ property: LUIProperty, on kind: LUINodeKind) -> Bool {
if property == .accessibilityIdentifier { return true }
// Position hint honored on overlay children and the overlay itself;
// admitted before the restrictive matrix so e.g. an aligned
// menu-trigger child still carries it. Inert elsewhere.
if property == .alignment { return kind != .root }
// Restrictive per-kind allow-lists and additive extras come from
// schema/components.json via LUISchemaMatrix; kinds absent from the
// restrictive table fall back to the shared structural rules below.
Expand Down Expand Up @@ -717,6 +739,8 @@ struct LUIRetainedTree {
kind == .row || kind == .column || kind == .grid || kind == .box ||
kind == .panel || kind == .card || kind == .stack ||
kind == .scroll || kind == .avatar ||
kind == .edgeInset || kind == .overlay ||
kind == .viewThatFits ||
kind == .text || kind == .heading || kind == .paragraph ||
kind == .label || kind == .button || kind == .toggleButton ||
isTextEntry(kind) || kind == .checkbox || kind == .toggle ||
Expand Down Expand Up @@ -745,7 +769,7 @@ struct LUIRetainedTree {
kind == .virtualList || kind == .scroll || kind == .card ||
kind == .panel || kind == .box ||
kind == .dropdownMenu || isHorizontalGroup(kind) || kind == .split
|| kind == .tableRow || kind == .tree
|| kind == .tableRow || kind == .tree || kind == .edgeInset
case .placeholder:
isTextEntry(kind) || kind == .select
case .accessibilityIdentifier:
Expand All @@ -768,7 +792,9 @@ struct LUIRetainedTree {
kind == .checkbox || kind == .switchControl || kind == .toggle || kind == .radio
case .progressValue: kind == .progress || kind == .slider || kind == .split
case .resizeDuration, .resizeEasing, .resizeOrigin: kind == .split
case .orientation: kind == .divider || kind == .tabs || kind == .scroll
case .orientation:
kind == .divider || kind == .tabs || kind == .scroll ||
kind == .viewThatFits
case .placement: kind == .toolbar
case .size:
kind == .button || kind == .toggleButton || kind == .spinner ||
Expand Down Expand Up @@ -817,6 +843,9 @@ struct LUIRetainedTree {
case .role: isTreeRow(kind) || kind == .listItem
case .treeLevel, .expanded: isTreeRow(kind)
case .active, .title, .description, .meta, .indicator, .connector: false
case .edge, .visible: kind == .edgeInset
// `.alignment` is admitted ahead of the restrictive matrix above.
case .alignment: kind != .root
}
}

Expand All @@ -827,6 +856,7 @@ struct LUIRetainedTree {

private static func canContainChildren(_ kind: LUINodeKind) -> Bool {
kind == .root || kind == .row || kind == .column || kind == .grid || kind == .stack ||
kind == .edgeInset || kind == .overlay || kind == .viewThatFits ||
kind == .panel || kind == .card || kind == .box || kind == .scroll ||
kind == .list || kind == .virtualList || isHorizontalGroup(kind) || kind == .radioGroup
|| kind == .dropdownMenu || kind == .contextMenu || kind == .listItem || isModalSurface(kind)
Expand All @@ -841,6 +871,7 @@ struct LUIRetainedTree {

private static func acceptsExtensionChildren(_ kind: LUINodeKind) -> Bool {
kind == .root || kind == .row || kind == .column || kind == .grid || kind == .stack ||
kind == .edgeInset || kind == .overlay || kind == .viewThatFits ||
kind == .panel || kind == .card || kind == .box || kind == .scroll ||
kind == .list || kind == .virtualList || kind == .listItem || kind == .dialog ||
kind == .sheet || kind == .accordion || kind == .resizable || kind == .split ||
Expand Down Expand Up @@ -900,6 +931,9 @@ struct LUIRetainedTree {
if node.kind == .icon, node.properties[.name] == nil {
throw invalid("icon requires name")
}
if node.kind == .edgeInset, node.properties[.edge] == nil {
throw invalid("edge-inset requires edge")
}
if node.kind == .button || node.kind == .toggleButton ||
node.kind == .toggle || node.kind == .radio {
let text = node.properties[.text]?.stringValue ?? ""
Expand Down
Loading
Loading