Skip to content

Swift / SwiftUI — UI Patterns

OWNER: martijn, valentin ALSO_USED_BY: alexander (design integration) LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: Swift 6.1 / SwiftUI (iOS 26 SDK)


Overview

GE builds adaptive, accessible iOS interfaces with SwiftUI. This page covers custom components, Liquid Glass (iOS 26), animations, gestures, accessibility, and layout. Alexander (design) defines the visual spec — martijn and valentin enforce implementation patterns.

Liquid Glass (iOS 26)

Liquid Glass is Apple's new design language introduced at WWDC 2025. It uses translucent materials with real-time light refraction. Apps compiled with Xcode 26 get automatic Liquid Glass on standard components.

Automatic Adoption (Free)

Recompiling with the iOS 26 SDK applies Liquid Glass automatically to:

  • NavigationBar
  • TabBar
  • Toolbar
  • Sheets (partial height — inset with glass background)
  • Popovers, Menus, Alerts
  • Search bars
  • Toggles, Sliders, Pickers (during interaction)

CHECK: Agent is building a new view. IF: View uses standard SwiftUI navigation components. THEN: No extra work needed — Liquid Glass is automatic after recompile.

Custom Glass Effects

// Apply glass to a custom floating control
Button("Add Item", systemImage: "plus") {
    addItem()
}
.glassEffect()

// Group glass elements that should merge visually
GlassEffectContainer(spacing: 8) {
    Button("Edit") { edit() }
        .glassEffect()
    Button("Share") { share() }
        .glassEffect()
}

ANTI_PATTERN: Applying .glassEffect() to list items or content cards. FIX: Liquid Glass is for the navigation layer — floating controls, toolbars, action buttons. Content sits beneath glass, never inside it.

ANTI_PATTERN: Building custom blur effects to mimic Liquid Glass. FIX: Use .glassEffect(). Custom blur is not Liquid Glass — it lacks lensing, specular highlights, and motion response.

Liquid Glass Design Principles

  • Glass = navigation, not content. Content is the background that glass refracts.
  • Less is more. One or two glass surfaces per screen. Too many glass elements create visual noise.
  • Respect hierarchy. Glass creates depth — use it to separate control layers from content layers.
  • Test on device. Simulator does not accurately render lensing and motion effects.

Tab Bar Behavior (iOS 26)

Tab bars shrink when users scroll down (focus on content) and expand on scroll-up. This is automatic with TabView. Do NOT implement custom scroll-to-hide logic.

// iOS 26 TabView — glass and shrink behavior are automatic
TabView {
    Tab("Home", systemImage: "house") {
        NavigationStack { HomeView() }
    }
    Tab("Orders", systemImage: "list.bullet") {
        NavigationStack { OrderListView() }
    }
    Tab("Settings", systemImage: "gear") {
        NavigationStack { SettingsView() }
    }
}

Custom Components

GE Component Conventions

  • Every reusable component lives in Core/Components/.
  • Components are pure views — no side effects, no network calls.
  • Components accept data via parameters, not environment values.
  • Every component has a preview with multiple states.
struct StatusBadge: View {
    let status: OrderStatus

    var body: some View {
        Text(status.displayName)
            .font(.caption)
            .fontWeight(.semibold)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(status.color.opacity(0.15))
            .foregroundStyle(status.color)
            .clipShape(Capsule())
    }
}

#Preview {
    VStack(spacing: 12) {
        StatusBadge(status: .draft)
        StatusBadge(status: .active)
        StatusBadge(status: .completed)
        StatusBadge(status: .cancelled)
    }
}

ViewModifier for Consistent Styling

struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.background.secondary)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.05), radius: 4, y: 2)
    }
}

extension View {
    func cardStyle() -> some View {
        modifier(CardModifier())
    }
}

CHECK: Agent is adding styling to a view. IF: The same styling pattern appears in 3+ views. THEN: Extract to a ViewModifier in Core/Components/.

Animations

Implicit Animations (Preferred)

struct ExpandableCard: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Text("Order #1234")
            if isExpanded {
                Text("Details here...")
                    .transition(.opacity.combined(with: .move(edge: .top)))
            }
        }
        .animation(.easeInOut(duration: 0.3), value: isExpanded)
        .onTapGesture { isExpanded.toggle() }
    }
}

ANTI_PATTERN: Using withAnimation for every state change. FIX: Prefer .animation(_:value:) modifier for targeted animations. Use withAnimation only when you need to animate multiple unrelated state changes simultaneously.

Spring Animations (Default for Interactions)

