Skip to content

Latest commit

 

History

History
994 lines (759 loc) · 29.9 KB

API.md

File metadata and controls

994 lines (759 loc) · 29.9 KB

SwiftFlow — the app-facing API

Everything an app can reach. If it isn't here, it's internal to the framework and will move without warning.

The shape is SwiftUI's, and where a name matches SwiftUI the behaviour is meant to match too. The places it deliberately doesn't are marked Differs. ARCHITECTURE.md covers the why; this is the what.

Sizes are in points (Float, not CGFloat — there is no CoreGraphics here). Points become physical pixels at the FFI boundary.


Contents


App and scenes

@main
struct MyApp: SwiftFlowApp {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
Symbol Notes
protocol SwiftFlowApp init() + @SceneBuilder var body: Body
protocol Scene
struct WindowGroup<Content: View>: Scene init(@ViewBuilder content:)
@resultBuilder SceneBuilder

Differs: one scene. No Settings, no DocumentGroup, no multi-window.


Views

protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Body { get }
}

Two refinements exist for the framework's own types and are usable by apps that build nodes directly:

  • protocol PrimitiveView: View where Body == Never — has no body; must implement toSFNode() -> SFNode.
  • protocol RecursiveView: View where Body: View — the ordinary case.

AnyView erases: AnyView(erasing: someView) or AnyView(someView).

ViewBuilder supports if/else, if let, switch, and variadic blocks. There is no ten-child limit — it uses parameter packs.

Primitives

View Init
Text Text(_ content: String)
Image Image(_ name: String), Image(named:), Image.system(_:)
AsyncImage AsyncImage(url:), AsyncImage(url:content:)
Icon Icon.<name> from the generated catalogue, or Icon(scalar:)
TextField TextField(_ placeholder: String, text: Binding<String>, font: Font = .body, onSubmit: (() -> Void)? = nil)
Toggle Toggle(_ title: String, isOn: Binding<Bool>), Toggle(isOn:), Toggle(isOn:label:)
Slider Slider(value: Binding<Float>, in bounds: ClosedRange<Float> = 0...1, step: Float? = nil)
Divider Divider()
Spacer Spacer(minLength: Float = 0)
EmptyView EmptyView()
Color is itself a view — Color.red fills its box

Text chaining (each returns Text, so they compose):

Text("Hello")
    .font(.headline)
    .fontWeight(.semibold)
    .foregroundColor(.primary)     // or .foregroundStyle(_:)
    .lineLimit(2)                  // nil = unlimited
    .multilineTextAlignment(.center)

.multilineTextAlignment(_:) also exists as a View modifier, but it writes the field on whatever node the content produced and stops there — it does not walk into a stack and re-align every string inside it, which is what SwiftUI's does. Put it on the Text.

TextAlignment is .leading / .center / .trailing. It is its own type rather than HorizontalAlignment because it answers a different question — where lines sit inside one view, not where a view sits inside its parent — and the two are routinely opposite.

Only visible when the box is wider than a line: a paragraph that wrapped, or a Text given a .frame(width:). Alignment is applied per line, so a centred paragraph centres each line rather than centring the block.

Image:

Image("photo")
    .resizable()
    .scaledToFit()                 // or .scaledToFill()
    .aspectRatio(contentMode: .fit)
    .foregroundColor(.accent)      // tint

Icon:

Icon.house
    .size(24)
    .weight(.bold)
    .foregroundColor(.primary)

Stacks and layout containers

VStack(alignment: HorizontalAlignment = .center, spacing: Float = 8) { … }
HStack(alignment: VerticalAlignment = .center, spacing: Float = 8) { … }
ZStack(alignment: Alignment = .center) { … }

ForEach over a collection, keyed by identity:

ForEach(items, id: \.id) { item in Row(item) }
ForEach(items) { item in Row(item) }          // Identifiable
ForEach(0..<5, id: \.self) { i in … }

