본문 바로가기
IOS App Programming/IOS 연습

ActivitiKit 사용해보기(IOS 16.2 이후 업데이트 버전)

by B_Tori 2024. 3. 13.

Project 시작하기

1. widget Extension 추가 (file - new - target)

2. Info.plist 에 Supports Live Activities 추가 후 값은 Yes로 변경

LiveActivity 구현

LiveActivity 파일 살펴보기

구성을 살펴보면 다음과 같다.

처음에 살펴보면 이게 뭔가 싶다. 아직도 난 이게 뭔가 싶다.

일단 두 개의 구조체 확인 가능하다.

  • ActivityKitWidgetAttributes: 사용될 프로퍼티들 정의
  • ActivityKitWidgetLiveActivity: 보일 뷰 정의

ActivityKitWidgetAttributes

  • ActivityKitWidgetAttributes : 정적 프로퍼티 (변화하지 않는 값) - name
  • ActivityKitWidgetAttributes.ContentState: 동적 프로퍼티 (변화하는 값) - emoji

ActivityKitWidgetLiveActivity

  • ActivityConfiguration(for: ActivityKitWidgetAttributes.self) : 잠금화면에 보일 뷰 정의 (다이나믹 아일랜드를 지원하지 않는 기기도 가능)
  • dynamicIsland : 다이나믹아일랜드를 지원하는 기기라면 라이브 액티비티가 활성화되어 있는 동안 다이나믹 아일랜드 화면에 보일 뷰 정의
    • DynamicIsland { } : 다이나믹 아일랜드가 확장 UI를 가질 때 뷰 정의
    • compactLeading, compactTrailing { } : 다이나믹 아일랜드가 기본 컴팩트 UI를 가질 때 뷰 정의
    • minimal { } : 다른 앱의 다이다믹 아일랜드와 중첩되어 미니멀한 UI를 가질 때 뷰 정의

Compact UI expanded UI  minimal UI

IOS 앱에서 Activity Kit 연결

struct ActivityKitWidgetAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        // Dynamic stateful properties about your activity go here!
        var count: Int
    }

    // Fixed non-changing properties about your activity go here!
    var name: String
}

모델은 다음과 같다 타이머를 통해서 1초에 1씩 Count를 증가시킬계획이다.

struct ContentView: View {
    @State private var count: Int = 0
    var body: some View {
        VStack {
            Text("Count (100까지)")
            Text("\\(count)")
                .font(.title)
                .padding()
            Button {
                if !ActivityAuthorizationInfo().areActivitiesEnabled {
                    return
                }
                let attributes = ActivityKitWidgetAttributes(name: "Count 100")
                let state = ActivityKitWidgetAttributes.ContentState(count: count)
                let content = ActivityContent(state: state, staleDate: nil, relevanceScore: 1.0)
                do {
                    let activity = try Activity<ActivityKitWidgetAttributes>.request(attributes: attributes, content: content)
                    print(activity)
                } catch {
                    print(error)
                }
            } label: {
                Text("Activity Kit Start")
            }
        }
        .padding()
    }
}

아직 타이머 연결은 하지 않고 Activity Kit 연결만 시킨 상태이다.

  • ActivityAuthorizationInfo().areActivitiesEnabled : live activity 사용 가능 한지 확인
    • attributes : 정적 어트리뷰트 객체
    • content : 동적 어트리뷰트 객체
    • pushType : 활동 업데이트 푸시 알림 제공 여부. request(attributes:content:pushType:) : Live Activity 시작 요청

IOS 16.2 이전 까지는 Activity<ActivityKitWidgetAttributes>.request(attributes: attributes, contentState: state) 이처럼 사용할 수 있었지만 deprecated 되고 contentState 부분에 차이가 생겼다.

 

정적 어트리뷰트는 그대로 객체를 생성하여 주입해주고,

동적 어트리뷰트의 경우

.init(state:staleDate:relevanceScore:) 를 활용하여 content를 초기화해야 한다.

  • state : ActivityAttributes.ContentState 타입으로 라이브 활동의
  • staleDate : Live Activity가 만료되는 Date 값, nil 설정 시 8시간 후 Live Activity를 종료
  • relevanceScore : 라이브 활동이 두 개 이상인 경우 우선순위

새 활동을 요청할 시에는 앱이 포그라운드에 있어야 함

Timer로 Attribute State 업데이트하기

뷰모델을 따로 구현하여 업데이트 로직을 구현하였다.

import Foundation
import ActivityKit

