swiftcnearly preview

Native composition

Style native SwiftUI controls, sheets, navigation, and split views with editable recipes.

Use SwiftUI to own interaction and state. Use swiftcn to supply appearance and composable content parts. These complete examples run in the gallery's Native composition section and compile with package imports or copied source.

Bindings, format styles, focus, navigation, and dismissal remain typed Swift APIs. Classes describe appearance and supported motion. All paths share TWGlobalRules.named, TWTheme, and the existing .tw engine.

Native control styles

Use native Button, TextField, Toggle, and Label with our style adapters. Formatting, enum-based focus, roles, keyboard shortcuts, and submission remain SwiftUI APIs. Pass explicit focus state to the style for focus appearance. CNFieldControl decorates native controls that do not have a matching style protocol. Convenience components share the same recipes and styling engine. Change CNField.axis to move the label above or beside the same control group. AnyLayout preserves the children; .tw("feedback-motion", value: horizontal) supplies motion. Group the editor and supporting text so they remain together in both orientations.

NativeStylesExample.swift
import SwiftUI
import SwiftCN

struct NativeStylesExample: View {
    private enum Field: Hashable { case amount }
    @State private var amount = 12.5
    @State private var notifications = true
    @State private var disabled = false
    @State private var compact = false
    @State private var horizontal = false
    @State private var saves = 0
    @FocusState private var focusedField: Field?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            CNField(spacing: 12, axis: horizontal ? .horizontal : .vertical) {
                Label("Amount", systemImage: "number")
                    .labelStyle(.tw("field-label", base: .titleAndIcon))
                VStack(alignment: .leading, spacing: 6) {
                    TextField("Amount", value: $amount, format: .number)
                        .textFieldStyle(.tw("input", state: .init(isFocused: focusedField == .amount)))
                        .focused($focusedField, equals: .amount)
                        .onSubmit(save)
                        .disabled(disabled)
                    CNFieldDescription("Formatting, selection, and submit handling stay native.")
                }
            }.tw("feedback-motion", value: horizontal)
            Toggle("Notifications", isOn: $notifications)
                .toggleStyle(.tw("switch", base: .switch))
                .disabled(disabled)
            CNButtonGroup {
                Button("Save", action: save)
                    .buttonStyle(.tw("button-primary"))
                    .keyboardShortcut("s", modifiers: .command)
                Button("Edit amount") { focusedField = .amount }
                    .buttonStyle(.tw("button-outline"))
            }.disabled(disabled)
            Text("Saved \(saves) times · Amount: \(amount.formatted(.number))")
                .tw("text-sm text-mutedForeground")
            CNCheckbox("Disable controls", isOn: $disabled)
            CNCheckbox("Compact input recipe", isOn: $compact)
            CNCheckbox("Label beside the input", isOn: $horizontal)
        }
        .twRules {
            if compact {
                $0.named["input"] = "px-3 py-1 min-h-[44] border rounded-lg bg-surface focus:border-primary disabled:opacity-45"
            }
        }
    }
    private func save() { saves += 1; focusedField = nil }
}

Native drawer content

CNDrawerContent applies the drawer recipe to a vertical stack. It adds no scrolling, fixed size, or presentation host. Use it inside your own sheet to control detents, drag indicators, interactive dismissal, appearance, and callbacks. Keep the selected detent in the supported set. Detents follow platform behavior; macOS sheets use native window sizing. CNDrawer supplies scrolling, appearance, and nested hosts, with typed detents, selection, and onDismiss arguments. A caller-owned sheet also owns nested hosts and dismissal scope. Use the sheet binding or a native dismiss action to close it; clear cnPresentationDismiss if you reuse CNDialogClose inside a native sheet nested in a custom dialog.

NativeDrawerExample.swift
import SwiftUI
import SwiftCN