Differs: ForEach is the only place the framework can tell a removal from a rebuild, so it is what makes insertion and removal animate rather than cut. Use it even for a fixed handful of rows if they come and go.

Differs: stacks are Hug by default on both axes. Use .expands() to make a stack itself fill (see Layout).

Scrolling

ScrollView(_ axis: Axis = .vertical, name: String? = nil) { … }
ScrollView(.horizontal) { geometry in … }     // ScrollGeometry form

ScrollGeometry gives the live offset and extents, which is what virtualised content is built from:

public let offset: Float          // current scroll position, points
public let viewportLength: Float
public let contentLength: Float

name: pins a scroll view's identity when the call site moves. Without it, identity comes from #fileID/#line/#column.

Differs: a horizontal ScrollView must be told its height — .frame(height:) — because its content decides how far it runs sideways, not how tall it is.

.defaultScrollAnchor(_ anchor: ScrollAnchor)      // on ScrollView
.scrollEdgeEffect(_ style: ScrollEdgeEffectStyle = .soft,
                  for edge: ScrollEdge = .top,
                  height: Float? = nil)
.scrollEdgeEffect(for edge: ScrollEdge = .top,
                  height: Float? = nil,
                  band: () -> some View)

ScrollAnchor is .start, .center or .end — named along the axis, since the same anchor has to mean something on a horizontal scroll. It opens the view somewhere other than the top:

ScrollView { Transcript() }.defaultScrollAnchor(.end)

The anchor lands on the first frame that has any travel to land on, so a list that only overflows once it has loaded still ends up where it was asked to. After that the reader owns the position — it never pulls content back once scrolled.

Differs: SwiftUI takes a UnitPoint and anchors both axes. This takes a ScrollAnchor and anchors the one axis the scroll runs on.

Differs: the anchor resolves against measured content, which is known a frame late, so a scroll opening at .end shows its top for one frame before it lands.

.scrollEdgeEffect() is the blur the navigation and tab bars lay down, on its own, for a scroll that sits under neither. .soft ramps the blur out across the band so content dissolves on its way under. .hard is a plate — flat fill, hairline around it — with a late-starting blur behind, for chrome meant to read as a surface of its own. height defaults to the bar on that edge: the collapsed navigation bar at the top, the tab bar and its home indicator at the bottom.

ScrollView { Feed() }
    .scrollEdgeEffect()                            // soft, top
    .scrollEdgeEffect(.hard, for: .bottom)

Retuning the built-ins is ScrollEdgeEffectMetrics, which is the only thing either style reads:

ScrollEdgeEffectMetrics.hardBlurStart    = 0.75   // where the blur ramps
ScrollEdgeEffectMetrics.hardFillOpacity  = 0.55   // the plate
ScrollEdgeEffectMetrics.hardStrokeOpacity = 0.1   // the line around it
ScrollEdgeEffectMetrics.hardStrokeWidth  = 0.67
ScrollEdgeEffectMetrics.softStart        = 0      // 0 ramps across the band

Set them once at launch and every band in the app follows. Reach for the band: form below only for a shape these numbers can't describe.

Neither style is a special case in the renderer — a band is an ordinary view pinned to an edge, and the band: form hands you that seam. Anything the two styles can't say is composition rather than a new case:

ScrollView { Feed() }
    .scrollEdgeEffect(for: .bottom, height: 120) {
        Material.thickMaterial
            .overlay(RoundedRectangle(cornerRadius: 0).fill(.surface.opacity(0.3)))
            .overlay(RoundedRectangle(cornerRadius: 0).fill(.border).frame(height: 1),
                     alignment: .top)          // hairline along the inner edge
    }

The band is laid over the content at full width and the given height, pinned to edge. Everything else is yours.

Lists

Windowed: only the visible rows are built, however long the data is.

List { Text("One"); Text("Two") }              // static rows

