iyOmSd/Title: SwiftUI

[SwiftUI] Widget LiveActivity (feat. Dynamic Island) 잠금화면 기능

냄수 2023. 1. 14. 17:20
반응형

LiveActivity가 무엇이냐..?

잠금화면에서 확인할 수 있는 실시간 액션같은 기능이에요

대표적인 예로 배달의 민족에서 사용하고있어요

배달 시키면 이런거 잠금화면에서 볼수있죠?

네 이런겁니다~!

 

이걸 오늘 해보려고 합니다!

 

ActivityKit

을 사용해요

iOS16.1+ 부터 사용가능하구요!

 

위젯과 같이 만들어줘야하는 녀석이에요

프로젝트를 만들고

File > New > Target 에 widget을 선택하면 아래와같은 창이뜨는데

Include Live Activity 체크박스를 클릭해줘야합니다!

그러면 관련된 기본 템플릿이 쫙해서나올거에요

 

plist에가서

Supports Live Activities값을 YES로 권한 설정해줘야 사용가능합니다!

여기까지 셋팅을 끝냈으면 코딩만 남았네요

 

 

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

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

ActivityAttributes 변수로는 정적인프로퍼티

 즉, 변치않는 값을 정의하고

 

ActivityAttributes.ContentState에는 동적인프로퍼티

즉, 어디선가 동적으로 변경될 값을 정의해줍니다

 

위의 LockScreenWidgetAttributes를 토대로 아래에 위젯을 정의하게되요

context값으로 해당 변수값을 꺼내올 수 있어요

ActivityConfiguration(for: LockScreenWidgetAttributes.self) { context in
    // Lock screen/banner UI goes here
    // 잠금화면에 보여질 뷰를 정의합니다
    
    VStack(alignment: .leading) {
        Text("잠금화면에 뜨는 Live Activity")
        Text("이름: \(context.attributes.name)")  // 정적값 가져옴
        Text("진행률 \(context.state.value)")     // 동적값 가져옴
    }
    .padding(.leading)
    .activityBackgroundTint(Color.brown)
    .activitySystemActionForegroundColor(Color.orange)
    
} dynamicIsland: { context in
    // 다이나믹 아일랜드 저의
}

밑에서 언급하겠지만

update함수를 통해서 넣어줄수 있는값이 State이기 때문에

State타입만 동적으로 변경할 수 있는 그런 원리인것같아요

 

또한 dynamicIsland를 같이정의해줘요

Live Activity가 활성화된동안 dynamicIsland를 볼수있는 기기라면 같이 활성화되요

 

 

지금만든 LockScreenWidgetLiveActivity 파일 속성에서

앱쪽에서 이파일을 접근가능하도록 타겟을 추가해주세요

 

Live Activity 시작

request() 함수를 이용해서 정의합니다

func onLiveActivity() {
    // 앱이 live activity사용 가능한지여부
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
    let attribute = LockScreenWidgetAttributes(name: "NS")
    // stateful한 값
    let state = LockScreenWidgetAttributes.ContentState(value: 0)
    do {
        // live activity 시작
        self.activity = try Activity.request(attributes: attribute, contentState: state)
    } catch {
        print(error)
    }
}

위 타입에서 정의한 변수들을 정의해주고 담아서 보내면 끝이에요 간단하죠?

이부분은 앱쪽에서 실행되는 코드에요

앱쪽에서 특정이벤트발생시 Live Activity를 실행시키는거에요

.activityBackgroundTint(Color.brown) - 선택버튼영역 틴트컬러

.activitySystemActionForegroundColor(Color.orange) - 선택버튼 컬러

함수로 컬러를 지정할 수 도있어요

 

Live Activity 업데이트

업데이트도 간단합니다!

update()함수를 이용해요

뭐가 생겼다가 deprecate되기도하고 역시 swiftUI답게 격변이 심해요 ㅎ

func timer() {
    Timer.publish(every: 1, on: .main, in: .default)
        .autoconnect()
        .sink { [self] _ in
            num += 1
            Task {
                let newState = LockScreenWidgetAttributes.ContentState(value: num)
                // 애플워치에서 동작
                let alertConfiguration = AlertConfiguration(
                    title: "timer update",
                    body: "현재숫자: \(num)",
                    sound: .default
                )
                await activity?.update(using: newState, alertConfiguration: alertConfiguration)
            }
        }
        .store(in: &cancellable)
}

임의로 1초마다 업데이트를위해 타이머를 만들었고

정의해준 State값중 num을 하나씩 증가시킬거에요

 

여기서 AlertConfiguration도 정의해서 넣어주는데

이타입은 애플워치에서만 뜨는 알림창이에요 

 

위에서 진행률 부분만 동적변수를 넣었으니 그부분만 업데이트 되겠죠?

 

 

업데이트가되면 

다이나믹 아일랜드가 expand View형태로 보여져요 

최소화 할때는 compact View로 보여졌다가

Update가 일어나면 확장되면서 정의된 다른 뷰가 보여져요

진행률이 다이나믹 아일랜드에서도 증가되는게 보이시나요 ㅎㅎ

 

 

 

다이나믹 아일랜드

위의 영역처럼 위치가 타입으로 구분되어있어요

DynamicIslandExpandedRegion타입을 사용해서 정의해요

