iOS 16.1부터 앱은 Live Activity를 통해 진행 중인 일을 잠금화면과 Dynamic Island(아이폰 14 Pro 이상)에서 실시간으로 보여줄 수 있다. 배달 상태, 운동 기록, 타이머, 승차 정보처럼 짧은 기간 동안 변하는 정보를 사용자에게 계속 노출하는 데 적합하다.
ActivityConfiguration에서 잠금화면/섬 표시 UI를 SwiftUI로 정의한다.import ActivityKit
struct OrderAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var status: String // 예: "접수", "조리중", "배달중"
var eta: Date? // 예상 도착 시간
var progress: Double // 0.0 ~ 1.0
}
// Attributes(고정값)
var orderID: String
var storeName: String
}
Widget Extension의 YourWidget.swift에서 Live Activity UI를 구성한다.
import WidgetKit
import SwiftUI
import ActivityKit
@main
struct MyWidgets: WidgetBundle {
var body: some Widget {
MyLiveActivity()
}
}
struct MyLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OrderAttributes.self) { context in
// 잠금화면(활동 카드) / Always-On 영역
VStack(alignment: .leading) {
Text(context.attributes.storeName)
.font(.headline)
ProgressView(value: context.state.progress)
if let eta = context.state.eta {
Text("도착 예정: \(eta.formatted(date: .omitted, time: .shortened))")
.font(.subheadline)
}
Text("상태: \(context.state.status)")
.font(.subheadline)
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
// Expanded
DynamicIslandExpandedRegion(.leading) {
Text("주문 \(context.attributes.orderID)")
}
DynamicIslandExpandedRegion(.center) {
VStack {
Text(context.attributes.storeName)
ProgressView(value: context.state.progress)
}
}
DynamicIslandExpandedRegion(.trailing) {
if let eta = context.state.eta {
Text(eta, style: .time) // 카운트다운
}
}
DynamicIslandExpandedRegion(.bottom) {
Text("상태: \(context.state.status)")
}
} compactLeading: {
Image(systemName: "bag")
} compactTrailing: {
if let eta = context.state.eta {
Text(eta, style: .timer)
}
} minimal: {
Image(systemName: "bag")
}
// 기본 속성
.keylineTint(.accentColor)
}
}
}
func startOrderActivity(orderID: String, storeName: String) throws -> Activity<OrderAttributes>? {
let attributes = OrderAttributes(orderID: orderID, storeName: storeName)
let content = OrderAttributes.ContentState(status: "접수", eta: nil, progress: 0.1)
let activity = try Activity<OrderAttributes>.request(
attributes: attributes,
contentState: content,
pushType: .token // 푸시 업데이트가 필요 없다면 nil
)
return activity
}
pushType: .token을 주면 푸시 업데이트 토큰이 발급된다. 서버에 전달해야 한다.func updateOrderActivity(_ activity: Activity<OrderAttributes>,
status: String, eta: Date?, progress: Double) {
let updated = OrderAttributes.ContentState(status: status, eta: eta, progress: progress)
Task { await activity.update(using: updated) }
}
func endOrderActivity(_ activity: Activity<OrderAttributes>, success: Bool) {
let final = OrderAttributes.ContentState(status: success ? "완료" : "취소", eta: nil, progress: 1.0)
Task { await activity.end(using: final, dismissalPolicy: .immediate) }
}
dismissalPolicy: .immediate 즉시 숨김, .after(Date), .default 등 선택 가능let activity = try Activity<OrderAttributes>.request(
attributes: attributes,
contentState: initialState,
pushType: .token
)
for await token in activity.pushTokenUpdates {
let hex = token.map { String(format: "%02x", $0) }.joined()
// 서버에 전송
}
{
"aps": {
"timestamp": 1737000000,
"event": "update"
},
"content-state": {
"status": "배달중",
"progress": 0.7,
"eta": "2025-09-15T11:40:00Z"
},
"attributes-type": "OrderAttributes",
"content-state-type": "OrderAttributes.ContentState",
"dismissal-date": null
}
apns-topic을 <bundle-id>.push-type.liveactivity 로 설정해야 한다.Text(..., style: .timer)나 Date 기반 상대 표기 사용. 직접 초 단위 타이머를 돌리지 않는다.1) Capabilities에서 Live Activities가 켜져 있는가
2) Widget Extension에 ActivityConfiguration이 구현되어 있는가
3) Attributes/ContentState의 프로퍼티가 Codable/Hashable을 만족하는가
4) 시작 시 오류는 없는가 (Activity.request 예외 처리)
5) 푸시 업데이트 시
<bundle-id>.push-type.liveactivity 인가dismissalPolicy)1) 모델
struct OrderAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var status: String
var progress: Double
}
var orderID: String
}
2) 시작
let attr = OrderAttributes(orderID: "A-1001")
let state = OrderAttributes.ContentState(status: "접수", progress: 0.1)
let activity = try Activity.request(attributes: attr, contentState: state, pushType: .token)
3) 업데이트
await activity?.update(using: .init(status: "배달중", progress: 0.7))
4) 종료
await activity?.end(using: .init(status: "완료", progress: 1.0), dismissalPolicy: .immediate)
이 경우에는 일반 위젯(Timeline), 알림(Notification), 또는 앱 내부 화면 개선이 더 적절할 수 있다.
Live Activity는 짧은 수명, 빈번한 상태 변화를 시각적으로 이어서 보여주는 UX 도구다.
ActivityKit의 Attributes/ContentState/Activity와 Widget Extension의 ActivityConfiguration을 바르게 연결하고,
로컬/푸시 업데이트를 적절히 혼합하면 Dynamic Island와 잠금화면에서 자연스럽고 깔끔한 실시간 경험을 제공할 수 있다.