ActivityKit

Doldamul·2022년 10월 13일
7
post-thumbnail

이 글을 포스팅하는 시점을 기준으로 iOS 16.1 출시까지 2주 정도 남았지만, 아이폰 14 프로의 주요 기능으로 여겨지는 Dynamic Island에 대해서는 한국에서 나빼고 아무도 관심이 없는 것 같다... 그래서 내가 직접 정리해보았다. iOS 16.1에서 추가되는 Live Activity 및 Dynamic Island 적용법을 알아보자.


+ 2023/06/06 최신화. iPadOS 17 이상부터 iPad에서도 Live Activity를 사용할 수 있게 되었고, iOS 17에 새로 추가된 StandBy 모드에서도 Live Activity가 표시된다. 또한 iOS 17 및 iPadOS 17 이상부터는 위젯 및 Live Activity의 상호작용 및 애니메이션 세팅이 가능해졌다. 상호작용 및 애니메이션에 대한 자세한 설명은 상호작용 처리애니메이션 처리 챕터를 참조하자. App Intents를 통해서도 Live Activity를 시작/종료할 수 있지만, 이 글에서는 다루지 않았으므로 해당 공식 문서를 참조하자. 해당 내용들을 제외한 나머지 내용들은 여전히 iOS 16.2 이상에서 적용 가능하다.

+ 2023/01/20 최신화. 이제 iOS 16.2 기준으로 API를 설명한다. 몇몇 기존 API들이 iOS 16.3부터 deprecated 되었기 때문.(ㄷㄷ;) 또한 몇몇 설명 및 예제를 수정했으며, 대부분의 잡설을 제거했다.


WWDC23에서 관련 세션들이 공개되었다.
(이걸 이제야 해주네)

이 글에서는 다음 주제에 대해 설명하지 않는다: 귀찮다
- Displaying live data with Live Activities : Provide accessibility labels
- Displaying live data with Live Activities : Start and stop Live Activities from App Intents
- Adding interactivity to widgets and Live Activities
- ActivityKit push notifications : Set a custom dismissal date • Determine the update frequency
- enum ActivityAuthorizationError
- enum ActivityPreviewViewKind
- ActivityAttributes / previewContext(_:isStale:viewKind:)

+ 되도록이면 목차를 참조하여 전체 구조를 파악해가며 읽도록 하자. 다음은 모바일을 위한 목차다.

개요

Live Activity와 Dynamic Island

(이 글에서는 가독성을 위해 '실시간 현황' 대신 'Live Activity'로 지칭 용어를 통일한다.)
Live Activity(실시간 현황)은 애니메이션 및 업데이트가 가능한 위젯 형태의 고정 배너이다. 주기적으로 업데이트 데이터를 fetch하는 일반 위젯과 다르게 앱에서 직접 업데이트를 수행하며, 잠금화면 하단부와 Dynamic Island에 띄울 수 있다. Live Activity를 지원하기 위해서는 반드시 잠금화면 및 Dynamic Island 모두를 지원해야만 한다. iOS 17 및 iPadOS 17부터는 잠금화면 View가 StandBy 화면에도 표시된다.

Dynamic Island가 없는 기종의 경우 Live Activity가 업데이트될 때마다 화면 상단에 잠금화면 View가 알림 배너 형태로 잠시 표시된다. 다음은 아이폰 13 프로의 예시다:

Dynamic Island는 아이폰 14 프로/아이폰 14 프로 맥스부터 적용된 전면 카메라 펀치홀 및 주변부 디스플레이에서의 UI 시스템을 지칭한다. OS에서 제공되는 기본 알림들을 제외하고, 해당 시스템은 다음 3종류의 Live Activity를 아이폰 사용 맥락에 맞추어 제공한다:
(3종류를 모두 지원해야만 한다)

  • Compact Leading / Compact Trailing View
  • Minimal View
  • Expanded View

Dynamic Island는 Activity를 요청한 앱이 백그라운드에 있을 경우 기본적으로 Compact Leading / Compact Trailing View 세트를 띄운다. 2개 이상의 Activity들이 활성화될 경우 각 Activity의 우선순위 비교를 통해 선별된 두 Activity만을 Minimal View로 띄운다. Compact View/Minimal View를 길게 누르면 Expanded View로 확장된다.

Apple이 제공하는 iOS 16.* 버전의 Live Activity API는 시스템에 빌트인되어있는 Live Activity보다 기능이 제한되어 있었다. 예를 들어, iOS 16에서 타이머를 작동시킬 때 등장하는 Live Activity는 일시정지/재개 버튼과 종료 버튼이 있다. 음악 또는 영상을 재생시켰을 때 등장하는 Live Activity(*)는 앨범 커버를 토글해 확대/축소하는 기능과, 재생/일시정지 버튼과 재생헤드 슬라이더, 에어플레이 버튼까지 들어있다. 과거의 Live Activity API는 이러한 상호작용 UI를 제작하는 것이 불가능했다. 즉 Widget과 마찬가지로 딥링크만을 지원했다.

(*) 일부 시스템 UI 관련 API는 iOS 16.* 이전 버전의 Live Activity에도 상호작용 가능한 UI를 표시해주지만, 해당 UI의 커스텀은 매우 제한적이며 이 글에서는 다루지 않는다. 댓글 참고.

그외 애플 문서 본문에서 언급되는 주요 특징은 다음과 같다:

  • 앱 또는 사용자에 의해 종료되지 않은 경우 Live Activity는 최대 8시간 동안 활성화 가능. 8시간이 경과되면 시스템에 의해 자동적으로 종료된다.
  • Live Activity가 종료되면 더 이상 업데이트를 수신받을 수 없고 Dynamic Island에서 즉시 제거되지만, 잠금화면에서는 설정에 따라 최대 4시간 동안 더 표시 가능.
  • 각 Live Activity는 개별적인 샌드박스에서 구동되므로 네트워크 접근 로직/위치 추적 로직 등을 내부에서 작동시킬 수 없다. Live Activity를 업데이트하는 방법은 앱 내에서 ActivityKit으로 접근하거나, 해당 Live Activity가 remote push notification을 수신받도록 허용하는 것뿐이다.
  • ActivityKit / remote push notification을 통한 각 업데이트 데이터는 4KB를 초과할 수 없다.

UX적인 측면에 대해서 더 궁금한 게 있다면 Apple HIG: Live Activity 항목을 참조하자.


WidgetKit 워크플로우 훑어보기

Live Activity의 시작점이 WidgetConfiguration이므로, ActivityKit 사용시에는 WidgetKit 선행 이해가 요구된다.

  1. 위젯들을 작성할 Widget Extension 템플릿 생성
  2. TimelineProviderTimelineEntry로 위젯의 업데이트 데이터 및 업데이트 시점 제공
  3. Widget 프로토콜을 준수하는 구조체를 작성하여 body 프로퍼티에서 WidgetConfiguration의 종류 결정
  4. WidgetConfiguration의 modifier로 추가 속성 설정

이제 WidgetKit의 WidgetConfiguration은 3종류다:

  • StaticConfiguration : 고정된 UI 양식의 Widget.
  • IntentConfiguration :사용자 설정 가능한 UI/데이터 프로퍼티를 가진 Widget.
  • ActivityConfiguration : Live Activity.