DynamicIsland {
    // Expanded UI goes here.  Compose the expanded UI through
    // various regions, like leading/trailing/center/bottom
    DynamicIslandExpandedRegion(.leading) {
        Text("시작")
    }
    DynamicIslandExpandedRegion(.trailing) {
        Text("끝")
    }
    DynamicIslandExpandedRegion(.center) {
        HStack {
            Image(systemName: "heart.fill").tint(.red)
            Text("진행률")
        }
    }
    DynamicIslandExpandedRegion(.bottom) {
        ProgressView("", value: Double(context.state.value) / context.attributes.totalNum)
    }
} compactLeading: {
    Text("0")
} compactTrailing: {
    Text("100")
} minimal: {
    // 다른앱에서도 live activiy가 있어서 떠있을때 작은 원모양으로 보여짐
    Text("A앱")
}

코드에서 DynamicIsland로 정의해준 부분이 업데이트시 확장되면서 보여지는 부분이에요

 

 

 

앱을 최소화시켰을때 Live Activity가 진행중이라면 보여지는 부분이에요

compact쪽에 정의해준 부분이 보여지죠

작은상태의 다이나믹 아일랜드를 롱클릭하면 

위에보이는 expand view로 확장시킬수 있어요

 

 

다른앱도 live activity를 지원해서 2개가 떳을경우에 minimal에 정의된 뷰로 보여져요

 

 

 

Live Activity 종료

종료도 간단합니다

end()함수를 이용해요

func offLiveActivity() {
    Task {
        // using: final dynamic content정의
        // policy: 디폴트, 즉시종료, date후 종료 설정가능
        await activity?.end(using: nil, dismissalPolicy: .default)
    }
}

 

ActivityState에는 3가지가있어요

public enum ActivityState : Sendable, Codable, Hashable {

    /// The Live Activity is active, visible to the user, and can receive content updates.
    /// 활성화상태, 유저한테보이고, 컨텐츠 업데이트 수신가능
    case active

    /// The Live Activity is visible, but the user, app, or system ended it, and it won't update its content
    /// anymore.
    /// 유저에게 보여지는 상태지만 사용자, 앱 또는 시스템에의해 종료됨, 컨텐츠 업데이트 안함
    case ended

    /// The Live Activity ended and is no longer visible because the user or the system removed it.
    /// 종료됨, 사용자또는 시스템이 제거했기때문에 볼수없음
    case dismissed

 

 

전체코드 첨부로 마무리할게요!

 

뷰코드

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Text("\(viewModel.num)")
            Button {
                viewModel.onLiveActivity()
            } label: {
                Text("라이브액티비티 on")
            }
            Button {
                viewModel.offLiveActivity()
            } label: {
                Text("라이브액티비티 off")
            }
        }
        .padding()
    }
}

 

뷰모델 코드

import Combine
import ActivityKit

final class ViewModel: ObservableObject {
    @Published var num: Int = 0
    private var cancellable: Set<AnyCancellable> = Set()
    private var activity: Activity<LockScreenWidgetAttributes>?
    
    func onLiveActivity() {
        // 앱이 live activity사용 가능한지여부
        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
        let attribute = LockScreenWidgetAttributes(name: "NS")
        // stateful한 값
        let state = LockScreenWidgetAttributes.ContentState(value: 0)
        do {
            self.activity = try Activity.request(attributes: attribute, contentState: state)
            timer()
        } catch {
            print(error)
        }
    }
    
    func offLiveActivity() {
        Task {
            // final dynamic content정의, 디폴트, 즉시종료, date후 종료 설정가능
            await activity?.end(using: nil, dismissalPolicy: .default)
            cancellable.removeAll()
        }
    }
    
    func timer() {
        Timer.publish(every: 1, on: .main, in: .default)
            .autoconnect()
            .sink { [self] _ in
                num += 1
                Task {
                    let newState = LockScreenWidgetAttributes.ContentState(value: num)
                    // 애플워치에서 동작
                    let alertConfiguration = AlertConfiguration(
                        title: "timer update",
                        body: "현재숫자: \(num)",
                        sound: .default
                    )
                    await activity?.update(using: newState, alertConfiguration: alertConfiguration)
                }
            }
            .store(in: &cancellable)
    }
}

 

Live Activity코드

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

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

struct LockScreenWidgetLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: LockScreenWidgetAttributes.self) { context in
            // Lock screen/banner UI goes here
            
            VStack(alignment: .leading) {
                Text("잠금화면에 뜨는 Live Activity")
                Text("이름: \(context.attributes.name)")
                Text("진행률 \(context.state.value)")
            }
            .padding(.leading)
            .activityBackgroundTint(Color.brown)
            .activitySystemActionForegroundColor(Color.orange)
            
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded UI goes here.  Compose the expanded UI through
                // various regions, like leading/trailing/center/bottom
                DynamicIslandExpandedRegion(.leading) {
                    Text("시작")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("끝")
                }
                DynamicIslandExpandedRegion(.center) {
                    HStack {
                        Image(systemName: "heart.fill").tint(.red)
                        Text("진행률")
                    }
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView("", value: Double(context.state.value) / context.attributes.totalNum)
                }
            } compactLeading: {
                Text("0")
            } compactTrailing: {
                Text("100")
            } minimal: {
                // 다른앱에서도 live activiy가 있어서 떠있을때 작은 원모양으로 보여짐
                Text("A앱")
            }
        }
    }
}

 

 

 

 

반응형