struct NativeDrawerExample: View {
    @State private var nativeOpen = false
    @State private var convenienceOpen = false
    @State private var detent = PresentationDetent.height(260)
    @State private var preventDismissal = false
    @State private var dismissals = 0
    @Environment(\.twTheme) private var theme
    @Environment(\.colorScheme) private var scheme

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Button("Open caller-owned sheet") { nativeOpen = true }
                .buttonStyle(.tw("button-outline"))
                .sheet(isPresented: $nativeOpen, onDismiss: didDismiss) {
                    ScrollView {
                        CNDrawerContent {
                            CNDialogTitle("Activity")
                            Text("The content uses a recipe. This sheet owns its configuration.")
                                .tw("text-sm text-mutedForeground")
                            Toggle("Prevent swipe dismissal", isOn: $preventDismissal)
                                .toggleStyle(.tw("switch", base: .switch))
                            Button("Expand") { withAnimation { detent = .large } }
                                .buttonStyle(.tw("button-secondary"))
                            Button("Done") { nativeOpen = false }
                                .buttonStyle(.tw("button-outline"))
                        }
                    }
                    .presentationDetents([.height(260), .large], selection: $detent)
                    .presentationDragIndicator(.visible)
                    .interactiveDismissDisabled(preventDismissal)
                    .presentationBackground(theme.color(.surface, scheme: scheme))
                    .preferredColorScheme(scheme)
                    #if os(macOS)
                        .frame(minWidth: 360, minHeight: 320)
                    #endif
                }
            CNDrawer(isPresented: $convenienceOpen, detents: [.height(260), .large],
                     selection: $detent, onDismiss: didDismiss) {
                CNDialogTitle("Convenience drawer")
                Text("The same content part, with native scrolling and presentation defaults.")
                    .tw("text-sm text-mutedForeground")
                Button("Expand") { withAnimation { detent = .large } }
                    .buttonStyle(.tw("button-secondary"))
                CNDialogClose()
            } label: { Text("Open convenience drawer") }
            Text("Dismissed \(dismissals) times").tw("text-sm text-mutedForeground")
        }
    }
    private func didDismiss() { dismissals += 1 }
}

Adaptive surfaces and Liquid Glass

State the role and let swiftcn choose the native appearance. surface-floating uses Liquid Glass on iOS 26 and macOS 26, a material with a themed edge on earlier systems, and a solid themed surface under Reduce Transparency. surface-solid always uses the theme. glass asks for glass explicitly and uses the same fallbacks. Each surface resolves to one fill, so bg-* selects the solid color and never stacks under glass or a material. .twAppearance(.automatic | .glass | .solid) sets the policy for a subtree; local classes still win.

.buttonStyle(.tw("glass")) lets Apple draw the Button: it selects .glass or .glassProminent where available, or .bordered and .borderedProminent before. Its classes add spacing and typography to the label, not another background. TWSurfaceGroup wraps GlassEffectContainer and twSurfaceID wraps glassEffectID, so grouping and morphing stay explicit. twPresentationSurface keeps the system sheet where it uses glass and applies the theme surface elsewhere.

Accessibility and policy changes alter values inside a fixed view structure, so editors, bindings, and focus remain. Keep a view's surface role constant; changing between a surface class and none rebuilds the content. glass-interactive enables Apple's touch and pointer feedback only. Classes never install gestures; custom drag and scroll handoff remains component code.

NativeSurfacesExample.swift
import SwiftUI
import SwiftCN