iOS 16.1에서 새롭게 추가된 ActivityConfigurationTimelineProviderTimelineEntry 대신 ActivityAttributesContentState를 활용하여 업데이트 데이터를 작성한다.

이어지는 글에서는 Widget Extension, WidgetConfiguration, WidgetConfiguration의 modifier 등 전반적인 WidgetKit 사용법을 이해하고 있다는 가정 하에 ActivityConfiguration을 사용해 Live Activity UI를 작성하는 방법을 알아본다.

혹시 UI를 작성하는 방법에는 관심이 없고 이미 작성된 Live Activity를 앱 내에서 CRUD하는 방법만이 궁금하다면 Activity 장만 참고하시면 되겠다.


ActivityKit 워크플로우 훑어보기

Live Activity를 구현하는 방법은 Widget과 비슷하지만 몇몇 차이점이 있다.

  1. Info.plist 사전작업
  2. 위젯들을 작성할 Widget Extension 템플릿 생성
  3. ActivityAttributesActivity.ContentState 구조체 작성
  4. Widget 구조체를 작성하고 body 프로퍼티에서 ActivityConfiguration 생성
  5. WidgetConfiguration의 modifier로 추가 속성 설정
  6. 앱에서 Activity 객체를 생성하여 Live Activity CRUD 로직 작성

1번과 4번은 WidgetKit과 동일하므로 설명을 생략한다.

Info.plist 사전작업

Live Activity를 사용하기 위해서는 해당 프로젝트의 Info.plist 파일을 일부 수정해야 한다.

해당 프로젝트의 Info.plist 파일을 열고, 'Supports Live Activities'를 추가한 다음, 해당 논리값을 YES로 설정하세요. 또는, 해당 Info.plist 파일을 소스코드 형태로 열고 'NSSupportsLiveActivities' 키를 추가한 다음, 타입을 Boolean으로, 해당 값을 YES로 설정하세요. 만약 프로젝트에 Info.plist 파일이 없다면, 해당 프로젝트 파일의 iOS 앱 타겟 - Info 탭 - 'Custom iOS Target Properties' 목록에서 'Supports Live Activities'를 추가하고 값을 YES로 설정하세요.

Xcode 14에서 추가된 멀티플랫폼 프로젝트 템플릿은 멀티플랫폼 단일 타겟을 기반으로 하므로 macOS/iOS 앱 타겟이 분리되어있지 않다. 이러한 경우에는 그냥 멀티플랫폼 타겟 - Info 탭 - 'Custom macOS Application Target Properties' 목록에서 'Supports Live Activities'를 추가하고 값을 YES로 설정하면 된다.


ActivityConfiguration

struct ActivityConfiguration<Attributes> where Attributes : ActivityAttributes

Live Activity의 UI를 나타내는 최상위 객체.

init<Content>(
    for attributesType: Attributes.Type = Attributes.self,
    content: @escaping (ActivityViewContext<Attributes>) -> Content,
    dynamicIsland: @escaping (ActivityViewContext<Attributes>) -> DynamicIsland
) where Content : View

인자 attributesType이 Content 및 DynamicIsland에서 사용되는 ActivityAttributes의 타입임을 기억하자. ActivityAttributes, ActivityViewContext 등에 대해서는 잠시 후에 설명한다.

Widget Extension을 생성한 뒤, Widget 구조체 body 프로퍼티에서 ActivityConfiguration을 반환하는 Widget 구조체를 작성하면 대략 다음과 같다.

import Foundation
import SwiftUI
import WidgetKit
import ActivityKit

struct PizzaDeliveryAttributes: ActivityAttributes { ... }

@main
struct PizzaDeliveryActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
            // View 1 (잠금화면 및 Dynamic Island를 지원하지 않는 기기의 알림 배너)
        } dynamicIsland: { context in
            // View 2 (Dynamic Island)
        }
    }
}

WidgetKit을 사용할 때와 동일하게, 복수의 Widget/Live Activity를 사용할 경우 WidgetBundle 구조체에 @main 특성을 붙여 작성해야 한다.

Live Activity를 기존의 다른 Widget과 함께 사용하려면 WidgetBundle 내에서 if #avilable(iOS 16.1, *) 구문을 사용해 분리하세요.

@main
struct PizzaDeliveryWidgets: WidgetBundle {
    var body: some Widget {
        FavoritePizzaWidget()

        if #available(iOS 16.1, *) {
            PizzaDeliveryLiveActivity()
        }
    }
}

ActivityAttributes

protocol ActivityAttributes: Decodable, Encodable

Live Activity의 전체 데이터 모델을 서술할 때 준수하는 프로토콜.

Widget에서 사용했던 TimelineEntry와 유사하게, ActivityAttributes로 상태(시간에 따라 변할 수 있는 값)를 서술할 수 있다. 다만 상태만을 캡슐화하는 TimelineEntry과 다르게, ActivityAttributes는 시작될 때 값이 정해지는 상수 또한 포함하여 캡슐화한다. 상태와 상수값을 구분하기 위해, ActivityAttributes는 내부에 ContentStateassociatedtype으로 지정하여 '상태'들을 캡슐화하는 구조체의 구현을 요구한다.

associatedtype ContentState : Decodable, Encodable, Hashable

Live Activity에서 ContentState에 의해 인코딩된 동적 데이터(= 상태)는 4KB를 초과할 수 없습니다.

ActivityAttributesContentState를 작성한 예시는 다음과 같다.

struct PizzaDeliveryAttributes: ActivityAttributes {

    // typealias 구문으로 추후 ContentState를 사용할 때 가독성을 높일 수 있다.
    public typealias PizzaDeliveryStatus = ContentState
   
    // ContentState 내부에 상태 서술
    public struct ContentState: Codable, Hashable {
        var driverName: String
        var deliveryTimer: ClosedRange<Date>
    }

    // ContentState 외부에 상수 서술
    var numberOfPizzas: Int
    var totalAmount: String
    var orderNumber: String
}

ActivityAttributesContentState 타입 프로퍼티를 포함하지 않기 때문에, 잠시후 설명할 Activity 구조체의 request 함수에는 attributes 인자와 contentState 인자가 분리되어 있다.


ActivityViewContext

ActivityConfiguration 클로저에서 context 인자가 전달되는 것을 확인할 수 있다.

ActivityConfiguration(for: ...) { context in
    ...
} dynamicIsland: { context in
    ...
}

ActivityConfiguration은 Live Activity 시작 요청이 들어왔을 때 전달된 ActivityAttributesContentStateActivityViewContext 타입으로 래핑해 클로저 내부로 전달한다.

struct ActivityViewContext<Attributes> where Attributes : ActivityAttributes
  • let attributes: ActivityAttributes.
  • let state: ContentState.
  • let activityID: 해당 Live Activity의 고유 식별자 String.

context를 활용하는 View들은 새 context가 주입될 때마다 자동 업데이트된다.


Live Activity의 View와 수정자

iOS 16.x 버전에서의 Live Activity는 상호작용 가능한 View를 허용하지 않았다. 토글 버튼이나 슬라이더 등을 포함시키면 노란색 배경의 X 레터박스로 대체되어 표시되었으며, 버튼은 Label 인자에 Link를 넣어 사용할 수 있으나 action 인자는 무시되었다.

그 외에도 다음과 같은 주의사항이 존재한다:

