Live Activities and Dynamic Island in .NET MAUI 10: Native iOS Integration Guide

A practical walkthrough for shipping iOS Live Activities and Dynamic Island from a .NET MAUI 10 app: Swift Widget Extension, C# bridge via XCFramework, and remote APNs updates with working code.

.NET MAUI 10 Live Activities Guide 2026

Updated: June 7, 2026

To add iOS Live Activities and Dynamic Island support to a .NET MAUI 10 app, you build a native Swift Widget Extension that uses Apple's ActivityKit framework, expose a thin C# bridge through a binding library or [DllImport] P/Invoke into a static library, and update Live Activities either locally from the MAUI app or remotely via APNs with the apns-push-type: liveactivity header. MAUI itself has no first-class Live Activities API, so the native interop is unavoidable. Honestly, the workflow has stabilized in .NET 10 to the point where it's genuinely production-ready, and I've shipped it in two apps without major surprises.

  • Live Activities require an iOS-only Widget Extension written in Swift. There is no MAUI-native equivalent because ActivityKit views must be SwiftUI.
  • The MAUI app talks to ActivityKit through a Swift binding library that exposes start, update, and end calls with strongly-typed ActivityAttributes.
  • The Dynamic Island uses four presentations (compact leading, compact trailing, minimal, expanded). You must design for all four or iOS will reject your build at review.
  • Remote updates use APNs push type liveactivity, a separate push token per activity, and a payload with content-state and a stale-date.
  • Activities have a strict budget: ~8 hours runtime, ~12 KB ContentState, ~4 KB push payload, and roughly 1 high-priority push per second.
  • iOS 17.2 added ActivityKit push-to-start tokens so you can begin an activity remotely without the app being foregrounded first.

What are Live Activities and Dynamic Island?

Live Activities are persistent, real-time updates that appear on the iOS Lock Screen and, on iPhone 14 Pro and newer, in the Dynamic Island at the top of the display. Apple introduced them in iOS 16.1 alongside the ActivityKit framework, then expanded the surface in iOS 17 with push-to-start tokens and broadcast updates, and again in iOS 18 with StandBy compatibility and a wider expanded layout. They are the only sanctioned way to put live state (a ride-share driver's ETA, a sports score, a delivery countdown, a workout timer) in front of the user without forcing them into the app.

What trips up most cross-platform developers is that Live Activities are not an iOS app feature in the conventional sense. They live inside a Widget Extension, are rendered by SwiftUI, and are powered by a target-process running outside your main app binary. ActivityKit hands data to that extension; the extension renders. You cannot draw a Live Activity from C#, from MAUI XAML, from Avalonia, or from React Native. The rendering stack is SwiftUI, full stop. Apple documents this constraint in the WidgetKit Live Activities guide. If you're shipping a MAUI app, you accept that the presentation layer for this one feature is going to live in a Swift target, and you focus on making the C#-to-Swift handoff clean.

The .NET MAUI 10 integration architecture

In a .NET MAUI 10 solution, the integration looks like this:

  • MyApp.Maui: your existing MAUI project.
  • MyApp.LiveActivities: a native iOS Widget Extension target (Swift + SwiftUI), built in Xcode as a separate .xcodeproj that produces a .appex bundle.
  • MyApp.LiveActivities.Bridge: a Swift static library (.a) or XCFramework that wraps ActivityKit calls behind a C-compatible ABI.
  • MyApp.LiveActivities.Binding: a .NET MAUI binding library that exposes the bridge to C# via [DllImport] or generated Objective-C bindings.

The reason for the bridge layer is that .NET for iOS cannot directly P/Invoke Swift symbols, because Swift name mangling is unstable across compiler versions. The standard practice, documented in the .NET for iOS binding documentation, is to wrap Swift APIs in @_cdecl functions that expose a clean C entry point. The .NET 10 SDK adds first-class XCFramework support, which finally removes the manual fat-binary stitching we used to do in Xamarin. If you've worked through platform-specific code in .NET MAUI, the pattern will feel familiar. Live Activities are just a more elaborate version of the same handler-and-native-binding dance.

Building the Swift Widget Extension

You add the Widget Extension in Xcode (File → New → Target → Widget Extension), check Include Live Activity, and uncheck the Home Screen widget option if you don't need it. Xcode generates two files: MyAppLiveActivitiesAttributes.swift (the ActivityAttributes definition) and MyAppLiveActivitiesLiveActivity.swift (the SwiftUI presentation).

The ActivityAttributes protocol is how you describe the shape of your activity. It has two parts: fixed attributes set at activity creation (and never changed), and a nested ContentState struct that holds the mutable data updated over the activity's lifetime. For a delivery-tracking activity:

// MyAppLiveActivitiesAttributes.swift
import ActivityKit
import Foundation

public struct DeliveryActivityAttributes: ActivityAttributes {
    public typealias DeliveryStatus = ContentState

    public struct ContentState: Codable, Hashable {
        public var driverName: String
        public var etaMinutes: Int
        public var stageIndex: Int   // 0 = ordered, 1 = preparing, 2 = en route, 3 = arrived
        public var stageLabel: String
    }

    public var orderNumber: String
    public var restaurantName: String
}

The SwiftUI layout uses an ActivityConfiguration with two regions: the Lock Screen view (full width) and the Dynamic Island view (four sub-presentations). All four sub-presentations are required. Apple's reviewer guidelines will reject builds that omit any of them, and the Xcode build will warn if your DSL is incomplete.

// MyAppLiveActivitiesLiveActivity.swift
struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryActivityAttributes.self) { context in
            // Lock Screen / Notification Center presentation
            DeliveryLockScreenView(state: context.state, attrs: context.attributes)
                .activityBackgroundTint(Color.black.opacity(0.85))
                .activitySystemActionForegroundColor(Color.white)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) { Text(context.attributes.restaurantName) }
                DynamicIslandExpandedRegion(.trailing) { Text("\(context.state.etaMinutes) min") }
                DynamicIslandExpandedRegion(.center) { ProgressView(value: Double(context.state.stageIndex), total: 3) }
                DynamicIslandExpandedRegion(.bottom) { Text(context.state.stageLabel).font(.caption) }
            } compactLeading: {
                Image(systemName: "bag.fill")
            } compactTrailing: {
                Text("\(context.state.etaMinutes)m")
            } minimal: {
                Image(systemName: "bag.fill")
            }
            .widgetURL(URL(string: "myapp://orders/\(context.attributes.orderNumber)"))
        }
    }
}