List(items, id: \.id) { item in Row(item) }    // keyed
List(items) { item in Row(item) }              // Identifiable

Styling:

List(items) { … }
    .listStyle(.inset)              // .inset (default) | .plain
    .listRowSeparator(false)
    .listRowInsets(EdgeInsets(top: 8, bottom: 8, leading: 16, trailing: 16))

Differs: rows size themselves. Nothing has to be told a row height — a row that has been on screen is measured, one that hasn't is estimated until it is.

Controls

@State private var name = ""

TextField("Your name", text: $name)
TextField("Search", text: $query, font: .title3) { runSearch() }   // onSubmit

TextField is the whole input path in one view — focus on tap, committed text, IME preedit drawn underlined at the caret, backspace/delete, arrows, home/end, enter (onSubmit), escape to resign focus. It exists partly so there is one thing to drop into an app to find out whether typing works on a platform at all; the three hosts each reach the framework through a different file.

Differs: tapping puts the caret at the end rather than at the character you tapped. Glyph positions live in the renderer and nothing reports them back, so there is nothing to hit-test against yet.

For an editor — anything that wants raw keys rather than a field — use .textInput(onInsert:onKey:) directly. See Text input.

@State private var wifi = true

Toggle("Wi-Fi", isOn: $wifi)
Toggle(isOn: $wifi)                    // the switch on its own
Toggle(isOn: $wifi) { Label() }        // any view as the label

With a label the row fills the width and the switch sits at the trailing edge, and the whole row is the tap target. Holding the switch stretches the knob toward the side it would travel to; letting go springs it across, with the track's colour following a little behind.

@State private var volume: Float = 0.4
@State private var rating: Float = 3

Slider(value: $volume)
Slider(value: $rating, in: 1...5, step: 1)     // snaps, and shows ticks

A drag starts anywhere on the slider's row, not only on the knob, and the value follows where the finger is rather than how far it has moved — so a press at the far end jumps straight there. Holding it thickens the track and grows the knob. A step snaps the value and draws a tick per stop, up to 40 of them; past that the ticks are dropped, since they would read as a solid line.

Differs: the knob does not keep its grab offset. Grabbing it a few points off centre and dragging moves the value to the finger, not by the distance dragged.

Button("Save") { save() }
Button { save() } label: { Label() }

ButtonStyle for custom looks:

public protocol ButtonStyle {
    associatedtype Body: View
    func makeBody(configuration: ButtonStyleConfiguration) -> Body
}

ButtonStyleConfiguration carries label: AnyView and isPressed: Bool.

Shapes

RoundedRectangle(cornerRadius:), Circle(), Capsule(). Each takes:

.fill(_ color: Color)         -> FilledShape
.fill(_ material: Material)   -> MaterialFilledShape
.stroke(_ color: Color, lineWidth: Float = 1)

A cornerRadius of -1 means fully rounded (the shader resolves it to min(width, height) / 2 after layout).

Empty states

ContentUnavailableView("No results", icon: .magnifyingGlass,
                       description: "Try a different search")

ContentUnavailableView(icon: .tray, description: "Nothing here yet") {
    Button("Add one") { add() }
}

ContentUnavailableView.search                  // the stock search case
ContentUnavailableView.search(text: query)

Differs: an empty List renders an empty list. Showing this instead is an explicit if items.isEmpty, not automatic.


Modifiers

Layout modifiers

.frame(width: Float? = nil, height: Float? = nil,
       minWidth: Float? = nil, maxWidth: Float? = nil,
       minHeight: Float? = nil, maxHeight: Float? = nil,
       alignment: Alignment? = nil)

.padding()                          // 16 on all edges
.padding(_ all: Float)
.padding(_ edges: Edge, _ value: Float = 20)
.padding(_ insets: EdgeInsets)

.offset(x: Float = 0, y: Float = 0)  // render-time; siblings don't reflow
.weight(_ weight: Float = 1)
.expands()