위젯과 비슷하게, Live Activity의 전체 UI 사이즈를 명시적으로 지정하지 않아야 합니다. 시스템이 적절한 면적을 결정하도록 하세요.

잠금화면/Dynamic Island(Expanded)에서의 Live Activity가 높이 160 point를 넘을 경우 시스템이 임의로 해당 Live Activity 크기를 줄일 수 있습니다.


색상 처리

activityBackgroundTint(_:)

시스템은 어느 잠금 화면에도 잘 어울리는 기본 primary color를 Live Activity의 텍스트 및 배경색으로 사용합니다.

activityBackgroundTint(_:) View 수정자를 사용하면 Live Activity의 배경색을 원하는 색으로 지정할 수 있다.

func activityBackgroundTint(_ color: Color?) -> some View
  • color: 배경색. nil을 전달할 경우 기본 primary color를 배경색으로 사용하게 된다.

opacity(_:) View 수정자 또는 반투명한 프리셋 색상을 사용하면 배경색에 투명도를 반영할 수도 있다.

activitySystemActionForegroundColor(_:)

잠금화면에서 Live Activity를 좌측으로 스와이프하면 '지우기(dismiss)' 액션을 확인할 수 있다. Apple은 이를 '잠금화면에서 Live Activity를 종료할 수 있는 보조버튼'이라 지칭한다. 해당 버튼의 배경색 또한 activityBackgroundTint 수정자에 영향을 받기 때문에, activityBackgroundTint 수정자 뒤에 activitySystemActionForegroundColor(_:) View 수정자를 함께 사용하여 '지우기' 텍스트 색상을 지정하는 것이 일반적이다.

func activitySystemActionForegroundColor(_ color: Color?) -> some View
  • color: 텍스트 색상. nil을 전달할 경우 시스템 기본 색상(다크모드 ? 하양 : 검정)을 사용하게 된다.

(*) 추후 설명할 Dynamic Island에서는 위 두 수정자를 사용하지 않는다. 대신 compact•minimal View의 테두리색을 수정하기 위해 keylineTint(_:)를 사용할 수 있다.

isLuminanceReduced

잠금화면에 띄워지는 Live Activity는 아이폰 14 프로/프로 맥스부터 추가된 AOD(Always-On-Display)에 대응하는 것이 권장된다. 애플 워치 AOD에 대응할 때와 동일하게, isLuminanceReduced 환경 속성을 사용하여 아이폰의 AOD 여부를 감지할 수 있다.

var isLuminanceReduced: Bool { get set }
@Environment(\.isLuminanceReduced) var isAOD

var body: some View {
    Text("Live Activity View")
        .foregroundColor(isAOD ? .white : .gray)
}

AOD가 켜져 밝기가 낮아졌을 때 높은 채도로 가독성이 망가지는 일이 없도록 하자.


딥링크 처리

기본적으로, Live Activity를 터치하면 해당 앱에 진입하게 된다. Widget과 동일하게, widgetURL(_:) View 수정자를 사용하여 Live Activity에 딥링크를 걸 수 있다. 추후 설명할 DynamicIsland도 동일한 전용 수정자를 가지고 있으며, 어떤 수정자를 사용하든 모든 종류의 Live Activity View들에 일괄 적용된다.

func widgetURL(_ url: URL?) -> some View
func widgetURL(_ url: URL?) -> DynamicIsland

한 Live Activity 선언 내에 두개 이상의 widgetURL(_:) 수정자가 있는 경우, 모든 widgetURL(_:) 수정자가 무시되므로 반드시 한개만 작성되도록 주의해야 한다.

잠금화면 및 Dynamic Island의 expanded View에서는 복수의 Link View를 사용할 수도 있다.

struct Link<Label> where Label : View
Link("Deep Link", destination: URL(string: "https://www.example.com/...")!)

Live Activity 전체에 widgetURL()이 적용되어 있더라도 Link View의 영역은 LinkURL로 처리된다. Link View를 Button View로 래핑하여 buttonStyle을 적용하는 식의 활용 또한 가능하지만, 여전히 Buttonaction 클로저는 무시된다.

이렇게 전달된 URL은 앱 내에서 onOpenURL(perform:) 수정자에 등록된 이벤트 핸들러를 통해 처리할 수 있다.

func onOpenURL(perform action: @escaping (URL) -> ()) -> some View

이 글에서는 앱 내에서 딥링크를 핸들링하는 방법에 대해 자세히 다루지 않는다.

Live Activity에 명시적으로 딥링크가 제공되지 않은 경우, 시스템은 딥링크 처리를 다음 행동으로 대체합니다; 앱을 열어 다음 두 콜백 함수에 NSUserActivity 객체를 전달합니다:

  • scene(_:willContinueUserActivityWithType:)
  • scene(_:continue:)

두 콜백 함수를 구현할 때 NSUserActivity 객체의 activityTypeNSUserActivityTypeLiveActivity인지 확인하고, Live Activity의 현재 맥락에 맞는 화면을 표시하는 코드를 추가하도록 합니다.


애니메이션 처리

iOS 16.x 버전에서는 Live Activity에서 animation 기능들을 명시적으로 사용할 수 없었다.

Live Activity에서는 사용자의 animation 관련 수정자들을 전부 무시하고 시스템에서 지정한 애니메이션 타이밍 및 커브만을 사용합니다. 즉, 흔히 사용하는 withAnimation(_:_:) 함수나 animation(_:value:) 수정자를 통한 애니메이션은 불가능합니다. 하지만 ContentState를 변경시키면 시스템에서 몇 가지 기본 애니메이션을 수행합니다. Text View의 텍스트가 변경될 경우 블러처리된 content transition으로 애니메이션을 수행하고, 이미지 및 SF Symbols가 변경될 경우에도 content transition으로 애니메이션을 수행합니다. ContentState 등의 변경을 기반으로 View가 생성 또는 제거되는 경우에는 Fade-in/out transition이 수행됩니다. 사용자는 다음 구조체에 정의된 빌트인 transition을 사용 또는 조합하여 위의 기본 애니메이션을 사용자화할 수 있습니다.

  • Anytransition의 빌트인 transition
  • ContentTransition의 빌트인 content transition
    특히 ContentTransition.numericText(countsDown:)는 카운트다운/카운트업되는 타이머 텍스트를 애니메이션할 때 유용합니다.

iOS 17 및 iPadOS 17 이후 버전에서는 SwiftUI에서 사용하던 애니메이션 및 트랜지션들을 모두 사용할 수 있게 되었다. 즉, 기존의 Anytransition, ContentTransition 외에도 Animation 타입 및 올해 새로 추가된 Transition 타입까지 모두 사용할 수 있게 되었다. 단, Transaction은 사용할 수 없으므로 비슷한 효과를 주기 위해서는 ContentTransition.identity를 사용하거나 withAnimation(_:_:) 또는 animation(_:value:)nil값을 전달해야 한다.

Live Activiy 및 Widget의 애니메이션은 최대 2초까지의 지속시간을 가집니다.

AOD가 지원되는 기기에서는 배터리를 절약하기 위해 AOD 모드에서의 애니메이션이 차단됩니다. 애니메이션 로직이 수행되기 전에 SwiftUI의 isLuminanceReduced 환경 속성을 사용하여 아이폰의 AOD 모드 여부를 확인할 수 있도록 하세요.


상호작용 처리

