知阅百微 见微知著

Introducing JindoKit: Preview Dynamic Island Layouts Inside Your App

When you build a Live Activity, the normal way to inspect its Dynamic Island presentation is an Xcode preview:

#Preview(
    "Expanded",
    as: .dynamicIsland(.expanded),
    using: RunActivityAttributes.preview
) {
    LiveActivityWidget()
} contentStates: {
    RunActivityAttributes.ContentState.preview
}

That is the right tool while developing a Widget extension. But it does not help when the preview itself needs to become part of the product.

What if an app has a Live Activity editor and people should see their changes immediately? What if a configuration screen needs to switch between expanded, compact, and minimal presentations without leaving the app? What if a design tool needs to render several variants side by side?

That question led to JindoKit.

JindoKit turns a Dynamic Island presentation into an ordinary SwiftUI View.

A Familiar Dynamic Island API

JindoKit follows WidgetKit's Dynamic Island vocabulary. You still declare expanded regions, compact leading and trailing content, and minimal content:

import JindoKit
import SwiftUI

struct RunActivityPreview: View {
    @State private var mode: DynamicIslandPreviewMode = .expanded

    var body: some View {
        VStack {
            HStack {
                Button("Expanded") { mode = .expanded }
                Button("Compact") { mode = .compact }
                Button("Minimal") { mode = .minimal }
            }

            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    VStack(alignment: .leading) {
                        Image(systemName: "timer")
                        Text("12 MIN")
                    }
                }

                DynamicIslandExpandedRegion(.trailing) {
                    Image(systemName: "figure.run")
                }

                DynamicIslandExpandedRegion(.bottom) {
                    HStack {
                        Text("Golden Gate Run")
                        Spacer()
                        Text("68%")
                    }
                }
            } compactLeading: {
                Text("12")
            } compactTrailing: {
                Image(systemName: "figure.run")
            } minimal: {
                Image(systemName: "figure.run")
            }
            .contentMargins(.all, 20, for: .expanded)
            .previewMode(mode)
        }
    }
}

The expanded presentation is the default. Set .previewMode(.compact) or .previewMode(.minimal) to render another presentation. Because the mode is a normal value, it can come from @State, a model, or any other part of the app.

The API also includes the layout controls used by real Dynamic Island content, including region priorities, expanded and region-specific content margins, and .belowIfTooWide vertical placement.

The important difference is where the result appears. WidgetKit hands the declaration to the system. JindoKit returns a view that can participate in a regular SwiftUI hierarchy.

Why This Needs a Dedicated Layout

An expanded Dynamic Island is not simply an HStack above a VStack. The leading, center, and trailing regions negotiate horizontal space around the TrueDepth camera area. Priority affects how competing regions are fitted, .belowIfTooWide can move content below the notch, the bottom region participates in the final height, and margins can be overridden per region.

SwiftUI contains an internal layout named JindoTripleVStack for this job. WidgetKit uses the same layout model when it produces a Dynamic Island presentation.

JindoKit wraps that model behind a WidgetKit-shaped API and supplies the remaining compact and minimal presentation containers. This keeps application code focused on content rather than reproducing the region negotiation rules in every preview screen.

Layout Backends

JindoKit 0.2.0 provides four backend traits that select the view framework and Jindo layout implementation:

BackendLayout providerIntended use
SwiftUI_NO_SPI
(default)
JindoKit's open-source layout through SwiftUI's public Layout APIIn-app previews using SwiftUI
SwiftUI_SPIThe system SwiftUI JindoTripleVStack symbolLocal comparison with the system layout
OpenSwiftUI_NO_SPIJindoKit's open-source layout using OpenSwiftUIOpenSwiftUI layout development and integration testing
OpenSwiftUI_SPIThe Jindo layout exposed by OpenSwiftUI_SPILocal comparison with the OpenSwiftUI SPI layout

The default SwiftUI_NO_SPI backend implements the layout with SwiftUI's public Layout API. It does not import SwiftUI_SPI or enable JindoKit's SPI build flags.

The SwiftUI_SPI backend uses a bundled textual interface to compile against the system implementation. The symbol is provided by Apple's SwiftUI framework at runtime. This private API backend is intended for local compatibility testing and should not be used in an App Store build.

OpenSwiftUI_NO_SPI
avoids the SPI layout module, but both OpenSwiftUI backends inherit OpenSwiftUI's runtime dependencies. Neither should be treated as App Store-safe.

The four traits are mutually exclusive. Omit traits to use the default, or select exactly one backend. All four expose the same JindoKit API. The backend choice still applies to the complete view tree: content closures in an OpenSwiftUI configuration must provide OpenSwiftUI views, because SwiftUI and OpenSwiftUI view values are not interchangeable.

Installation

JindoKit 0.2.0 requires a toolchain that supports Swift 6.3. Add the dependency for the default SwiftUI backend with:

.package(
    url: "https://github.com/OpenSwiftUIProject/JindoKit.git",
    from: "0.2.0"
)

Then add the JindoKit product to the application target and import it alongside SwiftUI.

To use OpenSwiftUI with JindoKit's open-source layout, enable the OpenSwiftUI_NO_SPI package trait:

.package(
    url: "https://github.com/OpenSwiftUIProject/JindoKit.git",
    from: "0.2.0",
    traits: ["OpenSwiftUI_NO_SPI"]
)

The application then imports OpenSwiftUI instead of SwiftUI:

import JindoKit
import OpenSwiftUI

Both OpenSwiftUI backends require iOS 18 or later. The JindoKit declarations and .previewMode calls remain the same.

What JindoKit Does Not Replace

JindoKit renders a Dynamic Island presentation inside an app. It does not request, update, or end a Live Activity, and it does not replace the Widget extension that supplies content to the real system Dynamic Island.

A production app can use all three layers:

  1. JindoKit renders an interactive preview in the main app.
  2. ActivityKit manages the Live Activity lifecycle.
  3. WidgetKit renders the actual lock-screen, banner, and Dynamic Island presentations owned by the system.

JindoKit also does not promise pixel-for-pixel identity across every OS release and device. WidgetKit owns the final system chrome and can change its constraints. The goal is to reuse the same content structure and layout model closely enough for an in-app editor or preview to be useful, while the actual Live Activity remains the final reference.

Open Source

JindoKit is available at OpenSwiftUIProject/JindoKit. The repository includes a comparison app that renders the same inputs with JindoKit and a WidgetKit extension, plus an OpenSwiftUI-backed example.

If your app has a Live Activity configurator, a theme editor, a design gallery, or any other reason to show Dynamic Island content outside SpringBoard, JindoKit provides the missing bridge: the familiar Dynamic Island declaration, rendered as a view you control.

For more information, see Preview Dynamic Island presentations in your app with JindoKit on the OpenSwiftUI blog.