Antigravity × SwiftUI Live Activities & Dynamic Island: An Implementation Guide for iOS 26
A practical guide to building Live Activities and Dynamic Island with Antigravity AI: ActivityKit design, Dynamic Island UI, APNs background updates, plus production lessons from a 50M-download indie app portfolio.
Three things that hit me the first time I shipped a Live Activity
I've been shipping iOS apps as a solo developer since 2014, with a portfolio that has now passed 50 million downloads. When I first put a Live Activity into a real app and pushed it to TestFlight, three things blocked me that the documentation alone didn't really prepare me for:
The lock screen rendered cleanly, but the Dynamic Island compact view collapsed once I tried to put real text into it.
APNs payloads went out fine, but a small but stubborn percentage of updates failed silently — no error, no log.
I forgot to set a staleDate, so when users came back hours later the lock screen still showed "ETA: 3 min" as if it were live, and I got complaints.
None of these were really called out in the official guides, and Xcode's console stayed quiet through most of it. Somewhere in the middle of debugging I asked Antigravity Planning Mode to "add Live Activities to an existing delivery-tracking app that already has a WidgetKit widget", and what came back was an end-to-end plan covering targets, file layout, APNs payload shape, and stale handling. That plan finally gave me a clean structure to work in.
This guide is meant to take you from zero to a Live Activity that's safe to ship in one or two days. It's written in the order that I actually hit each problem in production. iOS 26 has relaxed the update frequency limits, and Xcode 26's Dynamic Island preview is finally stable enough to rely on, so the barrier to entry is genuinely lower than it used to be.
The expected reader is an iOS developer who is comfortable with SwiftUI. Some WidgetKit background helps, but I start from ActivityAttributes so it's still followable if Live Activities is your first stop after SwiftUI.
Chapter 1: ActivityKit Design and Data Modeling
1-1. Live Activities Architecture
Live Activities are technically an extension of WidgetKit. The key differences from regular widgets: data can be updated in real time, and the lifecycle is controlled by user interaction and your app.
The architecture has three layers:
App target (ActivityKit): Starts, updates, and ends the Live Activity
Widget Extension: Defines lock screen and Dynamic Island UI using SwiftUI
ActivityAttributes protocol: Separates static from dynamic data
1-2. Designing ActivityAttributes
The most important concept in ActivityAttributes is the separation of static data (attributes) and dynamic data (ContentState).
import ActivityKit// Food delivery tracker examplestruct DeliveryAttributes: ActivityAttributes { // Static data — fixed when the Live Activity starts public struct ContentState: Codable, Hashable { // Dynamic data — can be changed via update() var status: DeliveryStatus var estimatedArrival: Date var currentLocation: String var progressPercentage: Double // 0.0 – 1.0 } // Static fields (attributes) var orderID: String var restaurantName: String var itemSummary: String}enum DeliveryStatus: String, Codable { case preparing = "Preparing" case pickedUp = "Picked Up" case nearBy = "Nearby" case delivered = "Delivered"}
Prompt Antigravity: "Design an ActivityAttributes struct for a food delivery Live Activity" — it will generate scaffolding like this. Follow up with "Verify Codable conformance and add the == operator required for Hashable" to get production-ready code with minimal compilation errors.
1-3. Effective Antigravity Prompts for Model Design
Here's a prompt pattern that works especially well:
Prompt to Antigravity:
"Design an ActivityAttributes for Live Activities in SwiftUI.
Requirements:
- App: FoodRunner (food delivery)
- Static data: order ID, restaurant name, items summary
- Dynamic data: delivery status, estimated arrival Date, current location string, progress percentage
- Compatible with iOS 26's new pushToken update flow
- Include Sendable conformance"
Code generated from this prompt compiles cleanly and handles iOS 26 API changes. In practice, this approach can cut model design iteration time by 70% or more.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A continuous walk-through from ActivityAttributes and ContentState design, through Dynamic Island's three display modes, all the way to APNs background updates
✦Production lessons that are not in the official docs: silent APNs delivery failures, stale presentation, and pushToken rotation, each grounded in concrete numbers from my own apps
✦How I use Antigravity Planning Mode and Sandbox for Live Activities, including the exact prompt templates, review checklist, and App Store review notes I rely on
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
{ "aps": { "timestamp": 1712000000, "event": "update", "content-state": { "status": "pickedUp", "estimatedArrival": "2026-04-03T12:30:00Z", "currentLocation": "3rd Ave, near Oak St", "progressPercentage": 0.6 }, "stale-date": 1712003600, "alert": { "title": "Your order has been picked up", "body": "Estimated arrival in ~15 minutes" } }}
Chapter 3: Dynamic Island UI Design
Dynamic Island, available on iPhone 14 Pro and later, is an interactive display area that replaces the notch. Mastering all three display modes is key.
3-1. Three Display Modes
struct DeliveryLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in // Lock screen / StandBy display LockScreenView(context: context) } dynamicIsland: { context in DynamicIsland { // Expanded — user tapped the island DynamicIslandExpandedRegion(.leading) { ExpandedLeadingView(context: context) } DynamicIslandExpandedRegion(.trailing) { ExpandedTrailingView(context: context) } DynamicIslandExpandedRegion(.bottom) { ExpandedBottomView(context: context) } DynamicIslandExpandedRegion(.center) { ExpandedCenterView(context: context) } } compactLeading: { // Compact left — shown when no other activities are active Image(systemName: deliveryIcon(for: context.state.status)) .foregroundStyle(.orange) } compactTrailing: { // Compact right Text(context.state.progressPercentage.formatted(.percent.precision(.fractionLength(0)))) .font(.caption2.bold()) .foregroundStyle(.white) } minimal: { // Minimal — shown when another Live Activity is also active Image(systemName: "bag.fill") .foregroundStyle(.orange) } .keylineTint(.orange) } } func deliveryIcon(for status: DeliveryStatus) -> String { switch status { case .preparing: return "fork.knife" case .pickedUp: return "bicycle" case .nearBy: return "location.fill" case .delivered: return "checkmark.circle.fill" } }}
Stored property 'orderID' of 'Sendable'-conforming struct has non-sendable type 'String'?
Ask Antigravity: "Fix the Sendable conformance errors in my ActivityAttributes" — it will suggest @unchecked Sendable or proper isolation patterns for the specific type.
Error 2: Missing NSSupportsLiveActivities in Info.plist
Without this key in your Widget Extension target, Activity.request() will silently fail. Ask Antigravity: "What Info.plist keys do I need for Live Activities?" to get a step-by-step setup guide.
Error 3: Stale staleDate causing immediate "outdated" display
Setting staleDate to a past date makes the Live Activity instantly show stale UI. Ask Antigravity: "How should I calculate staleDate for a delivery tracking app that updates every 2 minutes?" for a formula tailored to your update frequency.
Note: withAnimation can be restricted inside Widget Extensions. Ask Antigravity: "What animation techniques are safe inside a Live Activity Widget Extension?" to get ContentTransition-based alternatives.
Chapter 5: Production Readiness and Testing
5-1. Simulator vs. Device Testing
Live Activities can be validated in the simulator, but Dynamic Island testing requires a physical device. Use this debug helper to rapidly simulate state transitions:
#if DEBUGclass LiveActivityTestHelper { static func simulateDeliveryProgress() async { guard let activity = Activity<DeliveryAttributes>.activities.first else { return } let states: [(DeliveryStatus, String, Double)] = [ (.preparing, "Preparing at restaurant", 0.1), (.pickedUp, "Oak Street, heading your way", 0.5), (.nearBy, "200m from your location", 0.9), (.delivered, "Delivered!", 1.0) ] for (status, location, progress) in states { let state = DeliveryAttributes.ContentState( status: status, estimatedArrival: Date().addingTimeInterval(Double(states.count) * 60), currentLocation: location, progressPercentage: progress ) await activity.update(ActivityContent(state: state, staleDate: nil)) try? await Task.sleep(nanoseconds: 2_000_000_000) } }}#endif
5-2. Production Error Handling
func startActivityWithRetry(order: Order, maxRetries: Int = 3) async throws -> Activity<DeliveryAttributes> { var lastError: Error? for attempt in 1...maxRetries { do { return try await LiveActivityManager.shared.startDeliveryActivity(order: order) } catch let error as ActivityAuthorizationError { if case .denied = error { throw LiveActivityError.notAuthorized } lastError = error if attempt < maxRetries { try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000) } } catch { lastError = error if attempt < maxRetries { try await Task.sleep(nanoseconds: 1_000_000_000) } } } throw lastError ?? LiveActivityError.activityNotFound}
5-3. App Store Review Checklist
Common Live Activities review pitfalls:
Justify high-frequency updates: If using NSSupportsLiveActivitiesFrequentUpdates, prepare a clear reviewer note explaining why frequent updates are necessary
Battery consideration: Set staleDate appropriately to signal the system when data is fresh vs. stale
Device guidance in UI: On iPhone 14 and earlier, only the lock screen is shown — communicate this to users in your onboarding
Privacy: Data shown on the lock screen is visible to anyone holding the device — consider sensitivity of displayed information
Cleanup on deletion: Implement endAllActivities() when appropriate so orphaned Live Activities don't persist after uninstall scenarios
Chapter 6: Advanced Patterns
6-1. Managing Multiple Live Activities
iOS 26 allows more simultaneous Live Activities. Here's a pattern for managing them at scale:
Adding Live Activities to an existing UIKit app is a common scenario Antigravity handles well:
class OrderDetailViewController: UIViewController { private var liveActivityTask: Task<Void, Never>? func startTracking(order: Order) { liveActivityTask = Task { do { let activity = try await startActivityWithRetry(order: order) for await state in activity.activityStateUpdates { await MainActor.run { self.updateUI(for: state) } } } catch { await MainActor.run { self.showError(error) } } } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) liveActivityTask?.cancel() } func updateUI(for state: ActivityState) { switch state { case .active: trackingStatusLabel.text = "Live tracking active" case .stale: trackingStatusLabel.text = "Information may be outdated" case .ended, .dismissed: trackingStatusLabel.text = "Tracking ended" @unknown default: break } }}
Production lessons that aren't in the official docs
Most of the implementation guide above is reachable from WWDC sessions and the official Apple Developer Documentation. The set of things you only discover after shipping is different. Below are five lessons that paid off in my own apps (wallpaper apps, ambient/healing apps, and manifestation apps — together past 50M downloads) along with the rough numbers I observed.
7-1. APNs Live Activity updates fail more often than you'd expect
Silent failure on server-sent Live Activity updates is easy to underestimate during design review, but in production I saw roughly 3–5% of updates drop silently. When I instrumented one of my apps and broke down the causes, the distribution looked roughly like this:
Missing apns-priority: 10 (so deliveries weren't immediate) — about 40%
Codable mismatch on content-state (camelCase vs snake_case key drift) — about 30%
Missing apns-push-type: liveactivity header — about 15%
Server still holding an old pushToken after rotation — about 10%
Other (transient APNs errors) — about 5%
The conclusion after a year of running this is: assume Live Activity updates are lossy and design accordingly. I no longer use a Live Activity update as the single channel for important information. Anything that matters is also delivered as a regular notification, or is re-fetchable when the user opens the app.
// ❌ Relying only on the Live Activity updatefunc notifyDeliveryArrival(activity: Activity<DeliveryAttributes>) async { try? await activity.update(using: .init(status: .arrived, eta: 0))}// ✅ Live Activity update + regular notification fallbackfunc notifyDeliveryArrival(activity: Activity<DeliveryAttributes>) async { try? await activity.update(using: .init(status: .arrived, eta: 0)) let content = UNMutableNotificationContent() content.title = "Your delivery has arrived" content.body = "Left at your front door." content.sound = .default let request = UNNotificationRequest( identifier: "delivery_arrived_\(activity.id)", content: content, trigger: nil ) try? await UNUserNotificationCenter.current().add(request)}
7-2. Without staleDate, "the past as the present" leaks in
This was my first-release mistake. I left staleDate as nil, and when users came back hours later, the lock screen still showed "ETA: 3 min" as if the data were current.
A few support emails arrived along the lines of "It's been hours, why is it still showing as out for delivery?" That's when I learned what staleDate is really for. It tells the system, "after this point the data is no longer trustworthy", and from that moment you can switch the lock screen UI into a "stale" presentation.
// Set staleDate to the maximum useful lifetime of this Activitylet staleDate = Date().addingTimeInterval(60 * 60) // 1 hourlet content = ActivityContent( state: DeliveryAttributes.ContentState(status: .delivering, eta: 15), staleDate: staleDate, relevanceScore: 50)let activity = try Activity<DeliveryAttributes>.request( attributes: attributes, content: content, pushType: .token)
On the lock screen side, I read context.isStale and swap the layout. I usually go for a greyed-out card with a "Refreshing…" hint:
ActivityConfiguration(for: DeliveryAttributes.self) { context in if context.isStale { DeliveryStaleView() // greyed out + "refreshing" hint } else { DeliveryLockScreenView(state: context.state) }}
Adding this alone cut the "it's not updating" support volume to roughly one-third of what it was before.
7-3. pushToken rotates more often than you think
Activity.pushTokenUpdates streams the pushToken every time a Live Activity is started. The docs note "the token may rotate" almost in passing, but in practice rotation also happens mid-activity, not only at startup.
In my apps, every token that arrives is POSTed straight to the server as an upsert. If the server keeps using an old token, every update to that Activity goes silently into the void.
Task { for await tokenData in activity.pushTokenUpdates { let token = tokenData.map { String(format: "%02x", $0) }.joined() try await DeliveryAPIClient.shared.upsertPushToken( activityId: activity.id, token: token, appVersion: Bundle.main.appVersion ) }}
On the server side I key by activity.id, and whenever a new token arrives the old one is discarded. In my stack this is Cloudflare Workers + KV with a key like activity_token:{activity_id} and a 24-hour TTL.
7-4. Treat Dynamic Island compact mode as "one icon, one number"
This is more of a design point than a coding one, but Dynamic Island's compact area is small — somewhere around 16–20pt of usable width per side in practice. I see a lot of attempts to fit "ETA 15min" or similar composite expressions into it, and they tend to collapse.
In one of my wallpaper apps the user feedback shifted noticeably the moment I committed to one element per side, at most three characters:
DynamicIsland { // expanded — this is where you actually convey information ...} compactLeading: { Image(systemName: "shippingbox.fill") // one icon, nothing else .foregroundStyle(.blue)} compactTrailing: { Text("15m") // a number + one short unit .monospacedDigit() .font(.caption)} minimal: { Image(systemName: "shippingbox.fill") // smallest mode = icon only}
Sticking to "a number plus a single-letter unit" keeps the compact mode legible, and the minimal mode (which gets used when multiple Live Activities are competing for the same Dynamic Island) degrades much more gracefully.
7-5. App Store review will ask about "when does this end?"
In my experience, reviews of apps containing a Live Activity are very likely to ask: "After how long does this Live Activity end on its own?" and "Where can the user manually stop it?". Having the following written explicitly in the review notes has, anecdotally, lowered my rejection rate noticeably:
The time horizon after which staleDate causes the activity to flip into the "stale" presentation
Where Activity.end(_:dismissalPolicy:) is called from in the codebase
Whether the lock screen UI offers a "stop" button, and if not, where in the app the user can stop it
If there is no obvious user-driven way to end a Live Activity, reviewers have been flagging this under Guideline 4.1 in 2026. A safe baseline is to keep an in-app screen that lists "currently running Live Activities" with a per-row dismiss control:
How I actually use Antigravity Planning Mode and Sandbox
A Live Activity feature touches three layers — app target, Widget Extension, server-side APNs — so I lean on Antigravity Planning Mode as a map for which file to write first, rather than a code generator.
8-1. The prompt template I keep reusing for Planning Mode
I'm a solo iOS developer. I want to add a Live Activity to an existing SwiftUI app (iOS 17+ target).
Requirements:
- Delivery tracking (status: pending / delivering / completed, ETA in minutes)
- Backend: Cloudflare Workers + KV
- Existing WidgetKit home-screen widget
Please plan with these in mind:
1. Files to add (per Xcode target)
2. ActivityAttributes / ContentState field design
3. Required APNs headers and payload shape
4. staleDate policy
5. Test strategy (simulator, real device, sandbox)
6. App Store review angles to anticipate
I never ship the resulting plan straight to code. The human-side step I always add is to verify that the plan accounts for the production lessons in 7-1 through 7-5. Antigravity's plan is based on the official docs, which means it does not include "this fails 3–5% of the time" as a built-in assumption. That's the kind of edge knowledge I add manually.
8-2. A Sandbox checklist I run on every implementation pass
When I hand a work-in-progress to Antigravity Sandbox to look over, I paste the same checklist every time:
Does ActivityAttributes.ContentState satisfy both Codable and Sendable?
Do the JSON key names in the APNs content-state match the Swift ContentState's CodingKeys?
Is the pushTokenUpdates subscription properly retained inside a Task?
Is staleDate set, and if not, is that intentional?
Are the compact and minimal Dynamic Island modes legible on an actual iPhone 14 Pro or newer?
Running this five-item check through Sandbox catches roughly 80% of the regressions I would otherwise spot only during device QA.
8-3. Coexisting with an existing WidgetKit widget
For an app that already has a home-screen widget, what's worked best for me is to collapse everything into a single WidgetBundle rather than spinning up a new target for the Live Activity:
@mainstruct AppWidgets: WidgetBundle { var body: some Widget { // existing home-screen widget DeliveryHomeWidget() // Live Activity added on top DeliveryLiveActivity() }}
Bundling them keeps you from duplicating Stripe / Firebase / KeychainAccess dependencies across multiple extension targets, which adds up surprisingly fast on a long-running indie app.
Metrics I keep on the dashboard
When I run an app with a Live Activity I always keep this set of metrics visible on a shared Mixpanel + Firebase board. Cross-referenced against AdMob revenue, apps where Live Activities were introduced retained between 1.1× and 1.3× better on average.
Live Activity starts / DAU — share of DAU that started at least one Live Activity that day
Average active duration — cumulative time in Activity.activityState == .active
APNs update success rate — server-side; anything other than 200 OK counted as failure
Rate of transition into stale presentation — emit an event from the app the first time context.isStale == true
Manual end rate vs. automatic end rate — what fraction of activities were ended by the user
Metrics 4 and 5 turned out to be the most useful for validating the design. A very high transition-to-stale rate means the staleDate budget was set too tightly (or too loosely); a very low manual-end rate hints that the activity probably outstays its welcome.
Next steps
A Live Activity occupies two of the most visible surfaces on iOS — the lock screen and the Dynamic Island. That makes it worth spending several times more design time on "how much information is OK to live there permanently" and "how does it end" than on the implementation itself.
If you want a single concrete next move, pick one of these:
If you already have a home-screen widget, add a Live Activity to your WidgetBundle that displays just one status field.
Pick one of your existing notifications that has a timeline, and replace it with a Live Activity.
Paste the prompt template in 8-1 into Antigravity Planning Mode and have it produce a plan tailored to your own app.
To deepen the WidgetKit angle, the WidgetKit home-screen widget guide is a natural follow-on. To make the content shown in your Live Activity smarter, On-device AI with Core ML is the article I usually point people to next.
There are very few books that go deep on Live Activities specifically, so most of the operational knowledge above is the kind of thing you accumulate by shipping. I'm still finding "I wish I had done this differently" moments months after release, and I suspect you will too — that's part of the territory. If even one of the lessons here saves a sprint of yours, that's the best outcome I could ask for. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.