iOS 16.* 버전에서는 Live Activity에서 가능한 상호작용이 딥링크 처리뿐이었다.
iOS 17 및 iPadOS 17 이상부터는 Live Activity에 상호작용이 가능해졌다. 즉, 앱을 열지 않고 앱의 기능만 호출하는 것이 가능해졌다. 몇 가지 참고사항을 살펴보자:

  • 기존의 딥링크 처리 방법 및 동작은 동일하다.
  • 잠금된 상태에서는 상호작용 동작이 수행되지 않으며, 잠금 해제 인증을 선 요구한다.
  • expanded View 및 잠금 화면 View에서만 상호작용 가능하다.
  • App Intents 프레임워크를 요구한다.
  • Button과 Toggle만 사용할 수 있다.

Live Activity는 앱 외부에서의 상호작용이므로, 그에 대응하고 화면을 업데이트시키기 위해서 App Intents 프레임워크와의 연동이 필요하다. 보다 자세하게는, Activity에서 상호작용 이벤트가 일어날 때마다 그에 연결된 App Intents 단축어가 실행되도록 한다. 해당 AppIntent 프로토콜 타입을 받는 생성자가 Button 및 Toggle에만 정의되어 있으므로 해당 View들로만 상호작용이 가능하다.

현재 이 글에서는 App Intents 연동 방법에 대해 자세히 다루지 않는다. 읽어보는 중인데, 이곳에 정리할지는 아직 잘 모르겠다. 일단 자세한 설명은 해당 공식문서를 참조하도록 하자.


DynamicIsland

struct DynamicIsland

DynamicIsland 구조체는 Dynamic Island에 들어가는 View들의 집합체로서 사용된다. 앞서 Dynamic Island가 compact/minimal/expanded 상태를 포괄하는 UI 시스템이라고 언급했던 것을 상기하자.

ActivityConfiguration에서의 dynamicIsland 인자는 DynamicIsland 구조체를 반환한다.

ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
    // View
} dynamicIsland: { context in
    DynamicIsland(...)
}

DynamicIsland의 생성자를 살펴보자.

init<Expanded, CompactLeading, CompactTrailing, Minimal>(
    @DynamicIslandExpandedContentBuilder expanded: @escaping () -> Expanded,
    @ViewBuilder compactLeading: @escaping () -> CompactLeading,
    @ViewBuilder compactTrailing: @escaping () -> CompactTrailing,
    @ViewBuilder minimal: @escaping () -> Minimal
) where Expanded : DynamicIslandExpandedContent, CompactLeading : View, CompactTrailing : View, Minimal : View

이를 적용해보면 DynamicIsland 생성은 다음과 같은 형태가 된다.

ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
    // View
} dynamicIsland: { context in
    DynamicIsland { // expanded
        // DynamicIslandExpandedContent
    } compactLeading: {
        // View
    } compactTrailing: {
        // View
    } minimal: {
        // View
    }
}

compactLeading/compactTrailing/minimal 인자는 모두 SwiftUI View를 반환하지만, expanded 인자는 DynamicIslandExpandedContent를 반환함에 주목하자.


DynamicIslandExpandedContent

protocol DynamicIslandExpandedContent

DynamicIslandExpandedContentDynamicIsland 생성자의 expanded 인자 전용 프로토콜이며, DynamicIslandExpandedContent를 준수하는 구조체는 DynamicIslandExpandedRegion 뿐이다.


DynamicIslandExpandedRegion

struct DynamicIslandExpandedRegion<Content>: DynamicIslandExpandedContent where Content : View

Dynamic Island의 Expanded View에서의 특정 영역 및 View를 정의하는 단위 구조체.

init(
    _ position: DynamicIslandExpandedRegionPosition,
    priority: Double = 0,
    content: () -> Content
)
  • position: Expanded View에서의 일부 영역
  • priority: expanded view가 사용될 때 해당 영역의 렌더링 우선순위. 기본값은 0이다.
  • content: SwiftUI View

DynamicIslandExpandedRegionPosition

struct DynamicIslandExpandedRegionPosition

DynamicIslandExpandedRegionposition 인자에서 요구하는 DynamicIslandExpandedRegionPosition은 Dynamic Island에서 expanded View의 각 영역을 정의하는 구조체이다.

다음과 같은 static 프로퍼티들이 정의되어 있다:

  • center: TrueDepth 카메라 바로 밑.
  • leading: TrueDepth 카메라•center 영역의 좌측. 가로폭이 큰 View들은 leading•center 영역의 하단부에서 렌더링되기도 한다.
  • trailing: TrueDepth 카메라•center 영역의 우측. 가로폭이 큰 View들은 trailing•center 영역의 하단부에서 렌더링되기도 한다.
  • bottom: center, leading, trailing 영역 밑.

DynamicIslandExpandedRegion 생성은 다음과 같은 형태로 작성하게 된다.

DynamicIsland { // expanded
    DynamicIslandExpandedRegion(.center) { /*View*/ }
} compactLeading: { ... }
compactTrailing: { ... }
minimal: { ... }

DynamicIslandExpandedContentBuilder

SwiftUI에서의 ViewBuilder와 유사하게, DynamicIsland 생성자의 expanded 인자에는 @DynamicIslandExpandedContentBuilder 특성이 적용되어 있다.

@resultBuilder struct DynamicIslandExpandedContentBuilder {
static func buildPartialBlock<C>(first: C) 
    -> some DynamicIslandExpandedContent 
    where C : DynamicIslandExpandedContent
static func buildPartialBlock<C0, C1>(accumulated: C0, next: C1) 
    -> some DynamicIslandExpandedContent 
    where C0 : DynamicIslandExpandedContent, C1 : DynamicIslandExpandedContent
}

expanded 클로저 내에 다음과 같은 선언적 구문을 사용할 수 있다.

DynamicIsland { // expanded
    DynamicIslandExpandedRegion(.center) { ... }
    DynamicIslandExpandedRegion(.leading) { ... }
    DynamicIslandExpandedRegion(.trailing) { ... }
    DynamicIslandExpandedRegion(.bottom) { ... }
} compactLeading: { ... }
compactTrailing: { ... }
minimal: { ... }

Expanded View 렌더링 규칙과 우선순위

시스템은 대략 다음과 같이 DynamicIslandExpandedRegion의 View들을 렌더링한다:
1. 명시된 center 영역이 있을 경우 center 영역의 가로폭을 결정한다. center 영역이 없을 경우 TrueDepth 카메라 밑부분에 약간의 Padding이 생긴다.
2. 명시된 leading/trailing 영역이 있을 경우 True Depth 카메라와 center 영역의 가로폭 중 긴 쪽을 염두에 두고 leading/trailing 영역의 최소 가로폭을 결정한다.
3. 잠시후 설명할 belowIfTooWide 속성이 적용된 View는 해당 View의 가로폭이 해당 영역에 주어진 최소 가로폭보다 넓은 경우 해당 영역•center 영역의 밑부분에서 렌더링된다. 기본적으로 leading/trailing 영역은 동일한 가로폭의 면적을 제공받는다.
4. 명시된 bottom 영역이 있을 경우 bottom 영역의 가로폭을 결정한다.

생성자에서 priority 인자를 통해 가장 높은 우선순위를 부여받은 영역은 다른 영역들의 최소 면적을 제외한 최대의 면적을 제공받을 수 있다. 아래는 그 예시이다.