final class ViewModel: ObservableObject {
    @Published var count = 0
    private var timer: Timer?
    private var activity: Activity<ActivityKitWidgetAttributes>?
    func startLiveActivity() {
        if !ActivityAuthorizationInfo().areActivitiesEnabled {
            return
        }
        let attributes = ActivityKitWidgetAttributes(name: "Count 100")
        let state = ActivityKitWidgetAttributes.ContentState(count: count)
        let content = ActivityContent(state: state, staleDate: nil, relevanceScore: 1.0)
        do {
            if activity == nil {
                activity = try Activity<ActivityKitWidgetAttributes>.request(attributes: attributes, content: content)
            }
            startTimer()
        } catch {
            print(error)
        }
    }

    func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { [weak self] _ in
            guard let self = self else { return }
            let newState = ActivityKitWidgetAttributes.ContentState(count: count)
            let newContent = ActivityContent(state: newState, staleDate: nil, relevanceScore: 1.0)

            if count >= 100 {
                stopTimer()
                Task { [weak self] in
                    guard let self = self else { return }
                    await activity?.end(newContent, dismissalPolicy: .immediate)
                }
                return
            }
            count += 1
            Task { [weak self] in
                guard let self = self else { return }
                await activity?.update(newContent)
            }
        })
    }

    func stopTimer() {
        timer?.invalidate()
    }
}

타이머 스타트 버튼을 누르면 startLiveActivity() 함수가 실행된다.

activity 값이 없으면 아직 activity를 실행하지 않아 생성하지 않았다면, 아까 연결 코드에서 작성했던 코드를 실행해 준다. activity 값이 있으면 이미 생성했으므로 이 과정을 생략한다.

 

활동 업데이트

타이머를 통해 1초마다 count의 값이 올라가도록 설정했다.

단순히 count += 1을 하면 IOS 앱 화면에서는 업데이트가 되지만 Live Activity 화면에서는 업데이트되지 않았다.

 

update() 함수를 통해 State값을 새로 업데이트시켜줘야 했다.

당연히 정적 어트리뷰트는 변하지 않는 값이므로 동적 어트리뷰트를 업데이트해주는 과정이다.

 

request 할 때와 똑같이 16.2 이상에서는 ContentState객체를 만들고 ActivityContent로 변환해 주어 update 함수에 전달해 주면 된다.

update 함수가 async 함수이기에 await 키워드를 사용하였다.

활동 끝내기

카운트가 100이 넘으면 활동을 끝내도록 하였다.

await activity?.end(newContent, dismissalPolicy: .immediate)

종료 또한 16.2 이상에서는 마지막으로 표시될 객체를 ContentState객체를 만들고 ActivityContent로 변환해 주어 함수에 전달해 줬다.

  • dismissalPolicy : 잠금 화면에서 제거해야 하는 방법과 시기로 .immediate 를 통해 즉각 제거하도록 하였다.

제거하게 되면 다이나믹아일랜드는 물론 알림센터에 뜨던 활동들도 사라진다.

Dynamic Island 꾸미기

이제 다이나믹 아일랜드 뷰를 꾸며볼 것이다.

                 dynamicIsland: { context in
            DynamicIsland {
                // Expanded UI goes here.  Compose the expanded UI through
                // various regions, like leading/trailing/center/bottom
                DynamicIslandExpandedRegion(.leading) {
                    Text(context.attributes.name)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("\\(context.state.count)")
                }
                DynamicIslandExpandedRegion(.center) {
                    Text("Activity Kit Ex")
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(value: Float(context.state.count) / 100.0)
                }
            } compactLeading: {
                ZStack {
                    Circle()
                        .stroke(Color.gray, lineWidth: 3)
                    Circle()
                        .trim(from: 0.0, to: CGFloat(min(Float(context.state.count) / 100.0, 1.0)))
                        .stroke(Color.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round))
                        .rotationEffect(Angle(degrees: -90))
                }
                .padding(2)
            } compactTrailing: {
                Text("\\(context.state.count)")
            } minimal: {
                Text("\\(context.state.count)")
            }

뷰를 그리는 내용에 대한 설명은 생략하겠다.

위에서 설명했듯 dynamicIsland: 부분에서 DynamicIslandExpandedRegion(.위치) 를 통해 각 위치마다 뷰를 그릴 수 있도록 하였다.

bottom의 경우 현재는 프로그래스 뷰만 작성하였지만 여러 뷰를 선언하면 밑으로 길게 쌓이는 형식이다.

compactLeading, compactTrailing 부분에서는 컴펙트 프레젠테이션 시 보여줄 뷰의 모습을 그리고 있다.

댓글