// GE default spring for interactive elements
.animation(.spring(duration: 0.4, bounce: 0.2), value: someState)

// Snappy spring for button feedback
.animation(.spring(duration: 0.25, bounce: 0.3), value: isPressed)

Phase Animator (Complex Sequences)

struct PulsingIndicator: View {
    var body: some View {
        Circle()
            .fill(.blue)
            .frame(width: 12, height: 12)
            .phaseAnimator([false, true]) { view, phase in
                view
                    .scaleEffect(phase ? 1.2 : 1.0)
                    .opacity(phase ? 0.7 : 1.0)
            } animation: { _ in
                .easeInOut(duration: 1.0)
            }
    }
}

Gesture Handling

Standard Gestures

// Swipe to delete (built-in)
List {
    ForEach(items) { item in
        ItemRow(item: item)
    }
    .onDelete { indexSet in
        deleteItems(at: indexSet)
    }
}

// Long press
Text("Hold me")
    .onLongPressGesture(minimumDuration: 0.5) {
        showContextMenu = true
    }

// Drag gesture with state
@GestureState private var dragOffset: CGSize = .zero

Rectangle()
    .offset(dragOffset)
    .gesture(
        DragGesture()
            .updating($dragOffset) { value, state, _ in
                state = value.translation
            }
    )

CHECK: Agent is implementing a custom gesture. IF: Gesture conflicts with system gestures (edge swipe for back, pull-to-refresh). THEN: Use .simultaneousGesture() or .highPriorityGesture() to manage priority. Never override system navigation gestures.

Accessibility (VoiceOver)

Mandatory for All GE Apps

Every interactive element MUST have an accessibility label. Every image MUST be either labeled or marked as decorative.

// Interactive element
Button {
    toggleFavorite()
} label: {
    Image(systemName: isFavorite ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")

// Decorative image (no label needed)
Image("decorative-banner")
    .accessibilityHidden(true)

// Informational image
Image(systemName: "checkmark.circle.fill")
    .accessibilityLabel("Completed")

Grouping and Traits

HStack {
    Text(order.title)
    Spacer()
    StatusBadge(status: order.status)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(order.title), status: \(order.status.displayName)")

// Custom actions
.accessibilityAction(named: "Delete order") {
    deleteOrder(order)
}

ANTI_PATTERN: Accessibility labels that describe the control type ("button", "image"). FIX: Describe the action or content. VoiceOver announces the control type automatically.

Dynamic Type Support

// CORRECT — uses system text styles that scale
Text(order.title)
    .font(.headline)

Text(order.description)
    .font(.body)

// WRONG — fixed sizes do not scale with Dynamic Type
Text(order.title)
    .font(.system(size: 17))

CHECK: Agent is setting a font size. IF: Font uses a fixed point size (.system(size:)). THEN: Replace with a semantic text style (.headline, .body, .caption, etc.).

Adaptive Layouts

Device-Adaptive Views

struct OrderListView: View {
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        if sizeClass == .compact {
            // iPhone — single column
            List(orders) { order in
                OrderRow(order: order)
            }
        } else {
            // iPad — master-detail
            NavigationSplitView {
                List(orders, selection: $selectedOrder) { order in
                    OrderRow(order: order)
                }
            } detail: {
                if let order = selectedOrder {
                    OrderDetailView(order: order)
                } else {
                    ContentUnavailableView(
                        "Select an Order",
                        systemImage: "list.bullet"
                    )
                }
            }
        }
    }
}

Safe Area and Keyboard Avoidance

SwiftUI handles keyboard avoidance automatically for TextField inside ScrollView. For custom layouts, use .safeAreaInset(edge:).

VStack {
    ScrollView {
        // Content
    }
    .safeAreaInset(edge: .bottom) {
        HStack {
            TextField("Message", text: $message)
            Button("Send") { send() }
        }
        .padding()
        .background(.bar)
    }
}

GE-Specific Conventions

  • Liquid Glass for navigation — Do not apply to content elements.
  • Accessibility is not optional — Every PR must pass VoiceOver audit.
  • Dynamic Type support required — No fixed font sizes.
  • Spring animations as default — Use easeInOut only for opacity transitions.
  • Preview every component — Multiple states in #Preview.
  • iPad support from day one — Use NavigationSplitView for master-detail.

Cross-References

READ_ALSO: wiki/docs/stack/swift-swiftui/index.md READ_ALSO: wiki/docs/stack/swift-swiftui/architecture.md READ_ALSO: wiki/docs/stack/swift-swiftui/pitfalls.md READ_ALSO: wiki/docs/stack/swift-swiftui/checklist.md