DynamicIsland {
    DynamicIslandExpandedRegion(.leading, priority: 1.0) { ... }
    DynamicIslandExpandedRegion(.trailing) { ... }
} compactLeading: { ... }
compactTrailing: { ... }
minimal: { ... }

설명이 매끄럽지 못했는데, 위 예시가 leading 영역의 최대 면적과 trailing 영역의 최소 면적으로 계산됨을 이해한다면 두 영역에서 View가 어떻게 표시될지 이해하기 수월할 것이다.


dynamicIsland(verticalPlacement:)

func dynamicIsland(verticalPlacement: DynamicIslandExpandedRegionVerticalPlacement) -> some View

이 View 수정자는 leading/trailing 영역에 표시되는 View에 적용할 수 있다. verticalPlacement 인자는 DynamicIslandExpandedRegionVerticalPlacement 구조체를 요구한다.

struct DynamicIslandExpandedRegionVerticalPlacement: Equatable

다음과 같은 static 프로퍼티들이 정의되어 있다:

  • default : 시스템 기본값.
  • belowIfTooWide : 해당 View가 TrueDepth 카메라 측면에 위치시키기에 너무 넓은 경우 기본 위치보다 밑부분으로 보낸다.

즉 대부분의 View는 상단 측면(leading/trailing)에서 렌더링되지만, belowIfTooWide 속성이 적용된 View는 해당 View들의 아래에서 더 넓은 영역을 차지할 수 있도록 렌더링된다. 위 참고 이미지를 belowIfTooWide 속성이 적용된 View를 포함하는 leading 영역과 default View만 가진 trailing 영역으로 해석해도 이해에 도움이 될 것이다.

마지막으로, center 영역은 leading/trailing 영역보다 위에서 렌더링된다. 이말은 즉, belowIfTooWide 속성이 적용된 View는 center 영역에 의해 가려질 수 있다는 의미이므로 주의하도록 하자.


DynamicIsland 전용 수정자

DynamicIsland 구조체는 3가지 수정자를 제공한다.

  • func widgetURL(_:)
  • func keylineTint(_:)
  • func contentMargins(_:, _:, for:)

widgetURL(_:)

딥링크 처리 파트 참조.

keylineTint(_:)

compact View와 minimal View는 검정색 배경과 하얀색 텍스트를 기본값으로 사용합니다. keylineTint(_:) 수정자를 사용하면 위 두 종류의 Dynamic Island에 테두리를 추가할 수 있습니다. 예를 들어, DynamicIsland에 갈색(brown) 테두리를 적용하고 싶다면 다음과 같이 작성하면 됩니다:

DynamicIsland { ... }
compactLeading: { ... }
compactTrailing: { ... }
minimal: { ... }
.keylineTint(.brown)

위 사진은 youtube.com/Brandon Butch: iOS 16.1 Beta 5... 영상에서 확인한 것이다.

contentMargins(_:, _:, for:)

컨텐츠의 기본 여백을 오버라이드하도록 특정 Dynamic Island 모드에 대한 여백을 설정합니다.

func contentMargins(
    _ edges: Edge.Set = .all,
    _ length: Double,
    for mode: DynamicIslandMode
) -> DynamicIsland
  • edges: 여백을 설정할 모서리. Edge.Set 링크
  • length: 여백의 폭.
  • mode: 여백을 설정할 Dynamic Island 모드.
struct DynamicIslandMode: Equatable

다음과 같은 static 프로퍼티들이 정의되어 있다:

  • compactLeading
  • compactTrailing
  • expanded
  • minimal

이 수정자를 사용할 때는, 컨텐츠가 Dynamic Island의 경계 부분에 지나치게 가깝워지지 않도록 각별한 주의가 필요합니다.
이 수정자는 여러번 겹쳐 사용될 수 있으며, 각 모서리는 전체 모서리를 명시해 지정한 값보다 해당 모서리만을 명시해 지정한 값을 더욱 우선순위로 둡니다. 다음은 그 예시입니다:

dynamicIsland
    .contentMargins(8, .trailing, mode: .expanded)
    .contentMargins(20, mode: expanded)

위 예시에서는 마지막에 expanded 모드의 전체 모서리 여백을 20으로 설정하고 있지만, expanded 모드의 trailing 모서리는 오버라이드되지 않고 8로 유지됩니다.
또한, 다음과 같이 사용하는 것도 가능합니다:

dynamicIsland
    .contentMargin(8, [.top, .bottom, .trialing], mode: .expanded)

Activity

class Activity<Attributes> where Attributes : ActivityAttributes

Live Activity는 Widget과 달리 앱 내에서 직접 데이터를 수정하고 업데이트한다. 앱 내에서 Live Activity에 접근할 수 있도록 해주는 객체가 Activity이다.

Activity 클래스는 별다른 생성자가 없다. 잠시후 설명할 request 타입 메소드가 Activity 객체를 반환하므로, 사실상 생성자의 역할을 대신한다고 생각하면 되겠다.

Live Activity 활성화 여부

앱 내에서 Live Activity를 CRUD하기 전에 활성화 여부를 먼저 확인해야 한다. (읽기 제외)

Live Activity의 활성화 여부는 다음 두 요소에 의해 결정된다:

  • 지원 플랫폼 여부(아이폰만 지원)
  • 아이폰 '설정' 앱 > 해당 앱 > '실시간 현황' 활성화 여부(사용자가 제어 가능)

단일 앱은 복수의 Live Activity를 시작할 수 있으며, 기기는 복수의 앱으로부터 수많은 Live Activity를 가동시킬 수 있습니다. 따라서 Live Activity를 시작, 업데이트, 종료할 때에는 가급적이면 모든 에러를 핸들링하는 것을 권장합니다. 예를 들어, 사용자 기기에서 활성화된 Live Activity 개수가 수용 가능한 최대치에 도달하면 새 Live Activity를 시작하는 것은 실패할 것입니다.


ActivityAuthorizationInfo

final class ActivityAuthorizationInfo

앱 내에서 Live Activity를 시작하는게 허용되는지 및 원격 푸시 알림을 통한 잦은 업데이트가 허용되는지에 대한 정보를 담고 있는 객체.

해당 정보에 접근하려면 인스턴스를 생성하면 된다. 별다른 인자는 없으며, 타입 프로퍼티/타입 메소드도 없다.

ActivityAuthorizationInfo()

이 객체는 4개의 프로퍼티를 가진다:

  • var areActivitiesEnabled: 동기 연산 프로퍼티.
  • let activityEnablementUpdates: 비동기 관찰자 프로퍼티.
  • var frequentPushesEnabled: 동기 연산 프로퍼티.
  • let frequentPushEnablementUpdates: 비동기 관찰자 프로퍼티.

원격 푸시 알림과 관련된 두 프로퍼티에 대한 설명은 생략한다.


areActivitiesEnabled

final var areActivitiesEnabled: Bool { get }

이 동기(synchronous) 연산 프로퍼티는 호출될 때마다 Live Activity의 활성화 여부를 확인해 반환한다.

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            Button("Start Live Activity", action: startActivity)
        }
    }

    func startActivity() {
        let info = ActivityAuthorizationInfo()
        guard info.areActivitiesEnabled else { return }
        ...
    }
}

activityEnablementUpdates

