개발/Swift

LiveActivity Dynamic island 구현하기

덤벨로퍼 2024. 10. 16. 16:40

https://inf.run/V3b51

 

iOS Clean Architecture & MVVM: RxSwift 완전 정복 강의 | 덤벨로퍼 - 인프런

덤벨로퍼 | Clean Architecture와 MVVM 패턴을 실무에서 적용할 수 있도록 설명하며, RxSwift, Concurrency 등 필수 기술을 다룹니다., iOS Clean Architecture & MVVM: RxSwift 완전 정복현업에서 Clean

www.inflearn.com

 

Widget 기본 구조

import WidgetKit
import SwiftUI

@available(iOS 16.1, *)
@main
struct LivaActivityBundle: WidgetBundle {
    var body: some Widget {
        LivaActivityLiveActivity()
    }
}

LiveActivity 에서 확장 다이나믹 아일랜드가 16.1부터 지원이 되어서 어노테이션 추가

액티비티에 버튼 토글이 포함될경우에는 17을 써야함

아무튼 여기는 진입 시점이라 보면 됨

상태 관리

ActivityAttributes

인코딩 데이터 < 4KB 이하만 가능

 @available(iOS 16.1, *)
public struct LivaActivityAttributes: ActivityAttributes {
		// ContentState 내부에 상태 서술 - 동적 상태
    public struct ContentState: Codable, Hashable {
        let orderStatus: OrderStatus
    }
		// ContentState 외부에 상수 서술 - 정적 상태
    let ordernumber: Int
    let storeName: String
    let menuName: String
    let pickupTime: PickupTime
}

update를 통해 바꿔줄상태와 내부적으로 사용할 정적 데이터 정의

라이브 액티비티 UI 구현 하기

기본 구조

ActivityConfiguration(for: ...) { context in
    ... //여기에 잠금 화면 UI 구현
} dynamicIsland: { context in
    ... //다이나믹 아일랜드 UI 구현 - 확장, 축소 둘다
}

context 내부 데이터 구성은  ->  ActivityAttributes , ContentState, Live Activity의 고유 식별자 String

에 접근 가능하여 이거로 UI 구성 가능함

 

1. 잠금화면 Lock Screen 은 비교적 자유롭게 구현이 가능하다

//예시
ActivityConfiguration(for: LivaActivityAttributes.self) { context in
 VStack {
    HStack {
        Text("타이틀")
        Spacer()
        Text("\\(context.state.orderStatus.description)")
    }
    HStack {
        VStack {
            Text("주문번호 \\(context.attributes.ordernumber), \\(context.state.orderStatus.description)")
            Text(context.attributes.menuName)
        }
        Spacer()
        Image("imgCooked54")
    }
}
}

 

2. 다이나믹 아일랜드 부분 UI

//기본 구조
 DynamicIsland {
      // Expanded UI goes here
      DynamicIslandExpandedRegion(DynamicIslandExpandedRegionPosition.leading) {
          Text("Leading")
      }
      DynamicIslandExpandedRegion(.trailing) {
          Text("Trailing")
      }
      DynamicIslandExpandedRegion(.bottom) {
          Text("Bottom \\(context.state.emoji)")
      }
      DynamicIslandExpandedRegion(.center) {
          
      }
  }
            
            
// 타입 구조
 protocol DynamicIslandExpandedContent {} 
 struct DynamicIslandExpandedRegion : DynamicIslandExpandedContent {}

 

활용 예시이다 context에 접근하여 UI를 구성했다

