iOS Develop / Live Activities & Dynamic Island 구현

Minsang Kang·2023년 2월 15일
2

iOS Develop

목록 보기
1/4
post-thumbnail

Widget Extension 생성

Live Activities & Dynamic Island 를 지원하기 위해서는 Widget Extension 이 필요했다.
Widget extension 내 여러 widget 들과 함께 Live Activities 가 추가되는 원리.

  • Widget Extension 의 경우 기능별로 (location 필요, 불필요 등) 여러 extension 생성 가능.

File -> New -> Target -> iOS -> Widget Extension 통해 생성

  • Prodduct Name: widget 입력
  • Include Live Activity 선택 (자동으로 Live Activity 예제코드 생성)
  • Include Configuration Intent 선택 (위젯 편집 기능 제공)

Info.plist 파일 내 key: NSSupportsLiveActivities, value: YES (Boolean) 추가

WidgetBundle 생성

Widget Extension 생성으로 자동으로 WidgetBundle 가 생성된다.
WidgetBundlewidgetwidgetLiveActivity 가 포함되는 식.

  • 현재 widget 구현이 안된 상태이므로 주석.
import WidgetKit
import SwiftUI

@main
struct widgetBundle: WidgetBundle {
    var body: some Widget {
        // widget()
        if #available(iOS 16.2, *) {
            widgetLiveActivity()
        }
    }
}

ActivityAttributes 생성

Live Activities & Dynamic Island 에서 표시할 데이터를 정의하기 위한 구조체.

  • 동적인 데이터 (Live Activities 업데이트시 변경 가능한 데이터): ContentState 내에 정의.
  • 정적인 데이터 (Live Activities 생성시 최초만 필요한 데이터): 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 MembershipApp, WidgetExtension 모두 체크해야 한다.

LiveActivities 생성

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.
            // ...
        }
    }
}

LiveActivityView 생성

context 를 통해 ActivityAttributes 구조체를 받아 view 에 표시한다.
isLuminanceReduced 변수를 통해 iPhone 14Pro 사용자의 Always On Display 상태의 농도가 옅은 view 를 저의 할 수 있다.
View 표시에 필요한 Assets 의 경우 WidgetExtension 추가로 생성된 Assets 내 추가되어야 한다.

  • func color(context) 의 경우 App 과 공유되는 UserDefaults.shared 내 컬러값을 가져오기 위한 함수
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    

Dynamic Island 생성

위에서 본 widgetLiveActivitydynamicIsland 클로저 내 정의하면 된다.
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")
            }
        }
    }
}
expandedcompactminimal

최종 구현한 코드

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")!)
        }
    }
}
expandedcompactminimal

LiveActivities 표시

본인의 경우 App 내에서 특정상황에 표시하도록 구현.

  • ActivityKit 을 통해 표시하므로 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 방법을 통해 표시하는 방법도 존재.

LiveActivities 종료 및 제거

본인의 경우 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

profile
 iOS Developer

0개의 댓글