final let activityEnablementUpdates: ActivityAuthorizationInfo.ActivityEnablementUpdates

이 비동기 프로퍼티로 Live Activity의 활성화 여부를 관찰할 수 있다.

ActivityAuthorizationInfo 내에 ActivityEnablementUpdates 구조체가 정의되어있다.

struct ActivityEnablementUpdates: AsyncSequence {
    public typealias Element = Bool
    ...
}

동기 프로퍼티만 사용할 때보다 코드가 조금 더 늘어나지만, 유용하다. 앞서 작성했던 예제를 조금 더 확장시켜보자. (주석 위주로 훑어보셈)

import BackgroundTasks

@main
struct MyApp: App {
    @State var isAuthorized: Bool = false

    var body: some Scene {
        WindowGroup {
            Button("Start Live Activity", action: startActivity)
                .disabled(!isAuthorized)
                .task {
                    // 0. 비동기 실행을 위해 task modifier 사용.
                    // 동기 함수 내에서 Task를 생성해도 된다.
                    let info = ActivityAuthorizationInfo()

                    // 1. State를 초기화한다.
                    isAuthorized = info.areActivitiesEnabled

                    // 2. 활성화 여부가 변경될 때마다 대응한다.
                    for await activityEnablement in info.activityEnablementUpdates {
                        isAuthorized = activityEnablement
                    }
                }
        }
    }

    func startActivity() {
        // 이제 guard 문을 사용할 필요가 없다.
        ...
    }
}

AsyncSequence를 써보지 않았다면 위 구문이 조금 생소할 수 있다. 간단히 말하자면 '다음 Element를 기다리는 비동기 무한루프'로 이벤트 핸들러를 대체한 것이다.


ActivityContent

struct ActivityContent<State>: CustomStringConvertible, Sendable where State : Decodable, State : Encodable, State : Hashable // iOS 16.2+

Live Activity의 상태 및 설정들을 서술한 구조체.

iOS 16.2부터는 Activity<Attributes>.ContentState를 래핑한 ActivityContent 구조체가 새롭게 등장! CRUD를 진행하기 전에 해당 구조체를 설정해주어야 한다.

init(state: State, staleDate: Date?, relevanceScore: Double = 0.0)
let state: State
let staleDate: Date?
let relevanceScore: Double
  • state: ActivityAttributes.ContentState 값.
  • staleDate: 해당 Live Activity가 최신 정보가 아니라고(outdated) 식별되는 시점. 해당 시점에 도달하면 activityState.stale로 바꾼다. 해당 옵셔널 프로퍼티는 네트워크를 비롯한 여러 요인들로 인해 컨텐츠를 최신화하는데 일정 시간 동안 실패한 경우 이를 감지하고 최신화되지 않은 정보임을 유저에게 알리는데 도움이 된다.
  • relevanceScore: 기본값은 0이다. 한 앱에서 두개 이상의 Live Activity를 시작하는 경우, 해당 값으로 각 Activity간의 표시 우선순위를 지정할 수 있다. 높은 숫자일수록 높은 우선순위를 갖는다. 각 Activity들에 값을 제공하지 않았거나 동일한 값을 제공한 경우에는 더 먼저 시작한 Live Activity를 표시한다.

state를 비롯한 각 프로퍼티는 update 또는 end 함수 호출 시에도 수정된 값으로 전달 가능하다.

Live Activity들에 할당한 각 relevanceScore 값들을 추적하고 있으면 향후 각 Live Activity의 업데이트가 있을 때마다 서로간의 우선순위를 조정할 때 활용할 수 있습니다.

이제 앱 내에서 Live Activity를 CRUD하는 방법을 알아보자.

C (시작)

static func request(
    attributes: Attributes,
    content: ActivityContent<Activity<Attributes>.ContentState>,
    pushType: PushType? = nil
) throws -> Activity<Attributes> // iOS 16.2+

Live Activity를 요청하여 시작합니다.

  • attributes: ActivityAttributes. 해당 타입을 바탕으로 어떤 ActivityConfiguration인지 추론해낸다.

  • content: ActivityContent. 담고 있는 상태가 위 attributes의 타입 내에 선언된 ContentState 타입이어야만 한다.

  • pushType: 상태를 어디에서 수신받아 업데이트할지 결정한다. 기본값은 nil로, 앱에서의 update(using:) 함수 호출로부터 상태를 수신받는다. 앱뿐만 아니라 Remote Push Notification에서도 함께 수신받고 싶을 경우 해당 인자에 token 값을 전달한다. 자세한 방법은 Remote Push Notification 장을 참조.

  • -> Activity<Attributes>: 시작한 Activity 객체를 반환한다. 과정 도중 문제가 발생할 경우 에러를 반환할 수 있다.

앞선 장에서 언급했듯이, ActivityAttributesContentState 타입 프로퍼티를 포함하지 않으므로 각각을 따로 생성해서 전달해주어야 한다. ActivityAttributes에 대해 잘 모르겠다면 해당 파트를 읽고 돌아오는 것을 권장한다.

struct MyActivityAttributes: ActivityAttributes {
	    public struct ContentState: Codable, Hashable { ... }
	    ...
	}
}

func startActivity() {
	let attributes = MyActivityAttributes( ... )
    let initialState = MyActivityAttributes.ContentState( ... )

    let activityContent = ActivityContent(state: initialState, staleDate: .now.advanced(by: 3600.0))

    do {
        let activity = try Activity<MyActivityAttributes>.request(
            attributes: attributes, 
            content: activityContent)

        print("Live Activity Requested: \(activity.id)")
    } catch (let error) {
       print("Error requesting Live Activity: \(error.localizedDescription)")
    }
}

앱 내에서 startActivity 함수를 호출시키면 잠금화면과 Dynamic Island에 Live Activity가 활성화되는 것을 확인할 수 있다.

앱이 켜져 있을 때 이 함수를 사용하세요. 이 함수는 앱이 백그라운드 상태일 때 사용할 수 없습니다.


R (읽기)

각 Live Activity에 접근하려면 위 예제처럼 request 함수에서 반환하는 Activity 인스턴스를 저장하는 것이 좋지만, Activity의 타입 프로퍼티를 사용하여 접근하는 방법도 있다.

static var activities: [Activity<Attributes>] { get }

해당 방법을 사용하면 특정 Attributes를 사용하는 모든 Activity를 순회할 수 있다.

for activity in Activity<MyActivityAttributes>.activities {
    ...
}

읽기전용 데이터 프로퍼티

Activity 인스턴스로 다음과 같은 정보들을 조회할 수 있다.

final let id: String
final let attributes: Attributes
// contentState deprecated => content
var content: ActivityContent<Activity<Attributes>.ContentState> { get }
var activityState: ActivityState { get }
var pushToken: Data? { get }

attributescontentState 프로퍼티에 대한 설명은 생략하며, pushToken 프로퍼티에 대해서는 Remote Push Notification 장에서 다룬다.

id

final let id: String

해당 Live Activity의 식별자.

Activity 객체의 ObjectIdentifierString 타입으로 반환한다.

activityState

var activityState: ActivityState { get }

해당 Live Activity의 생명 주기에서의 현재 상태.

enum ActivityState: Decodable, Encodable, Equatable, Hashable, Sendable

