Beyond Standard Alerts: Shifting Expensive UI to Live Activities with ActivityKit
[Setup APNs & FCM Tokens]
[iOS Push Notifications: APNs and FCM]
[Timing the Permission Prompt: Ask When the User Actually Needs ]
[Delegation Done Right: willPresent, didReceive, and didRegisterForRemoteNotificationsWithDeviceToken]
[Configuring Notification Service & Live Activities]
The modern approach shifts these expensive, continuous state updates out of standard notification alerts and channels them directly into Live Activities via ActivityKit.
By processing silent push events in a central manager, you can capture changing states in the background and present a persistent, beautifully rendered real-time tracking interface directly on the Lock Screen and Dynamic Island.
1. Defining the Shared Data Architecture
Every Live Activity requires an explicitly structured data contract to pass information across your application targets. This is composed of Attributes and ContentState.
What are ActivityAttributes?
ActivityAttributes is a protocol that defines the data structure of your Live Activity. To optimize performance and battery life, Apple splits this structure into two distinct data categories: Static Data and Dynamic Data.
1. Static Data (The Properties)
Any property you declare directly inside your ActivityAttributes struct is considered static. These variables are sent only once when the Live Activity is initialized. They cannot change throughout the life of the activity.
2. Dynamic Data (ContentState)
Inside your attributes struct, you are required to define a nested struct called ContentState. This is where your dynamic datalives. Any property inside ContentState can be updated continuously over the network (via APNs push payloads) or locally from your main app.
Because ActivityKit needs to marshal this data between your background push extension and the device’s home screen layout engine, your metrics model must conform to Codable.
import ActivityKit
import WidgetKit
import SwiftUI
struct ShyawayEventsAttributes: ActivityAttributes {
public typealias WaitlistStatus = ContentState
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var pushData: PushData
}
// Fixed non-changing properties about your activity go here!
let activityName: String
let layout: LayoutConfig
}Building a Core Codable State Object
Your data payload model serves a dual purpose: it acts as a lightweight, codable transport model over the network while simultaneously behaving as an state representation for your widget's UI components.
final class PushData: Codable, Equatable, Hashable {
let title: String?
let subText: String?
let body: String?
let eventImage: String?
let thumbImage: String?
let clickAction: String?
let offerExpiresAt: Date?
let actions: [PushAction?]?
var activityImageURL: String? = nil
var eventImageURL: String? = nil
var formattedReminderDate: String
var isExpired: Bool {
switch offerExpiresAt {
case .some(let date):
let now = Date()
return date < now
case .none:
return true
}
}
enum CodingKeys: String, CodingKey {
case title
case subText = "subText"
case body
case clickAction = "deep_link"
case offerExpiresAt = "offer_expires_at"
case actions
case eventImage = "push_icon"
case thumbImage = "push_image"
case activityImageURL
case eventImageURL
}
// MARK: - Initializer / Decodable
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = container.safeDecodeValue(forKey: .title)
self.subText = container.safeDecodeValue(forKey: .subText)
self.body = container.safeDecodeValue(forKey: .body)
self.eventImage = container.safeDecodeValue(forKey: .eventImage)
self.clickAction = container.safeDecodeValue(forKey: .clickAction)
self.thumbImage = container.safeDecodeValue(forKey: .thumbImage)
// Decode image data
self.activityImageURL = container.safeDecodeValue(forKey: .activityImageURL)
self.eventImageURL = container.safeDecodeValue(forKey: .eventImageURL)
// Handle Date decoding (Supports String ISO8601 or Double timestamp)
if let dateString = try? container.decodeIfPresent(String.self, forKey: .offerExpiresAt), !dateString.isEmpty {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: dateString) {
self.offerExpiresAt = date
} else {
formatter.formatOptions = [.withInternetDateTime]
self.offerExpiresAt = formatter.date(from: dateString)
}
} else {
self.offerExpiresAt = nil
}
self.formattedReminderDate = Date().formatted(.dateTime.hour().minute())
self.actions = try? container.decodeIfPresent([PushAction?].self, forKey: .actions)
}
// MARK: - Encodable
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(title, forKey: .title)
try container.encodeIfPresent(subText, forKey: .subText)
try container.encodeIfPresent(body, forKey: .body)
try container.encodeIfPresent(eventImage, forKey: .eventImage)
try container.encodeIfPresent(clickAction, forKey: .clickAction)
try container.encodeIfPresent(thumbImage, forKey: .thumbImage)
try container.encodeIfPresent(actions, forKey: .actions)
// Encode image data
try container.encodeIfPresent(activityImageURL, forKey: .activityImageURL)
try container.encodeIfPresent(eventImageURL, forKey: .eventImageURL)
if let expiry = offerExpiresAt {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
try container.encode(formatter.string(from: expiry), forKey: .offerExpiresAt)
}
}
// MARK: - Equatable Conformance
static func == (lhs: PushData, rhs: PushData) -> Bool {
return lhs.title == rhs.title &&
lhs.subText == rhs.subText &&
lhs.body == rhs.body &&
lhs.eventImage == rhs.eventImage &&
lhs.clickAction == rhs.clickAction &&
lhs.offerExpiresAt == rhs.offerExpiresAt &&
lhs.actions == rhs.actions &&
lhs.thumbImage == rhs.thumbImage &&
lhs.activityImageURL == rhs.activityImageURL &&
lhs.eventImageURL == rhs.eventImageURL
}
// MARK: - Hashable Conformance
func hash(into hasher: inout Hasher) {
hasher.combine(title)
hasher.combine(subText)
hasher.combine(body)
hasher.combine(eventImage)
hasher.combine(clickAction)
hasher.combine(offerExpiresAt)
hasher.combine(actions)
hasher.combine(thumbImage)
hasher.combine(activityImageURL)
hasher.combine(eventImageURL)
}
}2. Orchestrating States via LiveActivityManager
Creating a Live Activity
When you look at the actual creation call insidecreateEventsActivity, everything comes down to three parameters passed into
Activity<ShyawayEventsAttributes>.request(...), and each one plays a distinct role in how the Live Activity behaves once it's live.
Get Hassan’s stories in your inbox
Join Medium for free to get updates from this writer.
The first parameter, attributes, represents the static configuration of the activity
The second parameter, content, is where the dynamic, mutable part of the Live Activity lives. It's built using ActivityContent.init(state:staleDate:), and it bundles together two important pieces.
The state holds the actual data the user sees and that can change throughout the activity's lifetime
Alongside the state, there’s the staleDate, which tells the system when this particular version of the content should be considered outdated.
The third parameter, pushType, controls how this Live Activity can be updated going forward. Setting it to .none means the activity will only ever be updated locally
func createEventsActivity(payload: PushData) {
if ActivityAuthorizationInfo().areActivitiesEnabled {
Task {
await endAllPushActivities(for: payload.clickAction)
let attributes = ShyawayEventsAttributes(
activityName: "PushActivity",
layout: LayoutConfig.liveActivity()
)
let initialContentState = ShyawayEventsAttributes.ContentState(pushData: payload)
let staleDate: Date = payload.offerExpiresAt ?? Date()
do {
let activity = try Activity<ShyawayEventsAttributes>.request(
attributes: attributes,
content: .init(
state: initialContentState,
staleDate: staleDate < Date()
? .none
: payload.offerExpiresAt
),
pushType: .none //.push
)
print("Live activity started: \(activity.id)")
} catch let error {
print("Live activity not started: \(error)")
await endAllPushActivities(for: payload.clickAction)
}
}
}
}Updating Live Activity
filtering activities byclickAction— shows up consistently acrosshandlePush,createEventsActivity,updateActivity, andendAllPushActivities. As part of the requirement, every push payload carries a unique key that gets mapped intoclickAction, and this is intentional— it’s what allows the system to reliably identify which specific Live Activity a given push is meant to affect.
It starts by pulling the full list of currently running activities through Activity<ShyawayEventsAttributes>.activities, and then looks for the one whose clickAction matches the incoming payload's clickAction.
Once the matching activity is found, the function rebuilds the dynamic content in exactly the same shape it was originally created with.
await activity.update(updatedContent) is called, which pushes this new content to the existing Live Activity.
func updateActivity(payload: PushData) async {
let activities = Activity<ShyawayEventsAttributes>.activities
guard let activity = activities.first(where: { $0.content.state.pushData.clickAction == payload.clickAction }) else { return }
let staleDate: Date = payload.offerExpiresAt ?? Date()
let updatedState = ShyawayEventsAttributes.ContentState(pushData: payload)
let updatedContent = ActivityContent(
state: updatedState,
staleDate: staleDate < Date()
? .none
: payload.offerExpiresAt
)
await activity.update(updatedContent)
}
Ending a Live Activity
This last function,endAllPushActivities(for:), handles the cleanup side of the lifecycle — it's responsible for terminating Live Activities that match a givenclickAction.
Functionally, this method is what gets called both as a safety measure before creating a new activity
It begins the same way the other functions do, by fetching the full list of currently running activities through Activity<ShyawayEventsAttributes>.activities. There's a small early exit here too
The loop that follows iterates over each of these selectedActivities and, for each one, ends it with await activity.end(nil, dismissalPolicy: .immediate).
Passing nil as the final content means the activity ends without showing one last "final" state on screen, and .immediateas the dismissal policy means it's removed right away from the Lock Screen and Dynamic Island, rather than being left visible for a short grace period as iOS sometimes allows.
func endAllPushActivities(for clickAction: String?) async {
let activities = Activity<ShyawayEventsAttributes>.activities
guard !activities.isEmpty else { return }
let selectedActivities = activities.filter { $0.content.state.pushData.clickAction == clickAction }
for activity in selectedActivities {
if activity.content.state.pushData.clickAction == clickAction {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}Coming Up Next: Downloading Media from the Push Payload and Feeding It Into the Live Activity
So far, everything we’ve walked through assumes the Live Activity is working purely with text-based data pulled directly from the push payload. But in most real-world implementations, especially for something like an offer or event card, you’ll also want to show an image — a banner, a product photo, or some kind of visual asset tied to that push.
In the next part of this series, we’ll look at how to use a Notification Service Extension
[Downloading Media from the Push Payload: A Notification Service Extension Deep Dive]