struct NativeSurfacesExample: View {
    private enum Tool: String, CaseIterable { case pencil, highlighter, eraser }
    @State private var appearance = TWAppearance.automatic
    @State private var tool = Tool.pencil
    @State private var expanded = false
    @State private var shares = 0
    @State private var sheetOpen = false
    @State private var note = ""
    @Namespace private var surfaces

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Picker("Appearance", selection: $appearance) {
                Text("Automatic").tag(TWAppearance.automatic)
                Text("Glass").tag(TWAppearance.glass)
                Text("Solid").tag(TWAppearance.solid)
            }.pickerStyle(.segmented)
            HStack(spacing: 12) {
                Button("Share") { shares += 1 }
                    .buttonStyle(.tw("glass px-1"))
                Button("Open note") { sheetOpen = true }
                    .buttonStyle(.tw("glass-prominent px-2 font-semibold"))
            }
            TWSurfaceGroup(spacing: 12) {
                HStack(spacing: 12) {
                    HStack(spacing: 4) {
                        ForEach(Tool.allCases, id: \.self) { item in
                            Button { tool = item } label: {
                                Image(systemName: item.rawValue).tw("p-2")
                                    .foregroundStyle(tool == item ? Color.accentColor : .primary)
                            }
                            .buttonStyle(.plain)
                            .accessibilityLabel(item.rawValue.capitalized)
                            .accessibilityAddTraits(tool == item ? .isSelected : [])
                        }
                        Button { withAnimation(.smooth) { expanded.toggle() } } label: {
                            Image(systemName: expanded ? "chevron.left" : "ellipsis").tw("p-2")
                        }
                        .buttonStyle(.plain)
                        .accessibilityLabel(expanded ? "Fewer tools" : "More tools")
                    }
                    .tw("px-2 py-1 surface-floating glass-interactive")
                    .twSurfaceID("tools", in: surfaces)
                    if expanded {
                        Button { tool = .pencil } label: { Image(systemName: "arrow.uturn.backward").tw("p-2") }
                            .buttonStyle(.plain)
                            .accessibilityLabel("Reset tool")
                            .tw("p-1 surface-floating glass-interactive")
                            .twSurfaceID("reset", in: surfaces)
                    }
                }
            }
            .tw("p-6 rounded-lg")
            .background(LinearGradient(colors: [.orange, .pink, .indigo], startPoint: .topLeading, endPoint: .bottomTrailing),
                        in: .rect(cornerRadius: 12))
            Text("Tool: \(tool.rawValue) · Shared \(shares) times").tw("text-sm text-mutedForeground")
        }
        .twAppearance(appearance)
        .sheet(isPresented: $sheetOpen) {
            ScrollView {
                CNDrawerContent {
                    CNDialogTitle("Note")
                    TextField("Write a note", text: $note)
                        .textFieldStyle(.tw("input"))
                    Button("Done") { sheetOpen = false }
                        .buttonStyle(.tw("glass-prominent"))
                }
            }
            .presentationDetents([.medium, .large])
            .presentationDragIndicator(.visible)
            .twPresentationSurface()
            .twAppearance(appearance)
            #if os(macOS)
                .frame(minWidth: 360, minHeight: 280)
            #endif
        }
    }
}

Native navigation

Use NavigationSplitView, List, and NavigationLink directly for app navigation. The sidebar-list and sidebar-item-label recipes supply appearance. SwiftUI owns column visibility, selected-row appearance, keyboard navigation, and compact navigation. Label parts preserve the native link; avoid putting action buttons inside a navigation link. Keep editor state in the caller. CNSidebar remains the separate icon-rail and offcanvas layout. The gallery opens this example as a complete navigation root. Set a flexible navigationSplitViewColumnWidth(min:ideal:max:) on each column to choose a useful resize range. The minimum detail width protects the editor. SwiftUI still controls compact presentation and sidebar visibility.

NativeNavigationExample.swift
import SwiftUI
import SwiftCN

struct NativeNavigationExample: View {
    private enum Destination: String, CaseIterable, Identifiable {
        case inbox = "Inbox", projects = "Projects", archive = "Archive"
        var id: Self { self }
    }
    @State private var selected: Destination? = .projects
    @State private var visibility: NavigationSplitViewVisibility = .all
    @State private var draft = "A draft stays in caller-owned state."

    var body: some View {
        NavigationSplitView(columnVisibility: $visibility) {
            List(Destination.allCases, selection: $selected) { destination in
                NavigationLink(value: destination) {
                    Label(destination.rawValue, systemImage: "folder")
                        .labelStyle(.tw("sidebar-item-label"))
                }
            }
            .listStyle(.sidebar)
            .scrollContentBackground(.hidden)
            .tw("sidebar-list")
            .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: .infinity)
            .navigationTitle("Workspace")
        } detail: {
            VStack(alignment: .leading, spacing: 16) {
                Text(selected?.rawValue ?? "Choose a destination").tw("text-xl font-semibold")
                CNFieldControl("textarea") {
                    TextEditor(text: $draft).scrollContentBackground(.hidden)
                        .accessibilityLabel("Project draft")
                }
                Text("SwiftUI owns column visibility, row selection, and compact navigation.")
                    .tw("text-sm text-mutedForeground")
            }
            .tw("p-6 bg-background")
            .navigationSplitViewColumnWidth(min: 300, ideal: 560, max: .infinity)
            .navigationTitle(selected?.rawValue ?? "Workspace")
        }
    }
}

