Live Activities Are a System Renderer, Not an App Feature
Foreword
I stepped on a few pitfalls while integrating the Dynamic Island. Looking back, I realized my understanding of the Dynamic Island wasn't thorough enough, so I re-examined the entire chain from start to finish. The best way to solidify this understanding is to produce something, hence this article.
1. What is the Dynamic Island?
Starting with the iPhone 14 Pro, Apple transitioned from the notch to the punch-hole era. That hole is the front camera—there was seemingly no other place to put it. For aesthetics, black pixels wrap around this area, making it look like a black "pill." The system can then dynamically control this area. For example, if you start a timer, it shows the countdown; play a song, and the album cover hangs there; connect AirPods, and it pops up a little animation. It can morph, stretch, and split. This black screen display area surrounding the front sensor cutout, controlled by the system, is the Dynamic Island.
It has three states, corresponding to the image above:
- Compact: Divided by the cutout into left (leading) and right (trailing) small sections—the default look when something is running.
- Minimal: A small dot/pill, used when a second activity needs to grab the spot.
- Expanded: After a long press, it expands into a large card.
So, who can put things on it? Two categories:
- System's own
- Incoming calls
- FaceID
- Charging
- Silent mode
- AirPods
- Screen recording
- Navigation
- Now Playing music
- ...
- Third-party APPs
Third-party APPs, meaning apps developed by us developers. For a third-party app to appear on the island, there is only one way: using the Live Activities framework.
But here's a key distinction: integrating the Dynamic Island is actually integrating Live Activities. However, Live Activities don't just display in the Dynamic Island. They can appear in two places: one is the Dynamic Island, and the other is your lock screen. When you receive Live Activities content, it simultaneously shows up in your Dynamic Island and on your lock screen.
Which devices support it?
It first launched on the iPhone 14 Pro, and both the 14 Pro and Pro Max support it. Starting with the iPhone 15, basically the entire lineup supports it.
Another requirement is the system version. iOS 16.0 launched alongside the 14 Pro, but 16.0 only supported system use. Third-party access was allowed in 16.1, so the minimum system requirement is actually iOS 16.1.
There's also a programming language requirement. To implement the Dynamic Island's UI, you must use SwiftUI, because the Live Activity's UI runs inside a Widget Extension, and WidgetKit only recognizes SwiftUI, not UIKit.
But your main app doesn't have to be SwiftUI. The ActivityKit code for starting/updating/ending activities is a standard Swift API, so older UIKit projects can use it normally. Only that small Widget interface needs to be written in SwiftUI.
So the requirements are:
- iPhone 14 Pro or later models
- iOS 16.1
- App can be UIKit, but the Dynamic Island UI must be SwiftUI
As we mentioned earlier, we are integrating Live Activities. Live Activities don't just work on the Dynamic Island. Even on phones without a Dynamic Island, when the system receives a Live Activity message (if you are using the phone), it will pop up an alert on the screen, a banner similar to a notification. It also has a persistent entry point, which is your lock screen, where a card will permanently reside.
2. How to Integrate
First, let's look at how the data flows:
① Configure certificates/capabilities
② Define the data model (Attributes static + ContentState dynamic)
③ Build the UI (Widget Extension, SwiftUI)
④ App: Start activity → Listen for token → Report to server
│
▼
⑤ Server → APNs push (apns-push-type: liveactivity + new content-state)
│
▼
⑥ System re-renders your Widget's view with the new content-state ← App process is not involved at all!
│
▼
⑦ User sees the update on Lock Screen / Dynamic Island → ⑧ App or server sends end to finish the activity
Now let's break down these steps one by one.
2.1 Certificates
Starting with certificates, Live Activity also uses APNs, so you need to enable the Push Notifications capability.
Although it uses the same path, Live Activity has its own dedicated channel, and the token used is specifically a Live Activity token.
iOS has three types of tokens:
| token | Bound topic | How to get |
|---|---|---|
| Normal push device token | <bundleID> |
registerForRemoteNotifications (UserNotifications) |
| Live Activity token | <bundleID>.push-type.liveactivity |
ActivityKit |
| VoIP token | <bundleID>.voip |
PushKit |
A token is essentially a 'delivery address for a specific topic'. The type of token depends on which topic it is bound to.
For Live Activities, after distinguishing by topic, they are further distinguished by scope:
- App-level, push-to-start token (Activity.pushToStartTokenUpdates): One per app, independent of any activity, used to start new activities.
- Activity-level, per-activity token (Activity.pushTokenUpdates): One for each running activity, used to update/end that specific activity.
Finally, distinguish the action you want to perform based on the event:
- start
- update
- end
In the section below, I will provide a complete example of a message body.
2.2 Project Structure + Defining the Model
Live Activity is part of WidgetKit, so you need to create a new Widget Extension in your project:
Then you need to check 'Include Live Activity' — this will generate the ActivityAttributes skeleton and the Live Activity widget template:
So a Live Activity project has two fixed targets:
- Main App (ActivityKit): Start/update/end activities, get tokens and report them.
- Widget Extension (WidgetKit): Draw the lock screen view + the three Dynamic Island states. It's an independent process with its own Bundle ID.
Then comes defining the model. The Attributes created in the previous step look like this:
@available(iOS 16.1, *)
struct LiveActivityDemoAttributes: ActivityAttributes {
/// Dynamic data: Replaced entirely on each local/push update.
/// Field names must match the server's content-state keys one-to-one.
public struct ContentState: Codable, Hashable {
var emoji: String // Emoji for the compact leading side
var title: String // Title (lock screen / expanded)
var body: String // Body text (lock screen / expanded)
var progress: Double? // Optional: progress 0...1
}
/// Static data: Determined at creation, immutable afterwards.
var name: String
}
One thing to note is that the Attributes file needs to be added to both targets, otherwise it won't compile:
The model is not only shared between the two targets; the server also needs to align with it:
{
"aps": {
"timestamp": 1782000060, // Unix seconds (required): System judges new/old, discards expired packets
"event": "start", // Action: start=create / update=update / end=end
"attributes-type": "LiveActivityDemoAttributes", // Must match the client's ActivityAttributes struct name exactly; mismatch causes iOS to silently reject (APNs still returns 200)
"attributes": { // Static data (immutable after creation), corresponds to non-ContentState fields in the struct
"name": "LiveActivityDemo" // = LiveActivityDemoAttributes.name
},
"content-state": { // Dynamic data; field names + types must match ContentState one-to-one
"emoji": "⬇️", // ContentState.emoji (String, required)
"title": "Downloading", // ContentState.title (String, required)
"body": "model_package.zip", // ContentState.body (String, required)
"progress": 0.62 // ContentState.progress (Double?, optional; must be a number, not "0.62")
},
"alert": { // Required for start, otherwise the system won't show the Dynamic Island; optional for update
"title": "Downloaded 62%", // Lock screen / banner alert title
"body": "Tap to view progress" // Lock screen / banner alert body
}
}
}
This is a complete message body submitted by the server to APNs, which must correspond to the definitions in Attributes.swift.
2.3 Registering / Starting an Activity (Client)
Next is getting the Live Activity token on the client side and reporting it to the server.
The method varies depending on the iOS version:
- 16.1: Must start the activity first, then get the token for that activity (update).
- 16.2: Same capability as 16.1, but the API for getting the token is replaced with a new one (update).
- 17.2: Can pull up the Dynamic Island out of thin air even when the App is closed (start).
Before request, you need to check permissions. ActivityAuthorizationInfo().areActivitiesEnabled must be true before calling request.
Before this, you must set NSSupportsLiveActivities = YES in info.plist, otherwise ActivityAuthorizationInfo().areActivitiesEnabled will always return false.
Note: In the text below, type 2 represents update/end, and type 3 represents start. This is a convention with our server, not Apple's type.
iOS 16.1, start activity → listen for this activity's token:
// iOS 16.1
let activity = try Activity.request(
attributes: attributes,
contentState: initialState, // Old API: pass ContentState directly
pushType: .token // Must be .token for remote push / getting token
)
// Token is delivered asynchronously and rotates — must listen continuously, not read once
Task {
for await tokenData in activity.pushTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined()
reportToken(token, pushType: 2) // per-activity token
}
}
16.1 requires calling request first, then getting the token via callback. 16.1 doesn't have start, so it's all update.
iOS 16.2, same capability, API changed to ActivityContent:
// iOS 16.2+
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: initialState, staleDate: nil), // New API
pushType: .token
)
Task {
for await tokenData in activity.pushTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined()
reportToken(token, pushType: 2)
}
}
The capability is the same as 16.1, also requiring request first, just with a new API that wraps state in ActivityContent and allows setting staleDate (expiration time).
iOS 17.2, no need to start an activity first, get App-level token directly:
17.2 is a qualitative change, introducing the push-to-start token (pushToStartTokenUpdates). It's App-level, doesn't require a pre-existing activity. Once the server gets it, it can pull up an activity even when the App isn't running and no activities exist.
// iOS 17.2+: App-level push-to-start token (type 3)
if #available(iOS 17.2, *) {
Task {
for await tokenData in Activity<MyAttributes>.pushToStartTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined()
reportToken(token, pushType: 3) // push-to-start token
}
}
// After the server starts an activity using the type-3 token, the App captures it here via activityUpdates,
// then listens for this new activity's own token (type 2) for subsequent updates
Task {
for await activity in Activity<MyAttributes>.activityUpdates {
observeActivityToken(activity) // Internally still activity.pushTokenUpdates → type 2
}
}
}
Combined:
if #available(iOS 17.2, *) {
observePushToStartToken() // type 3: Listen for App-level token
observeNewActivities() // Capture server-started activities → listen for their type 2
} else if #available(iOS 16.2, *) {
startActivityWithNewAPI() // No push-to-start: start an activity yourself to get type 2 (new API)
} else {
startActivityWithLegacyAPI() // iOS 16.1: Old API to start activity and get type 2
}
2.4 Server-Side Handling
After getting the token, hand it over to the server, which handles the interaction with Apple. Coincidentally, I wrote the server side too, so let's talk about that as well.
I won't paste server code here. It mainly does three things:
- Store tokens by type.
- When sending, prioritize update, fall back to start if it fails.
- Clean up dead tokens promptly.
Check the flowchart below:
2.5 UI Building & App Group
There isn't much to say about the UI part, but it's important to remind your UI designers to pay attention to the Dynamic Island's design specifications, otherwise some styles might not be achievable.
Also, if you need to use data from the main target, you must add the App Group capability in the certificates so that data can be shared between the main target and the Live Activity.
If you're setting up an App Group, there are actually three Identifiers and two Profiles.
The three Identifiers are:
- Main App's Identifier
- Live Activity's Identifier
- App Group's Identifier
Here's how to create the App Group:
When filling it in, the group. prefix is automatically added.
In the Identifiers for both the Main App and the Live Activity, check App Group and select the Identifier of the App Group you just created. This links the Main App and the Live Activity, allowing them to share App Group data.
Note: Both the Main App and the Live Activity must have the App Group capability enabled.
The two Profiles are the Main App's profile and the Live Activity's profile.
2.6 Rendering
After the previous steps, the channel between App, server, and system is established. The App initiates registration, the server uses the token to send messages to APNs, and the system receives and renders them. So the last step is how the system renders.
First, the App does not participate in the rendering process. This means styles and logic must be pre-defined in the project, for example:
@available(iOS 16.1, *)
struct LiveActivityDemoAttributes: ActivityAttributes {
/// Dynamic data: Replaced entirely on each local/push update.
/// Field names must match the server's content-state keys one-to-one.
public struct ContentState: Codable, Hashable {
var emoji: String // Emoji for the compact leading side
var title: String // Title (lock screen / expanded)
var body: String // Body text (lock screen / expanded)
var progress: Double? // Optional: progress 0...1
}
/// Static data: Determined at creation, immutable afterwards.
var name: String
}
Then the rendering part, drawing the progress:
struct LiveActivityDemoLALiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LiveActivityDemoAttributes.self) { context in
// ===== Lock Screen Card =====
LockScreenCard(state: context.state)
.activityBackgroundTint(Color.black.opacity(0.85))
.activitySystemActionForegroundColor(Color.white)
} dynamicIsland: { context in
let state = context.state
return DynamicIsland {
// ===== Expanded State =====
// Leave leading/trailing empty, place all content in .bottom and arrange it yourself:
// Edge-hugging content will be clipped by the expanded state's large rounded corners (progress bar ends get cut), making layout controllable this way
DynamicIslandExpandedRegion(.leading) { EmptyView() }
DynamicIslandExpandedRegion(.trailing) { EmptyView() }
DynamicIslandExpandedRegion(.bottom) {
ExpandedContent(state: state)
}
} compactLeading: {
// ===== Compact Leading =====
Text(state.emoji)
.padding(.leading, 4)
} compactTrailing: {
// ===== Compact Trailing =====
if let p = state.progress {
Text("\(Int(p * 100))%")
.font(.system(size: 14, weight: .medium)).monospacedDigit()
.foregroundStyle(p >= 1.0 ? Color.green : Color.blue)
.contentTransition(.numericText()) // Number scroll transition
.padding(.trailing, 2)
}
} minimal: {
// ===== Minimal State =====
Text(state.emoji)
}
.keylineTint(.blue)
}
}
}
I wrote a demo, check it out if needed, the code is all there.
Effect:
Next is the system's job. The App is not involved at all, even if the App isn't open:
APNs ──▶ Device (System receives)
① Validate: Does the token match the topic? Is the timestamp newer than the last one? (Old ones are discarded directly)
② Check event: start = create / update = update / end = end
③ Decode: Decode the content-state JSON into your ContentState using Codable
④ Re-render: Redraw the view in the Widget with the new ContentState
→ Lock screen card, Dynamic Island compact/expanded/minimal states, all refreshed together
Regarding rendering, there are a few points to add:
1. Three Hard Constraints
The UI inside the Widget Extension has three hard constraints, all stemming from "the system renders, not your App":
- No networking: There's no opportunity to make requests inside the view; all data can only come from ContentState.
- No scrolling: ScrollView won't error out if placed, but it won't scroll. This is a static card, not a page.
- Images must be local: Image can only load resources from the extension's own Assets (or files in the App Group container). You can't give it a URL for async loading. And ContentState has a 4KB limit, so you can't stuff images in it. So if you want to change images via the server, you must pre-bundle them in the client, have the server send an index, and map it on the client.
2. Tap to Navigate
Tapping the Dynamic Island or the lock screen card opens the App by default. To navigate to a specific page, you need to use widgetURL(_:) or Link.
In the minimal and compact states, there's only one tap target, so you can only use widgetURL. The single tap target is the Dynamic Island itself; you can't target a specific UI element on it. Also, if you need to navigate to a specific page, you need to include parameters in widgetURL, like widgetURL(URL(string: "liveactivitydemo://open?from=lockscreen")). However, through actual testing, on iOS 26, when the compact and minimal states launch the App, the system dispatches no deep link signal. widgetURL, Link, Button(intent:), NSUserActivity were all tried, none responded. So you can assume that after iOS 26, when tapping the compact and minimal states, widgetURL is parameterless.
Therefore, if you want precise routing to a specific page, you can only handle it in the expanded state and on the lock screen.
The expanded state and lock screen can use Link to bind different elements, meaning multiple Links can exist simultaneously. Tapping different elements can trigger different actions.
2.7 Summary
The flowchart roughly looks like this:
- Client gets the Dynamic Island token.
- Sends the token to the server.
- Server sends via APNs.
- System receives the push.
- Starts or updates the Live Activity.
3. Pitfalls Worth Mentioning
3.1 The Start Trap
When first integrating, I thought, "Why bother with Update? Just fire a Start every time, isn't that great?" because the user perception of a Start is the strongest. I quickly found that after a few pushes, nothing would pop up anymore (via APNs). Because start creates a new activity, it doesn't update: each start adds one more activity, old ones don't disappear, and Apple has a limit on the number of activities. Once the limit is reached, further requests/starts will fail directly.
So the correct approach is: if you have an activity's token, update it. Fall back to start only when the update fails (server calls APNs and gets a 410/400 error). This allows the Dynamic Island to 'update in place' instead of constantly creating new ones.
But this strategy has another pitfall. If you try to update a token for an activity that has ended, APNs returns 200, but nothing happens on the phone screen, and no fallback is triggered. This leads the server to believe it's updating normally, while the user never sees a new Dynamic Island again.
Apple doesn't specify exactly how long an active activity lasts before expiring, only a rough estimate of 8-12 hours. The solution is: when a token for an activity has been registered for > 8 hours, treat it as expired, clean it up directly, and fall back to start to create a new one.
The client also needs to handle this. On a cold start, check Activity<MyAttributes>.activities, and end the extra activities (e.g., keep only the latest one). Otherwise, during development, repeatedly starting will quickly fill up the limit on a real device.
3.2 Silent Failure: Both Sides Say Success, User Sees Nothing
APNs returns 200, the client reports no error, but no Dynamic Island content is seen. The main reasons are:
attributes-typemismatch: Must match the client'sActivityAttributesstruct name exactly. If wrong, iOS silently rejects creation, but APNs still returns 200.content-statedoesn't match ContentState: A single letter difference in a field name, a missing required field, Codable decoding failure — all result in silent no-update.- Type mismatch: For example, sending
0.62as"0.62"also causes a decoding failure and no update.
So if you find an APNs push succeeded but had no effect, first check if any field names are wrong. Also, be aware it might be due to accumulating too many starts without cleanup.
3.3 About Update
In my demo, the example updates locally. activity.update() is code running in the App process; if the process is killed, it stops. So if you need to update data even when the App isn't running, you must use push.
Some things that can be calculated based on time (countdowns, constant speed progress) don't need constant updates. Just provide a time on the first push, and let the system run the timer locally. It works even when the App is closed, ticking second by second.
3.4 Environment Mismatch
APNs has two independent environments with different hosts, and tokens are not interchangeable. Debug belongs to Sandbox, Release/TestFlight/AppStore belong to Production.
Sandbox should be sent to api.sandbox.push.apple.com, production builds to api.push.apple.com. If it works on a debug build but not a release build, check this first.
3.5 Troubleshooting and Locating Issues
Actually, knowing how the Dynamic Island's chain flows basically tells you how to troubleshoot. Just determine which part of the chain is broken.
- Inside the App (permissions / request / getting token)
- Token reporting and storage
- Server -> APNs
- System rendering
Local anomalies are easier, but the problem is how to troubleshoot online issues. The only thing you can rely on online is logging or tracking at every critical step. For a Dynamic Island, from registration to display, the online observable content is:
Authorization status → Got token → Reported successfully → Server sent (status/reason) → ??? → User tap feedback
↑
This segment (system rendering) has no receipt, it's an observation blind spot
Step by step, starting from the client:
- Use
pushType: nilto start a purely local activity and update locally — if it starts and updates normally, the model and UI definitions are fine, and the problem lies in the push chain. - Check authorization status. Report on cold start / returning to foreground, and only report changes (this tells you how many users have normally disabled authorization vs. abnormally not receiving Dynamic Island).
- Token registration: See at which step the registration breaks.
- On tap, add tracking in Link or
widgetURL, reporting the source and id. - Cold start: Report the count of
Activity.activities(to see if too many activities have accumulated).
Server side:
- Use uid to look up the token, see what type it is, how long it's been registered.
- Check the records of APNs calls, see if the interface reported errors.
- Check how long the token has been registered, if the fields are correct, if the mapping of attributes-type and bundleId is correct, etc.
- Bypass the server, use curl to directly send the client's token to APNs.
- Take the server's
content-stateand decode it on the client usingJSONDecoder. If there's an error, you can clearly see which field is the problem.
Just ensure every critical link has logs to review, and you can basically pinpoint where the chain broke.
Final Words
Looking back at the whole article, the core is really one sentence: What runs on the Dynamic Island is not your App, but the system. All you can do is hand over the styles and model to the system in advance (Widget Extension), and then send messages to the system via tokens (APNs).
Let's review the entire chain again:
- Configure Push capability and Info switch, create a new Widget Extension.
- Define Attributes / ContentState, align across Main App, Extension, and Server.
- Get tokens based on iOS version, report them by type.
- Server prioritizes update, falls back to start on failure, cleans up promptly.
- Leave the rest to system rendering. If there are problems, troubleshoot segment by segment along the chain.
The Dynamic Island's position is very prominent. Even phones without a cutout, i.e., phones without a Dynamic Island, can still receive expanded Dynamic Island notifications and lock screen cards. But pushing too much annoys users, so what content to push and at what frequency also needs careful consideration.
Feel free to reach out with any questions.
Also, here's the demo.
Top 2 from juejin.cn, machine-translated. The original thread is authoritative.
I bow to you, master.
Fucking awesome.