//예시
dynamicIsland: { context in
    //확장형 UI
      DynamicIsland {

          DynamicIslandExpandedRegion(.leading, priority: 1.0) {
              VStack(alignment: .leading) {
                  Text("Title")
                  Spacer()
                  Text("주문상태 \\(context.state.orderStatus.description)")
                  Text(context.attributes.menuName)
              }
              .background(Color.red)
              .dynamicIsland(verticalPlacement: .belowIfTooWide) //Leading 영역이 bottom까지 먹도록 함
          }
          DynamicIslandExpandedRegion(.trailing) {
              VStack(alignment: .trailing) {
                  Text(context.state.orderStatus.description)
                  Spacer()
                  Group {
                      if context.attributes.pickupTime == .immediatly {
                          Image("imgCooked54")
                      } else {
                          Text(context.attributes.pickupTime.title)
                      }
                  }
              }
              .background(Color.blue)
          }
          
         //축소형 UI
      } compactLeading: {
          Text("Title")
      } compactTrailing: {
          Text(context.state.orderStatus.description)
      } minimal: {
          Text("Mini") //다른곳에서 라이브액티비티 실행하면 보여짐 (ex>티이머 시작)
      }
      .keylineTint(Color.red)
  }

// 추가 속성

keylineTint - 테두리
contentMargins - 마진

 

 

다이나믹 아일랜드 배경색을 지정 해주려했지만 불가 하다고 함

You can’t change the background color of Live Activities in the Dynamic Island.

 

잠금화면 액티비티는 다크모드 라이트모드에따라 다르며 지정도 가능함

 

 

딥링크 - 반드시 한개

.widgetURL(URL(string: "딥링크 URL"))

// 앱 SceneDelegate 함수 호출
 func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>)
 여기서 딥링크 처리

 

참고 사항

OS 버전별 특징

iOS 16.* 버전에서는 Live Activity에서 가능한 상호작용이 딥링크 처리만 가능

iOS 17 및 iPadOS 17 이상부터는 Live Activity에 상호작용이 가능. 즉, 앱을 열지 않고 앱의 기능만 호출하는 것이 가능하나 제약 조건이 있다

Button과 Toggle만 사용할 수 있다
잠금 해제 인증을 선 요구한다.
expanded View 및 잠금 화면 View에서만 상호작용 가능하다.

Live • 아이폰 '설정' 앱 > 해당 앱 > '실시간 현황' 활성화 여부(사용자가 제어 가능)

사용자 기기에서 활성화된 Live Activity 개수가 수용 가능한 최대치에 도달하면 새 Live Activity를 시작하는 것은 실패

 

이벤트 처리

1. 라이브 액티비티 생성

ActivityContent

let state: State - Attributes.ContentState 정의한거 넣어줌
let staleDate: Date? - outdate 시점
let relevanceScore: Double - 우선순위

Request - 앱 켜져있을때 호출

 if ActivityAuthorizationInfo().areActivitiesEnabled {
 if #available(iOS 16.2, *) {
    let activityAttributes = LivaActivityAttributes(ordernumber: 3, storeName: "매장명",
                                                    menuName: "아이스 아메리카노", pickupTime: .ten_minute)
    let state = LivaActivityAttributes.ContentState(orderStatus: .paymentChecking)
    let tenMinutesLater = Calendar.current.date(byAdding: .minute, value: 10, to: Date.now)

    let content = ActivityContent(state: state, staleDate: tenMinutesLater)
    do {
        let activity = try Activity.request(attributes: activityAttributes, content: content)
        
    } catch let error {
        print("LivaActivityAttributes request Error - \\(error)")
    }
}
      

attribute - Attributes(정적데이터)
content - ActivityContent

2. 읽기 ( 현재 액티비티를 가져올수 있음)

if #available(iOS 16.2, *) {
  guard let currentActivity = Activity<LivaActivityAttributes>.activities.last else { return } 
  //가장 최근 액티비티 가져옴
  
  
}

// activity 안에 이런거 들어있음
final let id: String
final let attributes: Attributes
var content: ActivityContent<Activity<Attributes>.ContentState> { get }
var activityState: ActivityState { get }
var pushToken: Data? { get }

// 액티비티 상태 ActivityState
case active
case dismissed - 지워짐
case ended - 종료( 표시는 되지만 업데이트안댐)
case stale - 최신X

activityUpdates - 액티비티 업데이트되면 수신받는거

