Skip to content

Cross Platform

Daniel S edited this page Jul 2, 2026 · 1 revision

Cross-Platform

DesignFoundation is designed to work identically on iOS 18+, macOS 15+, and visionOS 2+. This page explains how that works, where the seams are, and when you need a platform guard in your own code.

Related pages: Getting-Started · Theming · Navigation · Overlays-and-Feedback


DFPlatformContext

DFPlatformContext is an internal type that captures the current rendering environment — platform, size class, display scale, and appearance — and injects it alongside DFTheme when you apply .dfTheme(_:) or .dfThemePreset(_:).

You do not create DFPlatformContext directly. It is populated automatically.

Reading DFPlatformContext in Custom Views

@Environment(\.dfPlatformContext) private var platform

var body: some View {
    if platform.variant == .compact {
        CompactLayout()
    } else {
        ExpandedLayout()
    }
}

DFPlatformVariant

public enum DFPlatformVariant: Sendable {
    case automatic    // Let DF resolve the correct variant
    case compact      // iPhone / iPod touch / compact-width iPad split view
    case expanded     // iPad regular width / macOS
    case immersive    // visionOS full-space
}

DesignFoundation resolves the correct variant from @Environment(\.horizontalSizeClass) and @Environment(\.sizeCategory) internally. You read the resolved result via platform.variant.

Light and Dark Mode in Custom Views

DFPlatformContext also carries colorScheme. For custom views that need to branch on appearance, prefer reading @Environment(\.dfTheme) with pre-resolved color tokens — they already reflect the current color scheme. Fall back to platform.colorScheme only when you need to make a non-token decision:

@Environment(\.dfTheme) private var theme
@Environment(\.dfPlatformContext) private var platform

var body: some View {
    // Prefer this — tokens already resolve to the correct appearance
    Text("Hello").foregroundStyle(theme.colors.textPrimary)

    // Use this only for decisions tokens don't cover
    Image(platform.colorScheme == .dark ? "hero-dark" : "hero-light")
}

The Zero-Guards Promise

The following components work identically on iOS, macOS, and visionOS without any #if os() or #available guard in your call site:

Component Platform behavior
DFSidebar Sidebar on macOS / iPad regular; sheet/drawer on iPhone compact
DFTabBar Bottom tab bar on iPhone; compact tab strip on macOS
dfNavigationBar Toolbar + back-button on macOS; large/inline title on iOS
DFModal Center card on macOS; full/partial sheet on iOS
DFSheet Bottom sheet on iOS; popover-style on macOS
DFPopover Native popover with arrow on macOS; floating panel on iOS
DFTooltip .help() on macOS; long-press label on iOS
All style .glass variants Gated internally — falls back to .default on OS < 26

Before and After

Before DesignFoundation (manual guards)

struct RootView: View {
    @State private var selectedTab = "home"

    var body: some View {
        #if os(macOS)
        NavigationSplitView {
            List(items, selection: $selectedItem) { item in
                Label(item.label, systemImage: item.icon)
            }
        } detail: {
            detailView(for: selectedItem)
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        Button("New") { createNew() }
                    }
                }
        }
        #else
        TabView(selection: $selectedTab) {
            ForEach(tabs) { tab in
                tabContent(for: tab.id)
                    .tabItem { Label(tab.label, systemImage: tab.icon) }
                    .tag(tab.id)
            }
        }
        .tint(Color.blue)
        #endif
    }
}

After DesignFoundation

struct RootView: View {
    @State private var selection: String? = "home"

    var body: some View {
        DFSidebar(selection: $selection, sections: navSections) { id in
            detailView(for: id)
                .dfNavigationBar(title: title(for: id)) {
                    DFButton("New") { createNew() }
                        .dfButtonStyle(.tinted)
                }
        }
    }
}

When Platform Guards ARE Needed

DFPlatformContext handles component-level platform differences. Some app-level APIs don't have DF wrappers — those still need guards.

Scene Declarations (App entry point only)

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .dfThemePreset(.slate)
        }

        #if os(macOS)
        WindowGroup("Detail", id: "detail", for: String.self) { $id in
            DetailView(id: id ?? "")
        }
        .defaultSize(width: 900, height: 700)
        .windowStyle(.titleBar)
        #endif
    }
}