Bridging ActivityKit to C#

Because .NET for iOS cannot consume Swift directly, you write a thin Swift wrapper exposed via @_cdecl, compile it into an XCFramework, and consume it from a MAUI binding library. The bridge needs to handle three operations: start, update, and end. It also needs to serialize state across the C ABI. The cleanest approach is JSON strings, decoded on the Swift side back into the typed ContentState.

// LiveActivitiesBridge.swift — compiled into LiveActivitiesBridge.xcframework
import ActivityKit
import Foundation

@_cdecl("la_start_delivery")
public func la_start_delivery(_ attrsJson: UnsafePointer<CChar>,
                              _ stateJson: UnsafePointer<CChar>,
                              _ outActivityId: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>) -> Int32 {
    let decoder = JSONDecoder()
    guard let attrsData = String(cString: attrsJson).data(using: .utf8),
          let stateData = String(cString: stateJson).data(using: .utf8),
          let attrs = try? decoder.decode(DeliveryActivityAttributes.self, from: attrsData),
          let state = try? decoder.decode(DeliveryActivityAttributes.ContentState.self, from: stateData) else {
        return -1
    }

    do {
        let content = ActivityContent(state: state, staleDate: Date().addingTimeInterval(3600))
        let activity = try Activity.request(attributes: attrs, content: content, pushType: .token)
        let idCopy = strdup(activity.id)
        outActivityId.pointee = idCopy
        return 0
    } catch {
        NSLog("la_start_delivery failed: \(error)")
        return -2
    }
}

@_cdecl("la_update_delivery")
public func la_update_delivery(_ activityId: UnsafePointer<CChar>,
                               _ stateJson: UnsafePointer<CChar>) -> Int32 {
    let id = String(cString: activityId)
    guard let stateData = String(cString: stateJson).data(using: .utf8),
          let state = try? JSONDecoder().decode(DeliveryActivityAttributes.ContentState.self, from: stateData),
          let activity = Activity<DeliveryActivityAttributes>.activities.first(where: { $0.id == id }) else {
        return -1
    }
    Task {
        await activity.update(ActivityContent(state: state, staleDate: Date().addingTimeInterval(3600)))
    }
    return 0
}