ActivityState는 4가지 case를 가진다.

  • active : 활성화 상태. 사용자에게 표시되고, 업데이트를 수신받을 수 있다.
  • dismissed : 지워진 상태. 사용자 또는 시스템에 의해 제거되어 더 이상 표시되지 않음.
  • ended : 종료된 상태. 사용자에게 표시되지만, 사용자/앱/시스템에 의해 종료되어 더 이상 업데이트를 수신받지 않는다.
  • stale : 최신 정보가 아닌 상태. content.staleDate에 도달하면 이 상태로 변경된다.(iOS 16.2+)

비동기 관찰자 프로퍼티

Activity 객체는 관련 변동사항을 관찰하고 대응할 수 있도록 다양한 AsyncSequence들을 제공한다.

// contentStateUpdates deprecated => contentUpdates
var contentUpdates: Activity<Attributes>.ContentUpdates { get }
var activityStateUpdates: Activity<Attributes>.ActivityStateUpdates { get }
var pushTokenUpdates: Activity<Attributes>.PushTokenUpdates { get }

static var activityUpdates: Activity<Attributes>.ActivityUpdates { get }

위 프로퍼티는 각각 content, activityState, pushToken, activities 프로퍼티를 Element로 가지는 AsyncSequence다. 자세한 설명은 생략한다.

Task {
    for await newActivity in Activity<MyActivityAttributes>.activityUpdates {
        print("new activity added: \(newActivity.id)")
    }
}

U (업데이트)

func update(
    _ content: ActivityContent<Activity<Attributes>.ContentState>,
    alertConfiguration: AlertConfiguration? = nil
) async // iOS 16.2+
  • content : 업데이트할 컨텐츠. 인코딩된 데이터 크기는 4KB를 초과할 수 없다.
  • alertConfiguration : 업데이트할 때 사용자에게 전달되는 알림의 세부 설정.
// 실행중인 Live Activity가 activity 프로퍼티에 저장되어있다고 가정한다.
func updateActivity() {
    let newContentState = MyActivityAttributes.ContentState(...)
    let updatedContent = ActivityContent(state: updatedState, staleDate: .now.advanced(by: 1800.0))

    Task {
        await activity.update(updatedContent)
    }
}

ActivityState.ended 상태에서는 해당 업데이트 함수 호출이 무시됩니다.

이 함수를 Background Tasks 프레임워크와 함께 사용하면 앱이 켜져있는 경우뿐만 아니라 백그라운드에서도 Live Activity를 업데이트할 수 있습니다.

Background Tasks 프레임워크 사용법에 대한 설명은 생략한다.


update 함수의 alertConfiguration 인자에 대해 알아보자.

AlertConfiguration

struct AlertConfiguration: Equatable {
    var title: LocalizedStringResource
    var body: LocalizedStringResource
    var sound: AlertConfiguration.AlertSound
}
  • title : 애플워치 알림 제목.
  • body : 애플워치 알림 본문.
  • sound : 알림 소리. nil을 전달할 경우 무음 알림이 된다.

생성자는 init(title: body: sound:)가 유일하며, 세 인자 모두 기본값이 제공되지 않는다.

애플워치에서는 titlebody 속성을 사용하지만, 아이폰에서는 두 속성을 사용하지 않고 Dynamic Island에 expanded View를 띄운다. Dynamic Island를 사용할 수 없는 기종의 경우에도 임시 배너로 expanded View를 띄운다.

LocalizedStringResource

struct LocalizedStringResource

iOS 16에서 App Intents 프레임워크와 함께 등장한 이 구조체는 로컬라이징 가능한 String을 참조하는 타입이다. 문자열이 필요할 때는 String(또는 AttributedString)에서 localized 인자를 포함하는 생성자를 사용한다. 내부에 var local 프로퍼티가 있기 때문에, 원할 경우 얼마든지 locale 설정을 바꾸어 다른 언어의 텍스트를 얻을 수 있다.

해당 구조체의 사용법에 대한 자세한 설명은 생략한다.

AlertSound

struct AlertSound: Equatable

두 static 프로퍼티/메소드를 제공한다:

  • default : 기본 알림 소리.
  • named(_:) : 알림에서 재생할 소리 파일의 제목. 앱의 메인 번들/Liblary/Sounds 폴더/앱의 데이터 컨테이너 내에 존재하는 파일이어야 한다.

D (종료)

func end(
    _ content: ActivityContent<Activity<Attributes>.ContentState>?,
    dismissalPolicy: ActivityUIDismissalPolicy = .default
) async // iOS 16.2+
  • content : 마지막으로 업데이트할 최종 컨텐츠. 인코딩된 데이터 크기는 4KB를 초과할 수 없다.
  • dismissalPolicy : Live Activity를 종료하는 방법 및 제거 시점을 설정한다.
struct ActivityUIDismissalPolicy: Equatable

Dynamic Island는 Live Activity가 종료되는 즉시 표시를 중단하지만, 잠금화면은 해당 구조체를 통해 종료 방법을 설정할 수 있다. 각 방법은 ActivityState의 종류 및 변경 시점에 영향을 미친다.

세 static 프로퍼티/메소드를 제공한다:

  • default : 사용자가 직접 제거하지 않으면 잠금화면에서 최대 4시간 동안 표시된다.
  • immediate : 잠금화면에서 즉시 제거된다.
  • after(date) : 특정 시각의 Date 구조체를 전달하여 종료 시점을 설정한다. 종료 시점과 종료 후 4시간 경과 시점 중 더 빠른 쪽이 우선된다. 물론 사용자가 직접 제거할 수도 있다.

이 함수를 Background Tasks 프레임워크와 함께 사용하면 앱이 켜져있는 경우뿐만 아니라 백그라운드에서도 Live Activity를 종료할 수 있습니다.

Background Tasks 프레임워크 사용법에 대한 설명은 생략한다.


Remote Push Notification

User Notifications 프레임워크는 APNs(Apple Push Notification service)를 사용하여 로컬•서버에서 앱으로 알림을 보낼 수 있게 해준다. Live Activity도 거의 동일한 방법으로 서버에서 원격 푸시 알림(Remote Push Notification)을 받아 업데이트 또는 종료할 수 있으며, 애플은 Registering Your App with APNsAsking Permission to Use Notifications 글을 읽어보는 것을 권장하고 있다. 다음 글은 애플 공식 문서를 참고하여, User Notifications 프레임워크의 기본 사용법을 알고 있다는 전제 하에 Live Activity에 서버 푸시 알림을 보내는 방법을 서술한다.

사용 방법

Live Activity에서는 인증 기반 등록이 불가능하고 토큰 기반 등록만을 지원한다. 또한 토큰을 얻을 때도 기존의 registerForRemoteNotifications()을 사용하는 대신 ActivityKit의 Activity.request(attributes:content:pushType:)을 사용해야 한다. (아마 토큰을 얻을 때 델리게이트 패턴을 필수적으로 구현해야 하는게 싫었던 것 같은데... 정확히는 모르겠다.)