Native split views

Use HSplitView or VSplitView on macOS and apply .tw to their panes. SwiftUI owns the dividers; native frames or sizing classes define minimum pane sizes. Native split views do not expose a SwiftUI fraction binding. CNResizable supplies that explicit binding and the mobile touch handle. Keep state outside the panes so resizing preserves native editors and controls. Give each desktop pane a flexible maximum width with w-full as well as a minimum width. A minimum-only frame can pin a pane to its intrinsic width. Use native frame alignment to keep the pane content leading-aligned while it grows.

NativeSplitExample.swift
import SwiftUI
import SwiftCN

struct NativeSplitExample: View {
    @State private var fraction = 0.4
    @State private var draft = "Resize the panes while keeping this draft."
    @State private var monospaced = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            #if os(macOS)
            HSplitView {
                inspector.tw("min-w-[160] w-full bg-surface")
                editor.tw("min-w-[220] w-full bg-surface")
            }.tw("border rounded-lg")
            #else
            CNResizable(fraction: $fraction, axis: .vertical, minimumFraction: 0.35) { inspector } second: { editor }
                .tw("border rounded-lg")
            #endif
            Text("Native desktop dividers; an explicit fraction and touch handle on mobile.")
                .tw("text-sm text-mutedForeground")
        }.frame(height: 420)
    }
    private var inspector: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Inspector").tw("text-lg font-semibold")
            Toggle("Monospaced font", isOn: $monospaced).toggleStyle(.tw("switch", base: .switch))
            Spacer()
        }.frame(maxWidth: .infinity, alignment: .leading).tw("p-4")
    }
    private var editor: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Editor").tw("text-lg font-semibold")
            TextEditor(text: $draft).scrollContentBackground(.hidden)
                .font(monospaced ? .system(.body, design: .monospaced) : .body)
                .accessibilityLabel("Split editor draft")
        }.frame(maxWidth: .infinity, alignment: .leading).tw("p-4")
    }
}

Native arrangements

Use ArrangementView on iOS 27.1 or macOS 27.1 for two related panes. SwiftUI chooses their placement around active divisions. Keep navigation outside the arrangement. Put each pane's scrolling content inside the arrangement. Keep shared state in the screen. Apply .tw to the native content. The example uses AnyLayout on older systems and opens as a complete screen in the gallery. Compile the new API with Xcode 27.1 or later. The package keeps its iOS 17 and macOS 14 deployment targets.

NativeArrangementExample.swift
import SwiftUI
import SwiftCN

/// Use this as a screen root. Keep navigation outside the native arrangement.
struct NativeArrangementExample: View {
    @State private var draft = "Edit this draft, then fold or resize the window."
    @State private var monospaced = false

    var body: some View {
        NavigationStack {
            GeometryReader { geometry in
                #if canImport(SwiftUI, _version: 8.0.85.27)
                if #available(iOS 27.1, macOS 27.1, *) {
                    ArrangementView {
                        editor
                    } secondary: {
                        inspector
                    }.arrangementViewStyle(.split)
                } else {
                    fallback(width: geometry.size.width)
                }
                #else
                fallback(width: geometry.size.width)
                #endif
            }.navigationTitle("Workspace")
        }
    }
    private func fallback(width: CGFloat) -> some View {
        let layout = width >= 640 ? AnyLayout(HStackLayout(spacing: 0)) : AnyLayout(VStackLayout(spacing: 0))
        return layout {
            editor
            inspector
        }
    }
    private var editor: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 12) {
                Text("Draft").tw("text-lg font-semibold")
                TextEditor(text: $draft)
                    .scrollContentBackground(.hidden)
                    .font(monospaced ? .system(.body, design: .monospaced) : .body)
                    .accessibilityLabel("Arrangement draft")
                    .tw("min-h-[240] p-3 border rounded-lg bg-surface")
            }.tw("p-6 w-full")
        }.scrollBounceBehavior(.basedOnSize).tw("w-full h-full bg-background")
    }
    private var inspector: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text("Inspector").tw("text-lg font-semibold")
                Toggle("Monospaced font", isOn: $monospaced).toggleStyle(.tw("switch", base: .switch))
                Text("SwiftUI places these panes around active folds. The editor state belongs to this screen.")
                    .tw("text-sm text-mutedForeground")
            }.tw("p-6 w-full")
        }.tw("w-full h-full bg-surface")
    }
}

