-
Notifications
You must be signed in to change notification settings - Fork 3
ActivityKit
Package
SwiftBindings.Apple.ActivityKit· Version26.2.6Auto-published fromapple-frameworks/ActivityKit/ACTIVITYKIT-GUIDE.md.
SwiftBindings.Apple.ActivityKit lets you drive Live Activities — the lock-screen cards and Dynamic Island content iOS shows for ongoing events — from a .NET app. You start, update, and end an activity entirely from C# and render it with a tiny SwiftUI widget, exactly the way every Live Activity is rendered, .NET or not. The request/update/end chain is verified end-to-end on both the iOS Simulator (Mono JIT) and a physical device (NativeAOT).
This guide covers the one design constraint that makes it possible, the complete setup (package, capability, widget, C#), and the full API surface.
- Requirements & install
- Quick start
- How it works (and why a fixed attributes type)
- Step 1 — Add the package
- Step 2 — Declare the capability
- Step 3 — Add the SwiftUI widget extension
- Step 4 — Drive it from C#
- API reference
- Push-driven updates
- Lifetime & threading
- What ships vs. what doesn't
- Alternative: roll your own Swift bridge
- Troubleshooting
- Reference links
- .NET 10.0+
- Target framework: any
net10.0-iosTFM — set your minimum with<SupportedOSPlatformVersion>; the package itself is built against the iOS 26.2 supplement. Live Activities are an iOS/iPadOS surface only — there is no macOS, Mac Catalyst, or tvOS leg -
iOS 16.2+ at runtime for
Request/Update/End(the attributes type itself is 16.1+) - macOS host for development
- The host app must be foreground-active when it calls
Request— ActivityKit throws otherwise (an Apple rule, not a binding limitation) - A WidgetKit extension embedded in your app to render the activity (~30 lines of SwiftUI; template below). It is embedded straight from your
.csprojviaAdditionalAppExtensions— your .NET/MAUI app never becomes an Xcode project (see Step 3) - The
NSSupportsLiveActivitiesInfo.plist key on the host app - The Dynamic Island specifically needs an iPhone 14 Pro or newer; every Live-Activity-capable device shows the lock-screen / banner presentation regardless
dotnet add package SwiftBindings.Apple.ActivityKit
using Swift.ActivityKit;This package also generates a binding for the system ActivityKit framework types (
ActivityAuthorizationInfo,ActivityState,ActivityStyle,ActivityAuthorizationError, push-token metadata, …) under theActivityKitnamespace. The high-level lifecycle API you'll use day to day isSwift.ActivityKit.LiveActivity, which ships in the transitively-referencedSwiftBindings.Applesupplement — no extra package reference needed.
The content crosses as a JSON string — that's the contract, since it round-trips through Codable into the widget's separate process — but you never hand-write it. Model each payload as whatever C# types suit your app and serialize them. Your payload shape and your widget's UI are entirely yours; the binding never looks inside the JSON.
using System.Text.Json;
using System.Text.Json.Serialization;
using Swift.ActivityKit;
// Your payloads — shape them however you like; the binding only needs them as JSON.
record DeliveryAttributes(string OrderId);
record DeliveryState(string Status, string? Eta = null);
// camelCase keys match the property names on the Swift struct your widget decodes into;
// null fields are omitted so an ended activity sends just {"status":"Delivered"}.
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// Always check first — a request on a disabled app throws.
if (!LiveActivity.AreActivitiesEnabled)
return;
// Start. `name` selects which widget UI renders; the two payloads are your serialized objects.
var activity = LiveActivity.Request(
name: "delivery",
attributesJson: JsonSerializer.Serialize(new DeliveryAttributes("A-42"), jsonOptions),
contentStateJson: JsonSerializer.Serialize(new DeliveryState("Preparing", "15 min"), jsonOptions));
// Update the changing state as often as you like (fire-and-forget; applies in order).
activity.Update(JsonSerializer.Serialize(new DeliveryState("Out for delivery", "5 min"), jsonOptions));
// Finish it. `immediate: true` removes it at once; the default lets the system keep it briefly.
activity.End(JsonSerializer.Serialize(new DeliveryState("Delivered"), jsonOptions), immediate: true);That's the whole loop. Request returns a handle; Update/End are methods on it. This C# won't render anything on its own — you must still do the two standard setup steps below (the NSSupportsLiveActivities Info.plist key and the SwiftUI widget), exactly as a pure-Swift Live Activity requires.
Publishing to a physical device? Device builds use NativeAOT, where reflection-based
JsonSerializer.SerializereportsIL2026/IL3050trim/AOT warnings (it still runs — simple payloads serialize fine — but the publish is noisy). For a warning-free publish, generate the serializer at compile time with a source-generated context: declare the context once — alongside your payload records or in any shared source file — and pass the generated per-typeJsonTypeInfoin place ofjsonOptions.[JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(DeliveryAttributes))] [JsonSerializable(typeof(DeliveryState))] partial class LiveActivityJson : JsonSerializerContext; // ...then serialize against the generated context: activity.Update(JsonSerializer.Serialize( new DeliveryState("Out for delivery", "5 min"), LiveActivityJson.Default.DeliveryState));
That setup — JSON payloads plus a hand-copied Swift struct in the widget — looks the way it does for one reason. ActivityKit's entry point is Activity<Attributes>, where Attributes conforms to ActivityAttributes, which refines Codable & Hashable. Those conformances are synthesized by the Swift compiler from the type's stored properties at compile time — there is no runtime entry point that manufactures a working witness table for a type the Swift compiler never saw. A C# type therefore cannot serve as Attributes, and Activity<YourCSharpType> can never be materialized. That is the permanent limitation behind the old "ActivityKit isn't supported from C#" guidance.
This binding sidesteps it by shipping one concrete attributes type, DotNetLiveActivityAttributes, fully defined in Swift inside the native SBApple framework. Because it is concrete at the binding's build time, the compiler synthesizes its Codable/Hashable witnesses then, and the Activity<DotNetLiveActivityAttributes> generic is resolved entirely within SBApple — no generic and no protocol-witness table ever crosses the C ABI. Your per-activity data rides inside that fixed type as JSON, and the widget decodes it to draw the UI.
Your .NET app
Swift.ActivityKit.LiveActivity.Request / Update / End
│
│ per-activity data as JSON, over the @_cdecl C ABI
▼
SBApple.framework (ships inside SwiftBindings.Apple)
DotNetLiveActivityAttributes — one fixed, Swift-defined type
Activity<…>.request / update / end
(concrete: no generics, no protocol-witness tables cross the C boundary)
│
│ ActivityKit pairs the activity to the widget by the attributes
│ type's *unqualified name* + a Codable round-trip
▼
Your SwiftUI widget extension (a ~30-line *.appex)
Declares its OWN byte-for-byte copy of DotNetLiveActivityAttributes
ActivityConfiguration(for:) { lock-screen card + Dynamic Island }
Cross-process pairing between your running activity and the widget is by the attributes type's unqualified name plus a Codable round-trip — not module identity — so your widget extension declares its own byte-for-byte copy of the type (Apple's standard "attributes type in two targets" pattern) and never links this package.
dotnet add package SwiftBindings.Apple.ActivityKitThis brings in the Swift.ActivityKit.LiveActivity API and, transitively, the SwiftBindings.Apple supplement that carries the native SBApple framework the API calls into. No other native reference is needed.
Add to your app's Info.plist:
<key>NSSupportsLiveActivities</key>
<true/>(For background/remote updates via push you would also enable push capabilities — see Push-driven updates. Local start/update/end from your own code needs only this key.)
Live Activity UI is always SwiftUI compiled into a WidgetKit app extension (a signed .appex bundle) — true for Swift apps too. It is the one piece that cannot be C#, and the one piece that needs Xcode's Swift toolchain to compile. The crucial point for .NET and MAUI: your app does not become an Xcode project. You author the widget as a standalone Swift extension, build it to a .appex, and let the .NET build embed and sign it into your app bundle's PlugIns/ folder — the same place every iOS app, Swift or not, carries its extensions.
The three sub-steps below do exactly that: 3a write the two Swift files, 3b compile them to a .appex, 3c embed it from your .csproj.
Create a Widget Extension target in any Xcode project — a throwaway one whose only job is to compile this widget is fine (File ▸ New ▸ Target ▸ Widget Extension, check Include Live Activity). Keep its two source files in your repo, e.g. under Platforms/iOS/LiveActivityWidget/. Put these two files in the target.
DotNetLiveActivityAttributes.swift — a byte-for-byte copy of the binding's attributes type. ActivityKit pairs a running activity to your widget by this type's unqualified name plus a Codable round-trip, so your widget declaring its own identical copy is all the pairing needs.
import Foundation
import ActivityKit
@available(iOS 16.1, *)
public struct DotNetLiveActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
public var json: String
public init(json: String) { self.json = json }
}
/// Identifies the activity "kind"; switch on it to pick a UI.
public var name: String
/// Static (non-updating) attributes, as a JSON blob.
public var json: String
public init(name: String, json: String) {
self.name = name
self.json = json
}
}DotNetLiveActivityWidget.swift — the UI. Decode context.attributes.json (static) and context.state.json (updating) into whatever shape your app sends, and switch on context.attributes.name if you render more than one kind of activity. This example shows the raw JSON fields for brevity — decode them into a real model before you ship (see the note right after the code):
import WidgetKit
import SwiftUI
import ActivityKit
@main
struct DotNetWidgetBundle: WidgetBundle {
var body: some Widget { DotNetLiveActivityWidget() }
}
@available(iOS 16.2, *)
struct DotNetLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DotNetLiveActivityAttributes.self) { context in
// Lock screen / banner presentation.
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(context.attributes.name).font(.headline)
Text(context.state.json).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "bolt.fill").foregroundStyle(.yellow)
}
.padding()
.activityBackgroundTint(Color.blue.opacity(0.25))
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text(context.attributes.name).font(.caption).bold()
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.json).font(.caption2)
}
} compactLeading: {
Image(systemName: "bolt.fill").foregroundStyle(.yellow)
} compactTrailing: {
Text(context.attributes.name).font(.caption2)
} minimal: {
Image(systemName: "bolt.fill").foregroundStyle(.yellow)
}
}
}
}Decode the JSON into a real model instead of showing the raw string in anything you ship — for example try JSONDecoder().decode(MyState.self, from: Data(context.state.json.utf8)). The binding does not care about the JSON's shape; you own both ends of it.
The widget is built by Xcode's Swift toolchain, never by .NET. Build its target once per slice you ship — device and simulator land in different output folders:
# Device slice
xcodebuild -project LiveActivityWidget.xcodeproj -scheme LiveActivityWidget \
-configuration Release -sdk iphoneos -derivedDataPath build-device
# Simulator slice
xcodebuild -project LiveActivityWidget.xcodeproj -scheme LiveActivityWidget \
-configuration Release -sdk iphonesimulator -derivedDataPath build-simEach run produces …/Build/Products/Release-{iphoneos|iphonesimulator}/LiveActivityWidget.appex. You don't sign it here — the .NET build re-signs it with your app's identity in Step 3c.
.NET for iOS has a first-class build item, AdditionalAppExtensions, that copies a prebuilt native .appex into YourApp.app/PlugIns/ and code-signs it with your app's identity during dotnet build / publish — no app-level Xcode project required. Add it to your iOS app .csproj (for MAUI, the iOS head's .csproj):
<ItemGroup>
<AdditionalAppExtensions Include="Platforms/iOS/LiveActivityWidget">
<Name>LiveActivityWidget</Name>
<!-- BuildOutput is appended to Include; the right slice resolves per build. -->
<BuildOutput Condition="'$(SdkIsSimulator)' == 'true'">build-sim/Build/Products/Release-iphonesimulator</BuildOutput>
<BuildOutput Condition="'$(SdkIsSimulator)' == 'false'">build-device/Build/Products/Release-iphoneos</BuildOutput>
<CodesignEntitlements>Platforms/iOS/LiveActivityWidget/Widget.entitlements</CodesignEntitlements>
</AdditionalAppExtensions>
</ItemGroup>Three details decide whether this resolves:
-
The SDK looks for the bundle at exactly
{Include}/{BuildOutput}/{Name}.appex. SoNamemust match the.appexfilename (without the extension) — hereLiveActivityWidget→LiveActivityWidget.appex. TheBuildOutputsplit is what lets one entry resolve to the device or simulator slice automatically based on what you're building. .NET then copies it intoPlugIns/and re-signs it; you hand-copy nothing. -
Signing follows the host app's identity. A simulator build re-signs the embedded
.appexad-hoc — no entitlements or provisioning needed, which is why the simulator path Just Works. A device build re-signs it with your real signing identity and provisioning profile, and that is whereCodesignEntitlementsmatters (e.g. an App Group or push entitlement on the widget). -
CodesignEntitlementsis optional. Omit it and the SDK auto-uses{Include}/{Name}.entitlementsif that file exists; if the widget needs none, drop the line and set<CodesignWarnIfNoEntitlements>false</CodesignWarnIfNoEntitlements>to silence the otherwise-emitted warning.
Two further bundle rules iOS enforces (both confirmed by the end-to-end test that validated this binding):
-
The widget's bundle id must be a child of the host app's — app
com.acme.app→ widgetcom.acme.app.widget. A non-prefixed id makes iOS silently refuse to load the extension (the activity still starts; only the UI is missing). -
The widget's
Info.plistmust declare the WidgetKit extension point —NSExtension→NSExtensionPointIdentifier=com.apple.widgetkit-extension. Xcode's Widget Extension template writes this for you.
MAUI: the MAUI iOS head is a .NET for iOS app, so
AdditionalAppExtensionsapplies unchanged — the widget is embedded under the iOS head'sPlugIns/. Microsoft's .NET MAUI Live Activity sample and the How to Build iOS Widgets with .NET MAUI blog post walk this exact path. To pass anything beyond the activity payload (or otherwise coordinate app ↔ widget), share an App Group container between the host app and the extension.
The C# is the three-call loop from Quick start — Request, then Update as the state changes, then End. One rule is specific to this binding: every JSON argument (attributesJson, contentStateJson, and the Update / End payloads) must be a JSON object — { … } — or null/empty, which the facade normalizes to {}. A malformed payload would start an activity whose widget silently renders nothing, so the facade validates eagerly and throws ArgumentException before any ActivityKit call.
Swift.ActivityKit.LiveActivity
| Member | Description |
|---|---|
static bool AreActivitiesEnabled |
The per-app Settings → Live Activities toggle combined with the NSSupportsLiveActivities capability. Check before Request. |
static LiveActivity Request(string name, string attributesJson = "{}", string contentStateJson = "{}", bool usePushToken = false) |
Starts an activity. Throws LiveActivityException if the system refuses (disabled, payload over the ~4 KB budget, app not foreground, unsupported target). Returns a live handle. |
bool IsActive |
False once the activity has ended (via End). |
bool Update(string contentStateJson) |
Replaces the updating content state. Returns false if already ended — never throws on a dead handle. Consecutive updates apply in call order. |
bool End(string? finalContentStateJson = null, bool immediate = false) |
Ends the activity. Idempotent — a second call is a safe no-op returning false. The end is ordered after pending updates, and the call blocks (bounded) until applied, so the activity is actually gone when it returns. |
bool ObservePushToken(Action<string> onToken) |
For server-driven updates: invokes onToken with each APNs push token as lowercase hex. Requires usePushToken: true and the push capability; otherwise a harmless no-op. |
Swift.ActivityKit.LiveActivityException — thrown only by Request; its Message is the system-reported reason (e.g. activities disabled, attributes over the ~4 KB budget, app not foreground-active).
To update an activity from your server instead of from the device:
- Start it with
usePushToken: trueand enable the push-notifications capability on the host app. - Observe token refreshes:
var activity = LiveActivity.Request("delivery", usePushToken: true);
activity.ObservePushToken(hex =>
{
// hex is the APNs push token as a lowercase hex string.
// Send it to your server; it pushes ContentState updates to APNs.
// NOTE: this callback runs on a background thread — marshal to the
// main thread before touching UI. Only one observer per activity.
});Your server then pushes content-state payloads to APNs against that token. The binding's role ends at delivering the token; the APNs push itself is standard server-side ActivityKit.
-
A Live Activity outlives the
LiveActivityobject — the system holds it. Letting the object be garbage-collected does not end the activity (correct ActivityKit behavior: an order-tracking card should outlive the view model that started it). End it explicitly withEnd. There is no finalizer. -
ObservePushToken's callback runs on a background thread (the Swift concurrency pool). Marshal to the main thread before touching UI. An exception it throws cannot propagate across the native boundary — it is caught and written to standard error. -
Update/Endare safe to call on a handle that has already ended (they return false); concurrentEndcalls are serialized so only one native end is dispatched.
Ships and works:
-
LiveActivity.Request/Update/End— the full lifecycle, returning a handle. -
LiveActivity.AreActivitiesEnabled,IsActive, andLiveActivityExceptionfor the failure reason. -
LiveActivity.ObservePushToken— APNs push tokens for server-driven updates. - The generated system-ActivityKit type surface (
ActivityAuthorizationInfo,ActivityState,ActivityStyle,ActivityAuthorizationError+ extensions,ActivityUIDismissalPolicy,AlertConfiguration,PushType). - Registry hardening: idempotent
End, andUpdate-after-Endis a safe no-op rather than a use-after-free.
Not available: genuinely distinct, strongly-typed ActivityAttributes structs authored per app in C#. You model per-activity data as JSON inside the one fixed type instead. If you need separate compiler-checked attributes types, declare them in a Swift companion target and call into a narrow @_cdecl shim — the same technique this binding uses internally.
This binding exists so a C#-only team can drive Live Activities without authoring or maintaining any Swift bridge or P/Invoke layer — you write only the widget UI (which is irreducibly SwiftUI no matter the approach). The cost of that convenience is the JSON-blob attributes design above.
If your team is comfortable writing a little Swift, Microsoft documents the lower-level path directly — How to Build iOS Widgets with .NET MAUI and the .NET MAUI Live Activity sample. There you author your own Swift bridge (Activity.request/update/end behind @_cdecl shims), expose it to C# via P/Invoke, and embed the widget with the same AdditionalAppExtensions step from Step 3c. It's more moving parts to write and maintain, but because you define the ActivityAttributes struct in your own Swift, you get genuinely typed, compiler-checked attributes instead of a JSON round-trip — the one capability this binding structurally cannot offer.
Rule of thumb: reach for this package when you want zero Swift bridge code and JSON payloads are fine; roll your own when typed attributes are worth owning a small Swift target. Either way the widget extension and AdditionalAppExtensions embedding are identical.
| Symptom | Cause / fix |
|---|---|
LiveActivityException: unsupportedTarget on Request
|
NSSupportsLiveActivities isn't in the built app bundle's Info.plist. Confirm the key is present and that an incremental build didn't skip the manifest — a clean rebuild forces it. |
LiveActivityException: visibility |
The app wasn't foreground-active when you called Request. Call it from an active state, not from the background or during launch. |
AreActivitiesEnabled is false |
The user turned Live Activities off for your app in Settings (or the entitlement is absent at runtime). A missing NSSupportsLiveActivities key usually surfaces instead as LiveActivityException: unsupportedTarget on Request. |
| Activity starts but nothing renders | No widget extension embedded, or its DotNetLiveActivityAttributes doesn't match (same property names, same Codable shape). The activity is still tracked; only the UI is missing. |
| Extension never loads — no UI, no error | The widget's bundle id isn't a child of the host app's (com.acme.app.widget), or its Info.plist is missing NSExtensionPointIdentifier = com.apple.widgetkit-extension. Both are required for iOS to load the .appex. |
| Widget UI doesn't reflect a Swift change you just made | MSBuild's incremental copy can ship a stale .appex. Rebuild the widget (Step 3b), then clean the .NET app's bin/ + obj/ before redeploying so the new bundle is copied into PlugIns/. |
| Nothing in the Dynamic Island, but the lock screen works | Expected on devices without Dynamic Island hardware (anything before iPhone 14 Pro). The lock-screen presentation is the cross-device surface. |
| Simulator shows nothing in the Dynamic Island | The iOS Simulator does not composite third-party Live Activities into the Dynamic Island. Use the lock screen, or a physical device, to see it render. The start/update/end calls themselves work on the simulator. |
- Apple: ActivityKit
- Apple: Displaying live data with Live Activities
- swift-dotnet-bindings wiki: Known Limitations — where the fixed-attributes-type design fits in the broader binding limitations
-
ActivityKit —
SwiftBindings.Apple.ActivityKitv26.2.6 -
CryptoKit —
SwiftBindings.Apple.CryptoKitv26.2.6 -
FamilyControls —
SwiftBindings.Apple.FamilyControlsv26.2.6 -
LiveCommunicationKit —
SwiftBindings.Apple.LiveCommunicationKitv26.2.6 -
Matter —
SwiftBindings.Apple.Matterv26.2.6 -
MatterSupport —
SwiftBindings.Apple.MatterSupportv26.2.6 -
MusicKit —
SwiftBindings.Apple.MusicKitv26.2.6 -
ProximityReader —
SwiftBindings.Apple.ProximityReaderv26.2.6 -
RealityFoundation —
SwiftBindings.Apple.RealityFoundationv26.2.6 -
RealityKit —
SwiftBindings.Apple.RealityKitv26.2.6 -
RoomPlan —
SwiftBindings.Apple.RoomPlanv26.2.6 -
StoreKit2 —
SwiftBindings.Apple.StoreKit2v26.2.6 -
TipKit —
SwiftBindings.Apple.TipKitv26.2.6 -
Translation —
SwiftBindings.Apple.Translationv26.2.6 -
WeatherKit —
SwiftBindings.Apple.WeatherKitv26.2.6 -
WorkoutKit —
SwiftBindings.Apple.WorkoutKitv26.2.6