Live Activities
& Dynamic Island
를 지원하기 위해서는 Widget Extension
이 필요했다.
Widget extension
내 여러 widget
들과 함께 Live Activities
가 추가되는 원리.
File
-> New
-> Target
-> iOS
-> Widget Extension
통해 생성
Prodduct Name
: widget 입력Include Live Activity
선택 (자동으로 Live Activity 예제코드 생성)Include Configuration Intent
선택 (위젯 편집 기능 제공)Info.plist
파일 내 key: NSSupportsLiveActivities
, value: YES
(Boolean) 추가
Widget Extension
생성으로 자동으로 WidgetBundle
가 생성된다.
WidgetBundle
내 widget
과 widgetLiveActivity
가 포함되는 식.
import WidgetKit
import SwiftUI
@main
struct widgetBundle: WidgetBundle {
var body: some Widget {
// widget()
if #available(iOS 16.2, *) {
widgetLiveActivity()
}
}
}
Live Activities
& Dynamic Island
에서 표시할 데이터를 정의하기 위한 구조체.
ContentState
내에 정의.ActivityAttributes
구조체 변수로 정의.현재 Live Activities 사용로직상 업데이트가 불필요하지만 본인의 경우 이런식으로 작성
import Foundation
import ActivityKit
struct TimerStopwatchAttributes: ActivityAttributes {
public typealias titiStatus = ContentState
public struct ContentState: Codable, Hashable {
var taskName: String
var timer: ClosedRange<Date>
}
var isTimer: Bool
}
Widget 과 다르게 App
에서 데이터를 넘길 수 있으므로 해당 swift 파일의 Target Membership
을 App
, WidgetExtension
모두 체크해야 한다.
Widget Extension
생성으로 자동으로 widgetLiveActivity
가 생성된다.
ActivityConfiguration
클로저를 통해 context 로 위에서 구현한 ActivityAttributes
구조체가 전달된다.
LiveActivities
(잠금화면 실시간 현황)의 경우 해당 클로저 안에 context
를 받아 표시하는 SwiftUI View
를 정의 하면 된다.
struct widgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerStopwatchAttributes.self) { context in
// Presentation on Lock Screen and as a banner on the Home Screen
LockScreenLiveActivityView(context: context)
} dynamicIsland: { context in
// Create the presentations that appear in the Dynamic Island.
// ...
}
}
}
context
를 통해 ActivityAttributes
구조체를 받아 view 에 표시한다.
isLuminanceReduced
변수를 통해 iPhone 14Pro 사용자의 Always On Display 상태의 농도가 옅은 view 를 저의 할 수 있다.
View 표시에 필요한 Assets
의 경우 WidgetExtension 추가로 생성된 Assets 내 추가되어야 한다.
struct LockScreenLiveActivityView: View {
let context: ActivityViewContext<TimerStopwatchAttributes>
@Environment(\.isLuminanceReduced) var isLuminanceReduced
var body: some View {
if isLuminanceReduced {
VStack(spacing: -3) {
Spacer(minLength: 14)
Text("\(context.state.taskName)")
.foregroundColor(.white.opacity(0.5))
Text(timerInterval: context.state.timer, countsDown: context.attributes.isTimer)
.multilineTextAlignment(.center)
.monospacedDigit()
.font(.system(size: 44, weight: .semibold))
.foregroundColor(color(context: context).opacity(0.5))
Spacer()
}
.background(.black.opacity(0.6))
} else {
VStack(spacing: -3) {
Spacer(minLength: 14)
Text("\(context.state.taskName)")
.foregroundColor(.white)
Text(timerInterval: context.state.timer, countsDown: context.attributes.isTimer)
.multilineTextAlignment(.center)
.monospacedDigit()
.font(.system(size: 44, weight: .semibold))
.foregroundColor(color(context: context))
Spacer()
}
.background(.black.opacity(0.6))
}
}
func color(context: ActivityViewContext<TimerStopwatchAttributes>) -> Color {
if let color = UserDefaults.colorForKey(key: context.attributes.isTimer ? .timerBackground : .stopwatchBackground) {
return Color(color)
} else {
return Color(UIColor(named: context.attributes.isTimer ? "Background" : "Background2")!)
}
}
}
Always On Display | Dark Mode | Light Mode |
---|---|---|
위에서 본 widgetLiveActivity
내 dynamicIsland
클로저 내 정의하면 된다.
Dynamic Island 의 전체구조는 아래와 같다.
expanded
상태의 View 는 DynamicIsland
클로저 내에 영역별로 정의leading
, trailing
, conter
, bottom
영역별로 정의compact
상태의 View 는 compactLeading
, compactTrailing
클로저 내에 영역별로 정의minimal
상태의 view 는 minimal
클로저 내에 정의struct widgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerStopwatchAttributes.self) { context in
// Presentation on Lock Screen and as a banner on the Home Screen
LockScreenLiveActivityView(context: context)
} dynamicIsland: { context in
// Create the presentations that appear in the Dynamic Island.
DynamicIsland {
// Create the expanded presentation.
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.center) {
Text("Center")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom")
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T")
} minimal: {
Text("M")
}
}
}
}
expanded | compact | minimal |
---|---|---|
최종 구현한 코드
struct widgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerStopwatchAttributes.self) { context in
// Presentation on Lock Screen and as a banner on the Home Screen
LockScreenLiveActivityView(context: context)
} dynamicIsland: { context in
// Create the presentations that appear in the Dynamic Island.
DynamicIsland {
// Create the expanded presentation.
DynamicIslandExpandedRegion(.center) {
Text("\(context.state.taskName)")
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
Text(timerInterval: context.state.timer, countsDown: context.attributes.isTimer)
.multilineTextAlignment(.center)
.monospacedDigit()
.font(.system(size: 44, weight: .semibold))
.foregroundColor(color(context: context))
}
} compactLeading: {
Image("titiIcon")
.renderingMode(.template)
.colorMultiply(color(context: context))
} compactTrailing: {
Text(timerInterval: context.state.timer, countsDown: context.attributes.isTimer)
.monospacedDigit()
.frame(width: 50)
.font(.system(size: 12.7, weight: .semibold))
.foregroundColor(color(context: context))
} minimal: {
Image("titiIcon")
.renderingMode(.template)
.colorMultiply(color(context: context))
}
.contentMargins(.all, 8, for: .expanded)
}
}
func color(context: ActivityViewContext<TimerStopwatchAttributes>) -> Color {
if let color = UserDefaults.colorForKey(key: context.attributes.isTimer ? .timerBackground : .stopwatchBackground) {
return Color(color)
} else {
return Color(UIColor(named: context.attributes.isTimer ? "Background" : "Background2")!)
}
}
}
expanded | compact | minimal |
---|---|---|
본인의 경우 App 내에서 특정상황에 표시하도록 구현.
import ActivityKit
한다.if #available(iOS 16.2, *)
를 통해 사용가능한 버전인지 확인.ActivityAuthorizationInfo().areActivitiesEnabled
를 통해 활성화 가능한 상태인지 확인.ContentState
, ActivityAttributes
, ActivityContent
세가지가 필요.Activity.request
함수를 통해 표시된다.private func startLiveActivity() {
if #available(iOS 16.2, *) {
if ActivityAuthorizationInfo().areActivitiesEnabled {
let future = Calendar.current.date(byAdding: .second, value: self.times.timer, to: Date())!
let date = Date.now...future
let initialContentState = TimerStopwatchAttributes.ContentState(taskName: self.taskName, timer: date)
let activityAttributes = TimerStopwatchAttributes(isTimer: true)
let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 30, to: Date())!)
do {
let activity = try Activity.request(attributes: activityAttributes, content: activityContent)
print("Requested Lockscreen Live Activity(Timer) \(String(describing: activity.id)).")
} catch (let error) {
print("Error requesting Lockscreen Live Activity(Timer) \(error.localizedDescription).")
}
}
}
}
※ ActivityKit Push Notifications 방법을 통해 표시하는 방법도 존재.
본인의 경우 App 내에서 특정상황에 종료와 함께 제거하도록 구현.
ActivityContent
생성.activity.end
함수를 통해 종료된다.dismissalPolicy
옵션을 .immediate
설정하면 된다.private func endLiveActivity() async {
if #available(iOS 16.2, *) {
let finalStatus = TimerStopwatchAttributes.titiStatus(taskName: self.taskName, timer: Date.now...Date.now)
let finalContent = ActivityContent(state: finalStatus, staleDate: nil)
for activity in Activity<TimerStopwatchAttributes>.activities {
await activity.end(finalContent, dismissalPolicy: .immediate)
print("Ending the Live Activity(Timer): \(activity.id)")
}
}
}
ActivityKit
Creating a Widget Extension
Displaying live data with Live Activities
Live Activities
Github 전체 반영 코드
본 내용 PR 링크
한글번역 정리 블로그
velog: ActivityKit
참고 프로젝트
Github: Tradinza
Widget 관련
Keeping a Widget Up To Date