[TIL] Live Activity (ActivityKit, Dynamic Island, 잠금화면)

Eden·2025년 9월 15일

iOS 16.1부터 앱은 Live Activity를 통해 진행 중인 일을 잠금화면과 Dynamic Island(아이폰 14 Pro 이상)에서 실시간으로 보여줄 수 있다. 배달 상태, 운동 기록, 타이머, 승차 정보처럼 짧은 기간 동안 변하는 정보를 사용자에게 계속 노출하는 데 적합하다.


1. 구성 요소 한눈에 보기

  • ActivityKit
    • Attributes: 활동의 변하지 않는 메타데이터. 예: 주문 ID, 사용자명.
    • ContentState: 시간에 따라 바뀌는 상태. 예: 남은 시간, 진행률, 배달 ETA.
    • Activity: 한 번 시작하면 업데이트/종료가 가능한 단일 인스턴스.
  • Widget Extension
    • ActivityConfiguration에서 잠금화면/섬 표시 UI를 SwiftUI로 정의한다.
    • Dynamic Island의 compact/expanded/minimal 여러 크기 레이아웃을 제공한다.
  • 업데이트 방식
    • 로컬 업데이트: 앱/앱 확장에서 상태 변경.
    • 푸시 업데이트: 서버→APNs→기기로 Live Activity를 갱신.

2. 지원 버전과 설정

  • iOS 16.1 이상
  • Xcode 14.1 이상
  • 프로젝트 설정
    1) Widget Extension 추가 (SwiftUI 기반)
    2) Target → Signing & Capabilities
    • Live Activities 추가
    • 푸시로 갱신하려면 Push NotificationsBackground Modes > Remote notifications
      3) Info.plist
    • 확장(Widget) 타겟에 Activity 관련 설정이 포함됨

3. 모델 정의: Attributes와 ContentState

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
}
  • Attributes는 한 Activity 수명 동안 변하지 않는 값을 담는다.
  • ContentState는 자주 바뀌는 값(상태/진행률/남은 시간 등)을 담는다.

4. UI 정의: ActivityConfiguration

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)
        }
    }
}
  • Dynamic Island는 expanded / compactLeading / compactTrailing / minimal 4영역을 구성한다.
  • 잠금화면과 섬에 같은 상태(context.state)를 다른 레이아웃으로 표현한다.

5. 시작, 업데이트, 종료

시작

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 등 선택 가능

6. 푸시로 업데이트하기

토큰 확보

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()
    // 서버에 전송
}

APNs 페이로드 예시

{
  "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 로 설정해야 한다.
  • 서버에서 보낸 값은 Widget Extension의 모델(타입명)과 정확히 매칭되어야 한다.

7. 설계 팁과 제약

  • 용도
    • 몇 분~몇 시간 내의 진행 상태에 적합. 길게 유지되는 대시보드 UI 용도에는 부적합.
  • 빈도 제한
    • 배터리와 성능을 위해 업데이트 빈도가 제한된다. 초 단위 갱신은 피하고, 의미 있는 순간만 반영한다.
  • 개인정보
    • 잠금화면/섬에 노출되므로 민감 정보는 표시하지 않는다.
  • 중복
    • 같은 주제의 Live Activity를 여러 개 만들지 말고, 기존 Activity 재사용을 고려한다.
  • 타이머
    • Text(..., style: .timer)Date 기반 상대 표기 사용. 직접 초 단위 타이머를 돌리지 않는다.
  • 접근성
    • Dynamic Type, VoiceOver 라벨 제공.
  • 테스트
    • 시뮬레이터(16.1+)에서 동작 확인 가능. Dynamic Island는 iPhone 14 Pro~ 기기로 실제 확인.
    • 개발 중에는 Xcode의 Widget Preview활동 시작/업데이트 시점 로그를 적극 활용.

8. 디버깅 체크리스트

1) Capabilities에서 Live Activities가 켜져 있는가
2) Widget Extension에 ActivityConfiguration이 구현되어 있는가
3) Attributes/ContentState의 프로퍼티가 Codable/Hashable을 만족하는가
4) 시작 시 오류는 없는가 (Activity.request 예외 처리)
5) 푸시 업데이트 시

  • 기기에서 받은 pushToken을 서버에 정확히 전송했는가
  • APNs topic<bundle-id>.push-type.liveactivity 인가
  • 페이로드의 타입 문자열이 실제 타입명과 일치하는가
    6) 종료 정책이 기대와 일치하는가 (dismissalPolicy)
    7) 실제 기기에서 Dynamic Island 레이아웃이 깨지지 않는가

9. 최소 예제 요약

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)

10. 언제 Live Activity를 쓰지 말까?

  • 항상 떠 있는 위젯/대시보드가 필요한 경우
  • 장기간(며칠~몇 주) 상태 표시가 필요한 경우
  • 사용자 주의가 자주 요구되지 않는 정적 정보

이 경우에는 일반 위젯(Timeline), 알림(Notification), 또는 앱 내부 화면 개선이 더 적절할 수 있다.


결론

Live Activity는 짧은 수명, 빈번한 상태 변화를 시각적으로 이어서 보여주는 UX 도구다.
ActivityKit의 Attributes/ContentState/Activity와 Widget Extension의 ActivityConfiguration을 바르게 연결하고,
로컬/푸시 업데이트를 적절히 혼합하면 Dynamic Island와 잠금화면에서 자연스럽고 깔끔한 실시간 경험을 제공할 수 있다.

profile
iOS Dev

0개의 댓글