워크플로우는 다음과 같다:

  1. APNs로 알림을 보낼 서버가 없다면 서버를 구축한다. (당연)
  2. activity를 시작할 때 pushType 인자에서 토큰을 요청한다.
	let activity = request(attributes: ..., content: ..., pushType: PushType.token)
  1. 반환받아 저장한 activity 인스턴스의 pushToken 프로퍼티를 서버로 보낸다.
  2. 서버에서 POST 요청을 작성한 뒤 APNs로 송신한다. POST 요청은 Sending Notification Requests to APNs 글을 기반으로 작성하며, 다음 규칙을 준수해야 한다: (아래 예시가 있다)
    • json 페이로드에서 content-state 키 내의 필드들은 구현된 Activity.ContentState 타입과 반드시 일치해야 한다.
    • apns-push-type 헤더 필드를 liveactivity로 작성해야 한다.
    • apns-topic 헤더 필드를 다음 양식에 맞게 작성해야 한다:
      <번들ID>.push-type.liveactivity
    • json 페이로드에서 event 키를 update 또는 end로 작성해야 한다. 각각은 Live Activity를 업데이트 또는 종료하라는 지시 문구다. 원할 경우 Live Activity를 종료할 때 contentState를 포함하여 최종 데이터가 표시되도록 할 수도 있다.
    • 원할 경우 json 페이로드에서 alert 키를 작성하고 내부에 title, body, sound 필드를 작성하여 알림을 보낼 수도 있다.
    • 원할 경우 json 페이로드에서 relevance-score 또는 stale-date 키를 작성하고 내부에 그 해당 값을 넣을 수 있다.
  3. Activity.pushTokenUpdates 프로퍼티로 관찰하여 토큰값 변경에 대응한다. 서버에 새 토큰을 전송하고, 서버에서 보관하던 기존 토큰은 만료시킨다.
  4. Live Activity가 종료된 경우 서버에서 보관하던 토큰을 만료시킨다.

시뮬레이터에서 Live Activity의 원격 푸시 알림을 테스트하려면 T2 보안 칩이 탑재된 맥 또는 애플 실리콘 맥을 사용해야 하며, macOS 13 이후 버전이어야 합니다.

3번을 따라 POST 요청이 작성된 예시는 다음과 같다.

HEADERS
  - END_STREAM
  + END_HEADERS
  :method = POST
  :scheme = https
  :path = /3/device/00fc13adff785122b4ad28809a3420...
  host = api.sandbox.push.apple.com
  authorization = bearer eyAia2lkIjogIjhZTDNHM1JSWDciIH...
  apns-push-type = liveactivity
  apns-expiration = 0
  apns-priority = 10
  apns-topic = com.doldamul.example.push-type.liveactivity
DATA
  + END_STREAM
{
    "aps": {
        "timestamp": 1659466849,
        "event": "update",
        "relevance-score": 75.0,
        "stale-date": 1650998941,
        "content-state": {
            "driverName": "Anne Johnson",
            "estimatedDeliveryTime": 1659416400
        },
        "alert": {
            "title": "Delivery Update",
            "body": "Your pizza order will arrive soon.",
            "sound": "example.aiff"
        }
    }
}

위 예시는 가독성을 위해 alert 키의 titlebody 필드에 일반 string을 사용했지만, 실제 구현할 때는 localized string을 사용하는 것을 권장한다. 해당 방법은 다음 글에 서술되어있다: developer.apple.com: Generating a remote notification

다음 상황에서는 사용자가 원격 푸시 알림을 수신받지 못한다:

  • 사용자가 네트워크 통신이 되지 않는 지역에 있음
  • Live Activity가 종료된 이후(시스템이 notification을 무시함)

이러한 경우에는 Live Activity가 만료되거나 잘못된 정보를 표기할 수 있으므로, 앱내 업데이트 로직으로 대응할 수 있도록 하자.

또한 시스템은 시간당 알림 횟수 수용량을 일정치로 정해둔다. 해당 수용량을 초과할 경우 시스템이 푸시 알림을 throttle할 수도 있다. 시간당 알림 수용량을 초과하고 싶지 않은 경우, 해당 수용량 계산에서 제외되는 낮은 우선순위(low priority) 푸시 알림을 보낼 수 있다.

마지막으로 뭔가 아쉬워서 ActivityKit에 있는 서버 푸시 알림 관련한 항목들을 모아 정리해봤다.

struct PushType: Equatable {
    static var token: PushType
}
extension Activity {
    var pushToken: Data? { get }
    var pushTokenUpdates: Activity<Attributes>.PushTokenUpdates { get }
}

마치며

사실 이 정도로까지 길게 작성하게 될 줄은 몰랐다. 이렇게 오래걸릴 거라고도 생각하지 못했다. 내 원래 목표는 글의 분량이 많든 적든 일주일에 하나씩 정기적으로 업로드하는 것이었다. 미리 써두었던 글을 업로드한걸 제외하면 거의 한달 가까이 쉬어버린 것인데, 핑계야 얼마든지 있다. 일주일 동안 일본 여행을 갔다왔다거나, 오버워치 2가 너무 재밌어서 멈추질 못하겠다거나... (이거 핑계 맞나)

그리고 즐거운 마음으로 시작한 건데 지나치게 버거워졌다. 틈틈이 쓰는 정도로만 하고 싶었는데 비중이 너무 커져버린 것 같다. 나는 앱을 개발해보고 싶었고, 그 앱을 개발하기 위해 필요한 사항들을 하나씩 공부해나가고 싶었다. 이번 주제는 너무 충동적으로 시작한 것 같다. '와! 다이나믹 아일랜드! 이건 UX의 혁신이야!' 앱등이 물론 이것도 공부가 되긴 한다. 프레임워크 하나를 디테일하게 파보는 만큼 많은 부분들이 연결되어 있다. WidgetKit, Background Tasks, User Notifications, POST request 등을 대충이나마 알게 되었고, 비동기 관련 문법들을 복습해보기도 했다. 프레임워크나 매뉴얼을 바라보는 시각도 업그레이드된 것 같다. 그런데 직접 코딩을 하면서 시행착오를 겪어보는 쪽으로는 너무 소홀해진 것 같다. 앞으로는 공부할 때 글로 정리하는 시간을 최대한 줄여보려 한다.

그래서... 앞으로 글의 양식이라던지, 업로드 빈도를 어떻게 바꿀지는 모르겠다. 아무튼 바꾸긴 할 거다. 해보고 싶은 건 많지만, 덜 중요한 것들 때문에 시간을 흘려보내고 싶지는 않다. 작성을 끝내고 보니 이 주제는 정말이지 나에게 전혀 중요하지 않은 것이었다. (나름 재미는 있었다)

아, 그리고 글이 워낙에 길고 내 지식이 얕다보니 이상하거나 틀린 부분이 있을 수 있다. (특히 서버 푸시 알림쪽) 워낙 완벽주의에 꽂혀서 작성했으니 누군가는 도움을 받을 수 있을거라 생각하는데, 보완할 부분이 있다면 소소한 보답이라 생각하고 댓글로 알려주시면 감사하겠다.

참고자료

< ActivityKit >

< remote push notification >

profile
덕질은 삶의 활력소다. 내가 애플을 좋아하는 이유. 재밌거덩

4개의 댓글

comment-user-thumbnail
2023년 4월 3일

좋은 글 감사합니다. 질문이 하나 있는데요.
Google music을 보니 Expanded View에서 play, stop, next, prev 등 컨트롤이 가능하더라고요.
이건 MPRemoteCommnadCenter 를 이용한 것일 까요?

1개의 답글
comment-user-thumbnail
2023년 5월 25일

너무너무 잘봤습니다. 혹시 레포지토리가 있을까요 ?!

1개의 답글