Arguments must be written in declaration order — width, height, minWidth, maxWidth, minHeight, maxHeight, alignment. .frame(maxWidth: .infinity, height: 44) is a compile error; .frame(height: 44, maxWidth: .infinity) is what you meant. A lint in the Rust test suite catches this.

Sizing is per axis. Each axis of .frame() answers one of three questions independently — was a size named, was .infinity named, or was nothing named (in which case the axis inherits Fill from the content, and hugs it otherwise). So:

.frame(maxWidth: .infinity)              // full width, own height
.frame(width: 74)                        // 74 wide, own height
.frame(width: 100, maxHeight: .infinity) // both take effect
.frame(height: 0)                        // really zero, not "unstated"

.weight() vs .expands() vs .frame(maxWidth: .infinity) — these are three different things and the difference bites:

  • .frame(maxWidth: .infinity) wraps the view in a filling box. The view inside is unchanged, so a wrapped VStack is still hugging, and weights inside it are dropped.
  • .expands() sets Fill on the view's own node — no wrapper. This is what a container needs so its children have leftover space to divide.
  • .weight(_:) takes a share of a stack's leftover main-axis space. Fill means all of the space; weight means a share of it. Two filling children in one stack both take the whole thing and the second lands off the end of the first.
VStack {
    Editor().weight(1)      // gets everything the status bar leaves
    StatusBar()
}
.expands()                  // ← not .frame(maxHeight: .infinity)

Spacer() is weight 1 in the same pool, so A.weight(1) beside a Spacer() splits in half. Weight is ignored in a hugging container — "a share of the leftover" is meaningless when the container is defined as the sum of its children.

Appearance modifiers

.background<V: View>(_ background: V)
.overlay<V: View>(_ overlay: V, alignment: Alignment = .center)
.stroke(_ color: Color, lineWidth: Float = 1)
.cornerRadius(_ radius: Float)
.clipShape<S: View>(_ shape: S)
.opacity(_ value: Float)
.scale(_ value: Float)
.blur(radius: Float)
.shadow(radius: Float, opacity: Float = 0.25)
.specular()                        // opt-in Liquid-Glass edge trim
.mergeable(_ allowed: Bool)        // opt out of the metaball merge
.font(_:) .fontWeight(_:) .fontDesign(_:) .foregroundColor(_:)

.overlay() is .background() the other way up: the decoration is measured against the view rather than the view against it, so the view's own size is what the pair reports. A shape fills it exactly (shapes size Fill), and anything that hugs — a badge, a count — sits inside it at alignment.

.stroke() lines the inside of the view's edge, following whatever corner radius the view already carries:

RoundedRectangle(cornerRadius: 12).fill(.surface).stroke(.accent, lineWidth: 2)

Row().background(.surface).cornerRadius(12).stroke(.border)

Because the line is drawn inside the bounds it never grows the view or bleeds into a neighbour, and .cornerRadius() before or after .stroke() is the same thing — both write the same field.

The radius it follows is the one on this view, not one on a shape behind it. .background(RoundedRectangle(cornerRadius: 12).fill(.surface)) leaves the radius on the background child, so a .stroke() after it comes out square. Either round the view itself, as above, or overlay the shape you actually want outlined:

Avatar().overlay(Circle().stroke(.accent, lineWidth: 2))

Differs: .padding(), .frame(), .background() and .overlay() build a real node; most of the rest write a field on the existing one. So .padding(20).frame(width: 200) is 200 wide overall and .frame(width: 200).padding(20) is 240 — but .padding(10).cornerRadius(4) and .cornerRadius(4).padding(10) are the same tree, where SwiftUI's differ.

Differs: neither .overlay() nor .background() clips. A decoration larger than what it decorates still draws — see .clipShape() below.

Differs: clipping is opt-in and does not clip. .clipShape() rounds coextensive children; a child that overflows its parent still draws. See ARCHITECTURE.md.

Materials and glass