Observable models and custom content

Own an @Observable model with @State. Use @Bindable in a child form to pass typed bindings to native controls and swiftcn components. The model controls validation and saving.

CNCommand accepts a query binding and generic itemContent and empty builders. Keep each item builder as a native button label; put additional actions outside that label.

CNToastHost.toastContent changes a card body while the host retains stacking, dismissal, and deadlines. context.action creates a native button that pauses the deadline during keyboard or accessibility focus. Custom controls can use context.focus and context.accessibilityFocus with a stable UUID. context.dismiss removes only the current toast value. Keep plain @State for local component values.

NativeModelExample.swift
import SwiftUI
import Observation
import SwiftCN

@MainActor @Observable final class WorkspaceFormModel {
    var name = "Studio"
    var notifications = true
    var category: String? = "team"
    var query = ""
    var toasts: [CNToast] = []
    var savedName = ""
    var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
    func save() {
        guard canSave else { return }
        savedName = name
        toasts.append(CNToast(title: "Workspace saved", message: name))
    }
}

struct NativeModelExample: View {
    @State private var model = WorkspaceFormModel()
    var body: some View { WorkspaceForm(model: model) }
}

private struct WorkspaceForm: View {
    @Bindable var model: WorkspaceFormModel
    @FocusState private var nameFocused: Bool
    private let categories = [CNOption("personal", title: "Personal", systemImage: "person"),
                              CNOption("team", title: "Team", systemImage: "person.2")]
    var body: some View {
        CNToastHost(toasts: $model.toasts) {
            VStack(alignment: .leading, spacing: 16) {
                CNField {
                    CNFieldLabel("Workspace name")
                    TextField("Name", text: $model.name)
                        .textFieldStyle(.tw("input", state: .init(isFocused: nameFocused)))
                        .focused($nameFocused).onSubmit(model.save)
                }
                Toggle("Notifications", isOn: $model.notifications).toggleStyle(.tw("switch", base: .switch))
                CNCommand(categories, selection: $model.category, query: $model.query, classes: "h-[180]")
                    .itemContent { option, selected in
                        HStack {
                            Label(option.title, systemImage: option.systemImage ?? "folder")
                            Spacer()
                            if selected { CNBadge("Selected") }
                        }
                    } empty: {
                        Label("No categories match your search.", systemImage: "magnifyingglass")
                            .tw("p-4 text-sm text-mutedForeground")
                    }
                Button("Save", action: model.save).buttonStyle(.tw("button-primary")).disabled(!model.canSave)
                Text(model.savedName.isEmpty ? "No saved changes" : "Saved: \(model.savedName)")
                    .tw("text-sm text-mutedForeground")
            }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        }
        .toastContent { context in
            VStack(alignment: .leading, spacing: 8) {
                Text(context.toast.title).tw("text-sm font-semibold")
                if let message = context.toast.message { Text(message).tw("text-sm text-mutedForeground") }
                context.action("Edit name") { nameFocused = true; context.dismiss() }
            }
        }
        .frame(minHeight: 460)
    }
}

Shared rules

Override a recipe for a subtree. Imported components and direct native adapters resolve the same rule:

.twRules {
    $0.named["input"] = "px-3 py-2 min-h-[44] border rounded-lg bg-surface focus:border-primary"
}

Use native .disabled, .focused, .onSubmit, and presentation modifiers for behavior. Supply style state for focus appearance. The controls keep their native identity while recipes change.

On this page