Opening Windows

// Never use NSWorkspace.shared.open() from a SwiftUI view
// Use the cross-platform environment action:
@Environment(\.openURL) private var openURL

openURL(url)
// Multi-window on macOS
@Environment(\.openWindow) private var openWindow

#if os(macOS)
Button("Open Detail") {
    openWindow(id: "detail", value: item.id)
}
#endif

UIKit / AppKit-Only APIs

If your code calls APIs that exist only on one platform, you still need guards:

// UIKit-only
#if canImport(UIKit)
UIApplication.shared.open(url)
#endif

// AppKit-only
#if canImport(AppKit)
NSWorkspace.shared.open(url)
#endif

For URL opening specifically, always use @Environment(\.openURL) in SwiftUI — it works on all three platforms and avoids the guard entirely.


Targeting All Three Platforms — Complete Example

import SwiftUI
import DesignFoundation

// Package.swift declares:
// .iOS(.v18), .macOS(.v15), .visionOS(.v2)

@main
struct CrossPlatformApp: App {
    var body: some Scene {
        WindowGroup {
            AppRootView()
                .dfThemePreset(.aurora)
                .dfToast(queue: DFToastQueue.shared)
        }

        #if os(macOS)
        Settings {
            SettingsView()
                .dfThemePreset(.aurora)
        }
        #endif
    }
}

struct AppRootView: View {
    @State private var selection: String? = "feed"

    private let sections = [
        DFSidebarSection(id: "main", title: "", items: [
            DFSidebarItem(id: "feed",     icon: "list.bullet",      label: "Feed"),
            DFSidebarItem(id: "explore",  icon: "magnifyingglass",  label: "Explore"),
            DFSidebarItem(id: "messages", icon: "message.fill",     label: "Messages", badge: .count(3)),
            DFSidebarItem(id: "profile",  icon: "person.fill",      label: "Profile"),
        ]),
    ]

    var body: some View {
        DFSidebar(selection: $selection, sections: sections) { id in
            destination(for: id)
                .dfNavigationBar(title: navTitle(for: id))
        }
    }

    @ViewBuilder
    private func destination(for id: String) -> some View {
        switch id {
        case "feed":     FeedView()
        case "explore":  ExploreView()
        case "messages": MessagesView()
        case "profile":  ProfileView()
        default:         ContentUnavailableView("Select a section", systemImage: "sidebar.left")
        }
    }

    private func navTitle(for id: String) -> String {
        switch id {
        case "feed":     return "Feed"
        case "explore":  return "Explore"
        case "messages": return "Messages"
        case "profile":  return "Profile"
        default:         return ""
        }
    }
}

Build Configuration

Package.swift Deployment Targets

let package = Package(
    name: "MyApp",
    platforms: [
        .iOS(.v18),
        .macOS(.v15),
        .visionOS(.v2),
    ],
    // …
)

Liquid Glass @available Guards

DF's .glass style variants are gated internally with @available(iOS 26, macOS 26, *) checks. You only need explicit @available guards in your own code if you are calling iOS 26 / macOS 26 APIs directly outside of DF:

@available(iOS 26, macOS 26, *)
struct MyGlassPanel: View {
    // Calls a SwiftUI API that only exists in iOS 26
    var body: some View {
        Text("Glass panel")
            .glassEffect(.regular)  // iOS 26-only SwiftUI API
    }
}

DF's .glass variant on DFCard, DFButton, etc., is NOT one of these cases — DF gates it internally and falls back gracefully. You do not need @available in your call site for DF components.

Info.plist Notes

No DesignFoundation-specific Info.plist keys are required. Follow Apple's standard platform requirements for each target (e.g., UILaunchScreen for iOS, NSPrincipalClass for macOS AppKit apps).

Xcode Target Setup

For a single multi-platform target (recommended):

  1. In your Xcode project, select the target.
  2. Under General → Supported Destinations, add iOS, macOS, and visionOS.
  3. Set deployment targets to iOS 18, macOS 15, visionOS 2.
  4. Under Frameworks, Libraries, and Embedded Content, confirm DesignFoundation is linked.

See also: Getting-Started · Theming · Navigation · Overlays-and-Feedback · Changelog

Clone this wiki locally