Material.ultraThinMaterial   // blur 3
Material.thinMaterial        // blur 5
Material.regularMaterial     // blur 7
Material.thickMaterial       // blur 9
Material.ultraThickMaterial  // blur 11

material.progressive(toward: ProgressiveEdge = .bottom, start: Float = 0.5)
.progressiveBlur(
    _ material: Material = .regularMaterial,
    toward edge: ProgressiveEdge = .bottom,
    start: Float = 0.5
)

ProgressiveEdge is the edge the blur fades out toward: .bottom for what sits under a navigation bar, .top for a tab bar or a bottom toolbar. start is how far along the shape the ramp begins, measured from the other edge.

The blur itself thins as it ramps, walking down from the heavy blur to the screen as it stands, rather than a full blur fading its opacity out. At the quiet end the material is an exact copy of what is behind it, so it leaves no seam.

Glass — a material that also refracts:

Glass.regular
Glass.clear
glass.tint(_ color: Color)
glass.interactive(_ enabled: Bool = true)   // press response

.glassEffect(_ glass: Glass = .regular)
.glassEffect(_ glass: Glass = .regular, in shape: S)

Gestures

.onTap(count: Int = 1, perform: @escaping (Point) -> Void)

.gesture(DragGesture(axis: Axis))
.gesture(LongPressGesture())
DragGesture(
    axis: Axis = .all,
    minimumDistance: Float = 8,
    edge: Edge? = nil,            // start only within `edgeWidth` of an edge
    edgeWidth: Float = 20,
    yieldsToScroll: Bool = false  // a scroll that can move wins the press
)

yieldsToScroll is for a gesture that covers a whole screen, where the scroll under the finger is the more specific thing. It gives way only to a scroll that can actually move the way the finger is going, so a shelf already at its end passes the drag through.

DragGesture(...)
    .onChanged { (v: DragValue) in … }
    .onEnded   { (v: DragValue) in … }

LongPressGesture().onEnded { … }

DragValue carries location, startLocation, translation, velocity (all Point).

Differs: a DragGesture(axis: .horizontal) inside a vertical ScrollView arbitrates by first movement — a drag that starts vertically stays a scroll for its whole life, and vice versa. No cancellation mid-gesture.

Text input

.textInput(
    isFocused: Bool = true,
    onInsert: @escaping (String) -> Void,
    onKey: @escaping (Key, KeyModifiers) -> Bool = { _, _ in false }
)

onKey returns true to consume. Key is a UInt32 enum of physical keys; KeyModifiers is an option set of .shift, .control, .option, .command.

Swipe actions

Not confined to List — any view inside a scrollable container.

.swipeActions(edge: SwipeEdge = .trailing) {
    SwipeAction(icon: .trash, tint: .destructive, role: .destructive) {
        delete()
    }
    SwipeAction("Flag", icon: .flag, tint: .orange) { flag() }
}

SwipeEdge is .leading / .trailing. Role is .normal / .destructive.


Navigation

NavigationStack { RootView() }

NavigationLink(SomeDestination()) { Text("Go") }

Navigator drives it imperatively, from anywhere — no binding, no path type, no destination closure:

Navigator.push(_ view: any View) -> UInt64
Navigator.pop()
Navigator.pop(to id: UInt64)
Navigator.popToRoot()
Navigator.setPath(_ views: [any View])

Navigator.present(_ view: any View,
                  as style: NavPresentation = .sheet,
                  morphingFrom morphSource: String? = nil) -> UInt64
Navigator.dismiss()
Navigator.dismiss<Value: Sendable>(returning value: Value)

Navigator.stack(_ tab: Int) -> NavigationStackModel
Navigator.select(_ tab: Int, then: ((NavigationStackModel) -> Void)? = nil)

NavPresentation is .push / .sheet / .cover. Differs: all three are one call with one enum, and the stack that owns the push owns the modal.

Titles and chrome:

.navigationTitle(_ title: String)
.navigationBarTitleDisplayMode(.large)   // .automatic | .large | .inline

The morphing sheet. Mark a source and the sheet grows out of it — no other API:

Button("Compose") { Navigator.present(Composer(), morphingFrom: "compose") }
    .matchedTransitionSource(id: "compose")

Presenting with no morphingFrom, or from a source that isn't on screen, falls back to the ordinary rise-from-the-bottom sheet.


Tabs

TabView {
    Tab("Home", Icon.house) { HomeView() }
    Tab("Search", Icon.magnifyingGlass, role: .search) { SearchView() }
    Tab("Profile", Icon.user, onReselect: .popToRoot) { ProfileView() }
}
Tab(_ title: String, _ icon: Icon,
    role: TabRole? = nil,
    onReselect: TabReselect? = nil,
    @ViewBuilder content: @escaping () -> Content)

tab.badge(_ count: Int)

TabReselect is .popToRoot, .scrollToTop, or .run(() -> Void).

TabView's own configuration, all defaulted:

TabView(
    background: Material? = …,
    navigationBackground: Material? = …,
    transition: NavTransition = .standard,
    screenBackground: Color = .background,
    interactiveBack: Bool = true
) { … }

Differs: each tab is wrapped in its own NavigationStack automatically. Don't add one.

interactiveBack reads a horizontal drag anywhere on the screen, not only near the leading edge. A horizontal ScrollView under the finger keeps the press while it still has somewhere to scroll, and a view with its own drag gesture — a row with swipe actions, say — wins outright, being deeper in the hit path.


Toolbars

.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        Button("Done") { done() }
    }
}

ToolbarItemPlacement: .automatic, .topBarLeading, .topBarTrailing, .bottomBar (see the enum for the full set).


Animation and transitions

withAnimation(_ animation: Animation = .default) { … }
withoutAnimation { … }

.animation(_ animation: Animation = .default)
.animation(_ animation: Animation = .default, id: UInt32)
.animation(_ animation: Animation = .default, value: someEquatable)

Curves:

Animation.default                  // .easeInOut()
Animation.linear(duration: Double = 0.3)
Animation.easeIn(duration:) / .easeOut(duration:) / .easeInOut(duration:)
Animation.spring(response: Double = 0.4, dampingFraction: Double = 0.8)

Transitions, for insertion and removal inside a ForEach:

.transition(_ transition: Transition)
.contentTransition(_ transition: Transition, animation: Animation? = nil)

Transition.identity
Transition.opacity
Transition.slide
Transition.scale(_ factor: Float = 0.92)
Transition.offset(x: Float = 0, y: Float = 0)
Transition.blurReplace / .blurReplace(radius:)
Transition.sheet / .cover
Transition.morph(from: SFRect, to: SFRect)

a.combined(with: b)
Transition.asymmetric(insertion:removal:)