Task {
    for await newActivity in Activity<MyActivityAttributes>.activityUpdates {
        print("new activity added: \\(newActivity.id)")
    }
}

//아래 변동 사항이 생기면 위 호출됨
var contentUpdates: Activity<Attributes>.ContentUpdates { get }
var activityStateUpdates: Activity<Attributes>.ActivityStateUpdates { get }
var pushTokenUpdates: Activity<Attributes>.PushTokenUpdates { get }

static var activityUpdates: Activity<Attributes>.ActivityUpdates { get }

3. 업데이트

 if #available(iOS 16.2, *) {
    guard let currentActivity = Activity<LivaActivityAttributes>.activities.last else { return } //읽기 참조
    let newContentState = LivaActivityAttributes.ContentState(orderStatus: .cooking)
    let tenMinutesLater = Calendar.current.date(byAdding: .minute, value: 10, to: Date.now)
  
    let updatedContent = ActivityContent(state: newContentState, staleDate: tenMinutesLater) //업데이트할 상태 포함
    
    Task {
        await currentActivity.update(updatedContent)
    }
    
}
    
    
// AlertConfiguration 인자 넣을수있음 (워치용)

4. 종료 (dismissed 상태)

종료해도 사용자가 지우기 전까지는 잠금화면에 남아있음

그래서 종료 상태도 포함시켜야 함

if #available(iOS 16.2, *) {
      guard let currentActivity = Activity<LivaActivityAttributes>.activities.last else { return }

      let endContentState = LivaActivityAttributes.ContentState(orderStatus: .cooked)
      let endendContent = ActivityContent(state: endContentState, staleDate: nil)

      Task {
          await currentActivity.end(endendContent, dismissalPolicy: .immediate)
      }
  }
  

위 처럼 dismissalPolicy를 즉시로 지정해주면 락스크린에서 바로 지워지게 할수도 있음

Extension 생성 테스트

  1. widget extension 생성, infoPlist수정, extension targetOS 15로 수정
  2. 생성된 모든 기능이 16.1이상이라 모두 어노테이션 추가
@available(iOS 16.1, *)

  1. 호출부(액티비티 생성, 업데이트, 종료) 에서는 16.2 이상
 if #available(iOS 16.2, *) 

에러 발생

1. 접근 문제

앱(호출부) 에서 타입을 못불러올때

Cannot find type 'LivaActivityAttributes' in scope
-> public 접근제한 수정 
Undefined symbol: nominal type descriptor for LivaActivityExtension.LivaActivityAttributes
-> extension 접근제한 수정
-> 위젯관련 코드 제거, preview 제거

-> extension 에서 타겟 설정 (public 한 놈들만 설정)
@main 어노테이션 있는부분은 냅둬야 앱 @main 이랑 충돌 안남 

 

2. 다크 모드 문제

다크모드 대응을 위해 colorScheme을 사용

    @Environment(\\.colorScheme) var colorScheme

   if colorScheme == .dark {
                    Color.black500
                } else {
                    Color(.systemGray6)
                        .opacity(0.82)
                }
               

이렇게 배경색을 동적으로 바꿔줌 → 성공

그런데 아래 두가지 경우 실패했다.

 

Image 변경 → 실패

text color 변경 → 실패

f colorScheme == .dark {
    Image("darkImage")
} else {
   Image("Image")
}
             
Text("sdfsfdf").foregroundColor(colorScheme == .dark ? Color.orange450 : Color.grey800)

 

이상하게 조건문으로 대응이 안됨, 다른 문제는 없고 그냥 start할떄 사용한 라이브액티비티의 다크모드 라이트모드 대로 결정되고

이후에 화면 모드를 바꿔도 적용이 안됨

해결방법은 Image Set 에 다크모드용 이미지를 적용

Color Set에 다크모드용 색상을 적용하는것

Assets에서 해당 이미지 클릭→ Appearance 에 Any,Dark로 바꿔주고 다크 모드용 이미지를 따로 넣어주면

코드상 조건문 쓰지 않아도 됨,

Color set도 마찬가지로 대응