스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
ActivityKit 알아보기
실시간 현황은 앱 내 작업의 진행 상황을 간편하게 확인하는 방법입니다. 잠금 화면과 Dynamic Island, 스탠바이에서 유용한 경험을 제공하는 방법을 알아보세요. 또한 앱의 실시간 현황을 업데이트하고 활동 상태를 지켜보며, WidgetKit 및 SwiftUI를 활용해 더욱 풍부한 경험을 빌드하는 방법을 알아보세요.
챕터
- 0:00 - Intro
- 0:36 - Live Activity overview
- 4:23 - Lifecycle of Live Activities
- 10:43 - Building Live Activity UI
- 16:37 - Wrap-up
리소스
- ActivityKit
- Displaying live data with Live Activities
- Human Interface Guidelines: Live Activities
- Starting and updating Live Activities with ActivityKit push notifications
- WidgetKit
관련 비디오
WWDC23
-
다운로드
♪ ♪
안녕하세요, 잔 아란입니다 iOS 시스템 경험 팀의 엔지니어죠 실시간 현황을 소개해 드리게 되어 기쁩니다 이 영상에서는 실시간 현황을 개략적으로 살펴봅니다 그다음 실시간 현황의 수명 주기를 설명하고 몰입감 있으면서도 간결한 실시간 현황 UI를 빌드하는 법을 알려 드릴게요 먼저 실시간 현황의 기능부터 살펴보죠 실시간 현황은 몰입감 있고 간편하게 이벤트나 작업 진행 상황을 지켜볼 수 있는 방법입니다 실시간 현황에는 시작과 끝이 나뉘어 있으며 백그라운드 앱 런타임이나 푸시 알림을 통해 실시간 업데이트를 제공합니다 유나이티드항공과 MLB의 모범 예시를 보여 드리죠 실시간 현황은 iPhone 14 Pro와 Pro Max에서 더욱 몰입감 있습니다 앱이 백그라운드에서 실행 중이면 시스템 어느 곳에서든 Dynamic Island에 실시간 현황이 표시되죠 하나의 실시간 현황이 활성화된 경우 다양한 너비로 렌더링되고 '압축형'으로 표현되죠 Dynamic Island는 한 번에 최대 두 개의 실시간 현황을 표시할 수 있습니다 둘 중 하나는 TrueDepth 카메라에 붙어서 나타나고 다른 하나는 별도의 뷰에서 렌더링되죠 두 실시간 현황 모두 '최소형' 표시 방식을 씁니다 사용자는 언제든지 실시간 현황을 길게 눌러서 '확장형' 표현 방식을 나타내고 더 많은 정보를 빠르게 확인할 수 있습니다 확장형 표현 방식에서는 뷰를 통해 앱의 다른 영역으로 딥 링크하여 풍부한 사용자 경험을 제공할 수 있죠 iOS 17에서는 실시간 현황에 새로운 경험이 추가됐습니다 잠금 화면과 Dynamic Island뿐만 아니라 대기 모드일 때도 실시간 현황이 나타나죠 또한 이제 iPad도 실시간 현황을 지원합니다 iPadOS에서 구현을 활성화하고 iPad로 몰입형 실시간 현황을 사용해 보세요 이 Crumbl Cookies의 예시처럼요 iOS 17에서는 WidgetKit 및 SwiftUI를 사용하여 실시간 현황에 상호 작용성을 추가할 수 있습니다 버튼이나 토글을 추가하여 사용자 경험을 향상할 수 있죠 위젯에 상호 작용성을 추가하는 법에 대해서는 루카의 '위젯에 생기 불어넣기' 영상을 참고하세요 실시간 현황은 ActivitiKit 프레임워크에 의존하며 앱이 수명 주기를 요청 및 업데이트, 관리할 수 있게 합니다 SwiftUI와 WidgetKit를 통해 선언형으로 구성되죠 홈 화면 위젯을 구현해 보셨다면 익숙하실 겁니다 앱이 포그라운드에 있을 때 실시간 현황을 요청할 수 있는데요 앱이 특정한 사용자 동작 이후에 실시간 현황을 요청해야만 합니다 이벤트를 팔로우하거나 작업을 시작하는 동작일 수 있죠 이는 뛰어난 사용자 경험을 위해 매우 중요합니다 알림과 마찬가지로 실시간 현황도 사용자가 제어합니다 쉽게 종료하거나 전체적으로 비활성화할 수 있죠 API는 잠금 화면에서 세 가지 Dynamic Island 표시까지 모든 표현 방식을 지원해야 합니다 대기 모드일 때는 시스템이 잠금 화면 표현 방식을 화면에 맞게 확대하죠 백그라운드 런타임에 의존하는 것뿐만 아니라 앱에서 실시간 현황을 원격 업데이트 할 수도 있습니다 liveactivity 푸시 유형의 푸시 알림을 통해서요 푸시 알림으로 실시간 현황을 업데이트하는 방법에 대해서는 제프의 영상을 참고하세요 실시간 현황은 앱 수명 주기의 다양한 단계를 거칩니다 사용자가 Emoji Rangers 앱에서 영웅을 선택하고 모험을 떠나게 할 수 있는 실시간 현황을 빌드 중인데요 모험에서 영웅은 난관에 부딪치고 적을 물리칩니다 이 모험의 주요 순간들을 실시간 현황에 표시해 보죠 이 실시간 현황은 영웅의 모험에 대한 중요한 정보를 보여 줍니다 영웅의 이름과 통계 아바타, 체력 수준 모험에서 겪는 일에 대한 설명을 포함하죠 실시간 현황의 수명 주기에는 크게 네 가지 단계가 있습니다 시작은 활동을 요청하는 것입니다 시작되고 나면 최신 콘텐츠로 업데이트하죠 동시에 활동을 관찰하며 사용자의 종료를 비롯한 상태 변경에 대응합니다 작업이 완료되면 활동을 종료합니다 실시간 현황 요청은 매우 간단합니다 앱을 포그라운드에 표시하고 앱을 구성하면 필요한 활동 요청 데이터와 초기 콘텐츠를 얻을 수 있죠 Emoji Rangers 앱에서 실시간 현황을 요청하려면 ActivityAttributes를 구현하여 실시간 현황의 정적 및 동적 데이터 집합을 정의해야 합니다 이를 AdventureAttributes로 명명하죠 이 속성은 영웅이라는 정적 데이터 한 개를 보여 줍니다 또한 영웅의 체력 수준과 이벤트 설명을 캡슐화하는 커스텀 ContentState를 정의하죠 이러한 프로퍼티가 바뀔 때마다 실시간 현황 UI가 업데이트되어 모험의 현 상태를 화면에 표시할 수 있게 됩니다 정적 및 동적 데이터가 준비되었으니 모험 활동 요청을 설정해 보죠 우선 영웅에 AdventureAttributes 인스턴스를 생성하고 영웅의 체력 수준과 이벤트 설명을 초기 콘텐츠로 설정합니다 각 활동 콘텐츠는 staleDate와 제공될 수 있습니다 콘텐츠의 유효 날짜가 지났을 때 시스템에 알리기 위해서죠 지금은 nil로 두겠습니다 콘텐츠의 관련성 점수는 여러 모험 활동이 시작될 때 각 실시간 현황이 나타나는 순서를 정합니다 새로운 모험 활동을 시작하려면 관련성 점수를 각기 다르게 지정해야 하죠 관련성 점수 전달은 선택 사항입니다 기본값은 0이죠 이제 활동을 요청할 수 있습니다 속성과 초기 콘텐츠 푸시 알림 유형을 전달하겠습니다 푸시 알림 유형은 실시간 현황이 ActivityKit 푸시 알림을 통해 동적 콘텐츠에 업데이트를 수신하는지 알려 줍니다 이 예제에서는 nil로 설정하겠습니다 이 활동이 로컬에서만 업데이트를 수신한다는 뜻이죠 실시간 현황을 시작하려면 Emoji Rangers 앱의 실시간 현황 설정을 활성화해야 합니다 이제 실시간 현황을 요청할 수 있으니 영웅이 모험에서 짜릿한 임무를 수행할 때마다 업데이트하는 방법을 살펴보죠 동적 속성은 실시간 현황을 업데이트할 때를 알려 줍니다 이벤트 설명이나 영웅의 체력 수준이 바뀔 때마다 활동을 업데이트하죠 이런, 영웅이 적한테 치명적인 공격을 당했군요 contentState를 생성하여 체력 변화를 반영하고 이벤트를 설명하겠습니다 영웅의 체력이 크게 손상됐으므로 경고를 보내야 합니다 경고 구성을 생성해 보죠 실시간 현황 관련 주요 정보가 변경되면 iPhone과 iPad 동기화된 Apple Watch에 경고를 표시합니다 이 경우 영웅이 심각하게 다쳐서 물약으로 회복해야 하는군요 구성 제목과 본문은 Apple Watch에서만 쓰이며 알림으로 표시됩니다 iPhone과 iPad에서는 업데이트된 콘텐츠를 포함한 활동 UI가 지정된 소리와 함께 나타나죠 이제 업데이트된 콘텐츠와 알림 구성을 통해 활동 객체의 업데이트 API를 호출할 수 있습니다 이를 통해 실시간 현황 UI가 업데이트되고 사용자가 업데이트를 알아챌 수 있습니다 활동 상태 변화는 실시간 현황의 수명 주기 내에서 언제든 일어날 수 있습니다 상태는 네 가지입니다 started, finished dismissed, stale이죠 활동 객체의 activityStateUpdates API로 비동기식 업데이트 수신 시 이러한 상태를 관찰합니다 활동이 종료되면 더는 모험 데이터를 추적하지 않도록 하고 진행 중인 활동을 표시하지 않게 앱에서 UI를 업데이트합니다 또한 activityState API로도 상태를 확인하여 필요시 동기적으로 회수할 수 있습니다 영웅이 고생했으니 이제 모험 실시간 현황을 끝낼 차례입니다 활동을 종료하기 위해 우선 최종 콘텐츠를 생성합니다 콘텐츠는 모험의 최종 상태를 보여 줍니다 영웅이 적을 물리쳤군요 그다음 UI의 종료 정책을 정합니다 이 예제에서는 기본 정책이 적합합니다 이 정책은 모험이 끝난 후 잠시 동안 잠금 화면에 모험 정보가 나타나게 합니다 그러면 사용자가 잠금 화면을 언뜻 보고 모험이 어떻게 끝났는지 알 수 있죠 이제 모험 활동을 끝내고 영웅을 쉬게 하겠습니다 실시간 현황 수명 주기 관련 로직을 모두 빌드했으니 이제 활동 UI를 살펴보겠습니다 Emoji Ranger 위젯 확장은 현재 WidgetBundle에 두 개의 위젯을 지닙니다 WidgetBundle에 실시간 현황 구성을 추가해야 하죠 AdventureActivity Configuration으로 지정할게요 AdventureActivity Configuration은 위젯 인프라를 활용하며 본문에 WidgetConfiguration을 반환해야 합니다 ActivityConfiguration 객체를 생성하여 실시간 현황의 콘텐츠를 설명하겠습니다 각 프레젠테이션의 클로저에 ActivityConfiguration 객체가 ActivityViewContext를 제공하여 정적 및 동적 속성과 활동 ID를 저장합니다 이 콘텍스트는 구성에 전달된 속성의 유형을 기반으로 생성되죠 이 유형은 활동 요청 시 사용되는 속성과 일치해야 합니다 AdventureAttributes 유형을 전달해서 활동 구성을 성공적으로 초기화하겠습니다 ActivityConfiguration의 첫 번째 클로저는 잠금 화면 UI를 지정합니다 활동 업데이트에 따라 뷰 콘텍스트가 변경될 때마다 매번 UI가 렌더링되죠 위젯과 마찬가지로 실시간 현황의 잠금 화면 UI 크기는 제공하지 않고 시스템이 적합한 크기를 정하도록 합니다 Emoji Ranger 활동에서는 영웅의 정보와 이름, 아바타 체력 수준, 이벤트 설명을 잠금 화면에 남색 배경과 함께 표시하겠습니다 전달된 뷰 콘텍스트를 통해 AdventureLiveActivityView가 이 모든 정보를 얻을 수 있죠 잠금 화면의 실시간 현황이 간단하고 세련돼 보이는군요 모험을 떠난 영웅이 겪는 일에 대한 정보도 모두 제공하고요 잠금 화면 UI를 마무리했으니 이제 Dynamic Island 프레젠테이션을 구현해 보죠 프레젠테이션은 세 가지입니다 압축형, 최소형, 확장형이죠 시스템에서 실행 중인 활동이 앱의 실시간 현황뿐인 경우 압축형 프레젠테이션으로 표시됩니다 압축형 프레젠테이션에는 두 영역이 있습니다 leading과 trailing이죠 이 둘은 Dynamic Island에 일관된 프레젠테이션을 형성합니다 제한적인 공간이므로 leading과 trailing 공간에 표시할 필수 콘텐츠를 선택해야 하죠 사용자가 여기서 콘텐츠를 보고 활동을 식별할 수 있게요 ActivityConfiguration 객체의 DynamicIsland 클로저에서 다시 뷰 콘텍스트에 접근하여 expanded와 compactLeading, compactTrailing minimal 뷰를 생성합니다 DynamicIsland 뷰 빌더를 만들어서 각각의 프레젠테이션을 나타내야 하죠 영웅의 모험에 대해서는 영웅의 아바타를 leading 콘텐츠에 추가하고 체력 수준을 trailing 뷰에 추가하겠습니다 또한 동적인 색상으로 영웅의 체력을 표시하겠습니다 모험의 압축형 프레젠테이션이 완성됐습니다 두 개 이상의 앱이 실시간 현황을 실행하면 어떤 실시간 현황을 보여 줄지 시스템에서 선택하고 최소형 프레젠테이션으로 둘 다 표시합니다 하나는 Dynamic Island에 붙어서 나타나고 다른 하나는 분리되어 나타나죠 최소형 뷰에는 가장 중요한 정보만 표시해야 합니다 활용할 수 있는 공간이 매우 작기 때문이죠 제 실시간 현황의 최소형 뷰에서는 영웅이 누군지와 영웅의 체력이 가장 중요한 정보이므로 동적 색상으로 체력 수준과 아바타를 표시하겠습니다 그러면 사용자가 최소형 뷰를 보고 언제 영웅을 도와야 하는지 알 수 있죠 압축형 및 최소형 프레젠테이션의 실시간 현황을 길게 누르면 시스템이 확장형 프레젠테이션으로 콘텐츠를 표시합니다 그 기능도 지원해 봅시다 확장형 프레젠테이션의 경우 시스템이 프레젠테이션을 여러 영역으로 나눕니다 DynamicIsland 뷰 빌더의 첫 번째 클로저는 확장된 콘텐츠를 나타냅니다 이 클로저 내에서 각 섹션 콘텐츠는 특정 위치를 전달하는 확장된 영역으로 정의할 수 있죠 영웅의 이름과 아바타를 leading 공간에 추가하고 영웅 정보를 trailing 공간에 추가하겠습니다 그리고 체력 수준과 이벤트 설명을 하단 공간에 추가하죠 그 결과 Dynamic Island UI가 간결하면서도 모험의 중요 정보를 모두 제공하게 됐습니다 이제 좋아하는 영웅들과 모험을 떠날 준비를 마쳤습니다 방금 만든 간결하면서도 몰입감 있는 실시간 현황 UI와 함께요 UI를 디자인할 때 실시간 현황에는 가장 중요한 콘텐츠만 표시하세요 간결성을 유지하고 사용자가 실시간 현황을 탭하면 앱에서 추가적인 세부 정보를 제공하세요 '실시간 현황 다이내믹하게 디자인하기' 세션을 참고하시길 권합니다 실시간 현황을 강력한 도구로 활용하여 진행 중인 활동의 실시간 정보를 간편하게 표시해 보세요 간단한 구성을 통해 동적인 방식으로 iOS와 iPadOS에서 사용자와 상호 작용할 수 있습니다 푸시 업데이트에 대한 자세한 내용은 '푸시 알림으로 실시간 현황 업데이트하기'를 확인하세요 여러분이 ActivityKit로 무엇을 빌드할지 기대됩니다 시청해 주셔서 감사합니다 ♪ ♪
-
-
5:40 - Define ActivityAttributes
import ActivityKit struct AdventureAttributes: ActivityAttributes { let hero: EmojiRanger struct ContentState: Codable & Hashable { let currentHealthLevel: Double let eventDescription: String } }
-
6:28 - Request Live Activity with initial content state
let adventure = AdventureAttributes(hero: hero) let initialState = AdventureAttributes.ContentState( currentHealthLevel: hero.healthLevel, eventDescription: "Adventure has begun!" ) let content = ActivityContent(state: initialState, staleDate: nil, relevanceScore: 0.0) let activity = try Activity.request( attributes: adventure, content: content, pushType: nil )
-
8:00 - Update Live Activity with new content
let heroName = activity.attributes.hero.name let contentState = AdventureAttributes.ContentState( currentHealthLevel: hero.healthLevel, eventDescription: "\(heroName) has taken a critical hit!" ) var alertConfig = AlertConfiguration( title: "\(heroName) has taken a critical hit!", body: "Open the app and use a potion to heal \(heroName)", sound: .default ) activity.update( ActivityContent<AdventureAttributes.ContentState>( state: contentState, staleDate: nil ), alertConfiguration: alertConfig )
-
9:30 - Observe activity state
// Observe activity state asynchronously func observeActivity(activity: Activity<AdventureAttributes>) { Task { for await activityState in activity.activityStateUpdates { if activityState == .dismissed { self.cleanUpDismissedActivity() } } } } // Observe activity state synchronously let activityState = activity.activityState if activityState == .dismissed { self.cleanUpDismissedActivity() }
-
10:03 - Dismiss Live Activity with final content state
let hero = activity.attributes.hero let finalContent = AdventureAttributes.ContentState( currentHealthLevel: hero.healthLevel, eventDescription: "Adventure over! \(hero.name) has defeated the boss! Congrats!" ) let dismissalPolicy: ActivityUIDismissalPolicy = .default activity.end( ActivityContent(state: finalContent, staleDate: nil), dismissalPolicy: dismissalPolicy) }
-
10:50 - Add ActivityConfiguration to WidgetBundle
import WidgetKit import SwiftUI @main struct EmojiRangersWidgetBundle: WidgetBundle { var body: some Widget { EmojiRangerWidget() LeaderboardWidget() AdventureActivityConfiguration() } }
-
11:05 - Define Lock Screen presentation
struct AdventureActivityConfiguration: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: AdventureAttributes.self) { context in AdventureLiveActivityView( hero: context.attributes.hero, isStale: context.isStale, contentState: context.state ) .activityBackgroundTint(Color.navyBlue) } dynamicIsland: { context in // ... } } }
-
13:28 - Define Dynamic Island compact presentation
struct AdventureActivityConfiguration: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: AdventureAttributes.self) { context in // ... } dynamicIsland: { context in DynamicIsland { // ... } compactLeading: { Avatar(hero: context.attributes.hero) } compactTrailing: { ProgressView(value: context.state.currentHealthLevel) { Text("\(Int(context.state.currentHealthLevel * 100))") } .progressViewStyle(.circular) .tint(context.state.currentHealthLevel <= 0.2 ? Color.red : Color.green) } minimal: { // ... } } } }
-
14:42 - Define Dynamic Island minimal presentation
struct AdventureActivityConfiguration: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: AdventureAttributes.self) { context in // ... } dynamicIsland: { context in DynamicIsland { // ... } compactLeading: { // ... } compactTrailing: { // ... } minimal: { ProgressView(value: context.state.currentHealthLevel) { Avatar(hero: context.attributes.hero) } .progressViewStyle(.circular) .tint(context.state.currentHealthLevel <= 0.2 ? Color.red : Color.green) } } } }
-
15:26 - Define Dynamic Island expanded presentation
struct AdventureActivityConfiguration: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: AdventureAttributes.self) { context in // ... } dynamicIsland: { context in DynamicIsland { // Leading region DynamicIslandExpandedRegion(.leading) { LiveActivityAvatarView(hero: hero) } // Expanded region DynamicIslandExpandedRegion(.trailing) { StatsView(hero: hero, isStale: isStale) } // Bottom region DynamicIslandExpandedRegion(.bottom) { HealthBar(currentHealthLevel: contentState.currentHealthLevel) EventDescriptionView(hero: hero, contentState: contentState) } } compactLeading: { // ... } compactTrailing: { // ... } minimal: { // ... } } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.