Differs: animation is keyed by call site (#fileID/#line/ #column). If a modifier moves between builds and you need the animation to follow, use the id: form.


State

@State private var count = 0            // value state, survives the rebuild
@Binding var text: String               // two-way handle

Binding(get: { … }, set: { … })

Reference state uses the observation runtime:

@Observable
final class Store {
    var items: [Item] = []
}

struct ContentView: View {
    @Observed var store = Store()       // ← not @State
    …
}

@Observable is a macro; the underlying protocol is protocol Observable: AnyObject. Any Observable can hand out a binding to one of its own properties:

store.binding(to: \.query)              // -> Binding<String>

Differs: the whole tree is rebuilt every frame, so state lives in registries that outlive the rebuild rather than in the view values. Nothing needs @StateObject / @ObservedObject / @EnvironmentObject; there is one spelling.


Environment

struct ThemeKey: EnvironmentKey {
    static let defaultValue = Theme.dark
}

extension EnvironmentValues {
    var theme: Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

// write
ContentView().environment(\.theme, .light)

// read
@Environment(\.theme) private var theme

ColorScheme (.light / .dark) is provided.


Types

Color

Color(r:g:b:a:)  Color(hex: 0xRRGGBB, alpha: Float = 1)
color.opacity(_ value: Float)

One fixed dark theme, tuned rather than sRGB primaries. Two groups, and the split is the useful part — roles say what a thing is (reach for these), named colours say what it looks like.

Roles: background, surface, overlay, fill, primary, secondary, tertiary, placeholder, separator, border, accent, scrim, destructive, success, warning, clear, black, white.

Named: red, orange, yellow, green, mint, teal, cyan, blue, indigo, purple, pink, brown, gray.

yellow will not carry white text at any lightness where it still reads as yellow — put .background on it instead.

Font

Font(size: Float, weight: Weight = .regular, design: Design = .default)
Font.system(size:weight:design:)

Presets: largeTitle 34, title 28, title2 22, title3 20, headline 17 semibold, body 17, callout 16, subheadline 15, footnote 13, caption 12, caption2 11.

Font.Weight maps 1:1 onto the variable-font wght axis (ultraLight 100 … black 900).

Font.Design: .default (Inter) and .monospaced (JetBrains Mono) are bundled. .serif and .rounded fall back to .default.

Icon and Image

Icon is a single glyph from the bundled Phosphor faces, sized to its em box rather than its ink — so a row of icons shares one size and one baseline. The catalogue is generated; reach for Icon.<name>.

Icon.Weight picks the face (thin … fill), not a variable axis.

Image sources: Image("assetName") from the flattened catalogue, Image.system(_:), or AsyncImage(url:). ContentMode is .fit, .fill, .stretch.

AsyncImagePhase is .empty / .success(Image) / .failure.

Differs: without .resizable() an image is intrinsic — it reserves its asset's own size. That matches SwiftUI.

Alignment, Edge, EdgeInsets

HorizontalAlignment  .leading .center .trailing
VerticalAlignment    .top .center .bottom
Alignment            .topLeading .top .topTrailing
                     .leading .center .trailing
                     .bottomLeading .bottom .bottomTrailing

Edge                 .top .bottom .leading .trailing
                     .horizontal .vertical .all      // OptionSet

EdgeInsets(top:bottom:leading:trailing:)
EdgeInsets(_ all: Float)
EdgeInsets.zero

Axis                 .vertical .horizontal
Point                Point(x:y:)

Device and screen

Screen.width / Screen.height / Screen.isKnown     // points
SafeArea.top / .bottom / .leading / .trailing
DeviceScale.current                               // points -> pixels
DeviceMetrics

NodeFrames reads back where a node actually landed after layout — what matchedTransitionSource and List are built on:

NodeFrames.shared.frame(for id: UInt32) -> SFRect?
NodeFrames.id(for name: String) -> UInt32

A frame is one frame old, and a missing id reports nil rather than zero-rect nonsense.


Lifecycle

protocol LifecycleProvider

view.onForeground { … }
view.onBackground { … }
view.onTerminate  { … }
view.onOpenURL    { (url: URL) in … }

Logging

Log.print(_ items: Any...)

Routes to the platform's own log — __android_log_write on Android, stderr elsewhere — so it survives on a device where print does not.


Things SwiftUI has that this does not

Worth knowing before you reach for them:

  • No GeometryReader. ScrollGeometry covers the scrolling case; NodeFrames covers reading a frame back after layout.
  • No @StateObject / @ObservedObject / @EnvironmentObject — one spelling, @Observed.
  • No PreferenceKey, no alignmentGuide, no Layout protocol.
  • No LazyVStack / LazyHStack — List is the windowed container.
  • No NavigationSplitView, no multi-column.
  • No .animation(_:) without a value on a leaf that changes identity — see the note on call-site keying.
  • Real clipping. .clipShape() is exact for coextensive boxes only.