@_cdecl("la_end_delivery")
public func la_end_delivery(_ activityId: UnsafePointer<CChar>) -> Int32 {
    let id = String(cString: activityId)
    guard let activity = Activity<DeliveryActivityAttributes>.activities.first(where: { $0.id == id }) else { return -1 }
    Task { await activity.end(nil, dismissalPolicy: .immediate) }
    return 0
}

On the C# side, the binding is a straightforward LibraryImport declaration. .NET 10 made LibraryImport the recommended source-generated alternative to DllImport:

// Platforms/iOS/LiveActivitiesNative.cs
using System.Runtime.InteropServices;

internal static partial class LiveActivitiesNative
{
    private const string LibName = "__Internal";

    [LibraryImport(LibName, EntryPoint = "la_start_delivery", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int StartDelivery(string attrsJson, string stateJson, out IntPtr outActivityId);

    [LibraryImport(LibName, EntryPoint = "la_update_delivery", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int UpdateDelivery(string activityId, string stateJson);

    [LibraryImport(LibName, EntryPoint = "la_end_delivery", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int EndDelivery(string activityId);
}

Starting a Live Activity from MAUI

The MAUI-facing service wraps the native bridge behind ILiveStatusService. The iOS implementation also has to gate every call on ActivityAuthorizationInfo().areActivitiesEnabled. Users can disable Live Activities per app in Settings → [Your App] → Live Activities, and ActivityKit will silently fail if you don't check first.

// Services/ILiveStatusService.cs
public interface ILiveStatusService
{
    Task<string?> StartDeliveryAsync(DeliveryAttrs attrs, DeliveryState state);
    Task UpdateDeliveryAsync(string activityId, DeliveryState state);
    Task EndDeliveryAsync(string activityId);
}

// Platforms/iOS/IosLiveStatusService.cs
public sealed class IosLiveStatusService : ILiveStatusService
{
    public Task<string?> StartDeliveryAsync(DeliveryAttrs attrs, DeliveryState state)
    {
        if (!ActivityKitInterop.AreActivitiesEnabled())
            return Task.FromResult<string?>(null);

        var attrsJson = JsonSerializer.Serialize(attrs);
        var stateJson = JsonSerializer.Serialize(state);
        var rc = LiveActivitiesNative.StartDelivery(attrsJson, stateJson, out var idPtr);
        if (rc != 0) return Task.FromResult<string?>(null);
        var id = Marshal.PtrToStringUTF8(idPtr);
        Marshal.FreeHGlobal(idPtr);
        return Task.FromResult(id);
    }

    public Task UpdateDeliveryAsync(string activityId, DeliveryState state)
    {
        LiveActivitiesNative.UpdateDelivery(activityId, JsonSerializer.Serialize(state));
        return Task.CompletedTask;
    }

    public Task EndDeliveryAsync(string activityId)
    {
        LiveActivitiesNative.EndDelivery(activityId);
        return Task.CompletedTask;
    }
}

Register the service in MauiProgram.cs with a conditional compile guard, then inject it into a view-model the same way you would any other platform-specific dependency. If you're newer to that pattern, the platform-specific code guide walks through the conditional registration in more detail.

Remote updates via APNs

For anything longer than a few minutes (a real delivery, a multi-stage workout, a flight tracker), you don't want the MAUI app to drive updates. The app may be force-killed by the user, swapped out by the system, or simply backgrounded long enough that ActivityKit's local-only calls stop firing reliably. Push updates are the correct path. Apple's Sending notifications to a Live Activity reference is the canonical spec.

Each activity, when started with pushType: .token, receives its own push token (separate from the app's APNs device token). You retrieve it in Swift via activity.pushTokenUpdates, marshal it to your backend, and your backend sends an APNs request to /3/device/<token> with these headers:

  • apns-push-type: liveactivity
  • apns-topic: com.yourcompany.myapp.push-type.liveactivity (note the suffix)
  • apns-priority: 10 (immediate) or 5 (background)
  • apns-expiration: <unix-seconds>

The payload mirrors a normal APNs body but with an event and a content-state:

{
  "aps": {
    "timestamp": 1717776000,
    "event": "update",
    "content-state": {
      "driverName": "Maria",
      "etaMinutes": 4,
      "stageIndex": 2,
      "stageLabel": "Driver is 0.5 miles away"
    },
    "stale-date": 1717779600,
    "alert": {
      "title": "Almost there",
      "body": "Your order arrives in 4 minutes"
    }
  }
}

The end-to-end APNs setup mirrors regular push, and if you already have an APNs pipeline for your MAUI push notifications, you can reuse the same auth token. Only the headers and topic suffix change. Make sure your APNs auth key has the Live Activities capability enabled in the Apple Developer portal. Older keys created before iOS 16.2 do not, and rotating the key is the only fix.

Designing the four Dynamic Island presentations

The Dynamic Island has four required presentations, and getting them right is the difference between a passable Live Activity and one users actually like. Here's how the four states map to real screen real estate:

PresentationWhen it appearsMax widthWhat belongs there
Compact leadingAlways visible when activity is active and nothing else is using the Island~50ptBrand icon or single glyph
Compact trailingSame as leading; paired~50ptSingle value: ETA, score, count
MinimalTwo activities active simultaneously; yours collapses to a pill~24ptOne glyph, no text
ExpandedUser long-presses the Island, or your activity becomes the focused oneFull Island widthFull status: progress, sub-text, controls

The expanded presentation is split into four named regions (leading, trailing, center, bottom), and SwiftUI computes the layout automatically based on which regions you populate. The Human Interface Guidelines for Live Activities are surprisingly opinionated here: no advertising, no clickbait, no animations longer than a state transition, and a strict 8-hour cap on activity lifetime that you cannot extend.

Interactivity with App Intents

iOS 17 added interactive Live Activities via App Intents. You can put a button in the expanded presentation that triggers a background action (pause a timer, mark a task done) without opening the app. The intent runs in the Widget Extension process and must complete in under 30 seconds. From a MAUI app, the cleanest pattern is to write the intent in Swift, have it post a Darwin notification or write to an App Group UserDefaults key, then read that key from your MAUI app on next foreground.

Common errors and how to debug them

The error messages ActivityKit throws are unhelpful, and Console.app logs are your friend. Filter by subsystem:com.apple.ActivityKit and you'll see the actual reason an Activity.request call failed.

  • ActivityAuthorizationError.unsupported: device is not iOS 16.1+ or is iPad/Mac (Live Activities are iPhone-only).
  • ActivityAuthorizationError.denied: user disabled Live Activities for your app. There is no programmatic re-enable; you must guide them to Settings.
  • ActivityAuthorizationError.targetMaximumExceeded: you're trying to start a sixth activity. iOS caps concurrent activities per app at five.
  • Activity starts but never appears on Lock Screen: your Widget Extension's deployment target is higher than the device's iOS version, or the extension's bundle ID isn't <main-app-id>.LiveActivities.
  • Updates work in foreground but not background: you forgot NSSupportsLiveActivitiesFrequentUpdates = YES in the main app's Info.plist, which is required for high-frequency (≥4 per hour) push updates.
  • Push token never delivered: you started the activity with pushType: nil (the default) instead of .token. The token is only generated when you explicitly request push delivery.

Frequently Asked Questions

Can you create Live Activities without writing Swift?

No. Apple requires the Widget Extension's view body to be SwiftUI, and the ActivityAttributes type to be Swift code. Third-party plugins that claim XAML-only Live Activities are wrappers around a pre-built Swift extension, so you have no control over the layout. For any real product, you write Swift.

How long can a Live Activity stay on the Lock Screen?

iOS allows up to 8 hours of active runtime, and after the activity ends it can remain on the Lock Screen in a dismissed state for up to 4 additional hours (depending on dismissal policy). The 8-hour cap is enforced by the system, so you cannot extend it programmatically.

Do Live Activities work on iPad or Mac?

No. ActivityKit is iPhone-only as of iOS 18. On iPad and Mac Catalyst, ActivityAuthorizationInfo().areActivitiesEnabled returns false unconditionally. Plan a fallback UI for non-iPhone surfaces.

What's the difference between a push token and the app's device token?

The app's device token identifies the device for regular APNs alerts. Each Live Activity gets its own short-lived push token tied to that specific activity instance. When the activity ends, the token expires. You must subscribe to activity.pushTokenUpdates in Swift and ship each new token to your backend separately from the device token.

Can you start a Live Activity remotely without the app being open?

Yes, since iOS 17.2. You request a push-to-start token via Activity<Attrs>.pushToStartTokenUpdates, send it to your backend, and your backend issues an APNs request with apns-push-type: liveactivity and event: "start". The activity will appear on the Lock Screen without the user ever opening the app.

David O'Reilly
About the Author David O'Reilly

Native iOS/Android specialist turned MAUI advocate. Writes about the gritty platform details most cross-platform tutorials skip.