ABOUT ME

Swift /Flutter 를 다루는 앱 개발자의 블로그 하지만 웨이트에 진심

Today
Yesterday
Total
  • LiveActivity Dynamic island 구현하기
    개발/Swift 2024. 10. 16. 16:40

     

    https://inf.run/iDaq4

     

    SwiftUI + TCA: 실전 프로젝트로 완성하는 차세대 iOS 아키텍처 강의 | 덤벨로퍼 - 인프런

    덤벨로퍼 | 복잡한 SwiftUI 상태 관리, TCA (The Composable Architecture)로 깔끔하고 견고한 앱을 만드세요. 실전 프로젝트 예제로 핵심만 빠르게 배웁니다. , SwiftUI + TCA: 실전 프로젝트로 완성하는 차세대

    www.inflearn.com

     

     

    Widget 기본 구조

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

     

    LiveActivity 구현의 시작점은 Widget Extension입니다. WidgetKitSwiftUI를 임포트하여 사용합니다1.
    @available(iOS 16.1, *) 어노테이션은 확장 다이나믹 아일랜드가 iOS 16.1부터 지원되기 때문에 추가해야 합니다12. 만약 액티비티에 버튼 토글이 포함될 경우, iOS 17 이상에서만 지원되므로 해당 기능 사용 시에는 @available(iOS 17, *) 어노테이션 또는 조건문 처리가 필요합니다211.

     

    @main struct LivaActivityBundle: WidgetBundle 구조는 위젯의 진입 시점을 나타냅니다

    상태 관리

    Live Activity의 상태는 ActivityAttributes를 통해 관리됩니다.
    ContentState: Live Activity 내부에서 동적으로 변경될 수 있는 상태를 서술합니다. 이 데이터는 인코딩 시 4KB 이하여야 합니다.
    ActivityAttributes 자체 속성: ContentState 외부에 상수로 정의되는 정적 데이터입니다.
    update를 통해 변경할 상태와 내부적으로 사용할 정적 데이터를 정의합니다.
     @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 구현 하기

    라이브 액티비티 UI는 잠금 화면다이나믹 아일랜드 두 가지 영역에 대해 구현해야 합니다

    기본 구조

    ActivityConfiguration(for: ...) { context in
        ... //여기에 잠금 화면 UI 구현
    } dynamicIsland: { context in
        ... //다이나믹 아일랜드 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")
        }
    }
    }
    

    잠금 화면 (Lock Screen): 비교적 자유롭게 구현이 가능합니다. ActivityConfiguration의 첫 번째 클로저 내부에 UI를 정의하며, context를 통해 attributes (정적 데이터)와 state (동적 상태)에 접근하여 정보를 표시할 수 있습니다.

     

     

    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 {}
    
    다이나믹 아일랜드 (Dynamic Island): 확장형과 축소형 UI를 모두 정의해야 합니다.

     

    확장형 (Expanded): DynamicIsland 클로저 내부에 정의하며, 여러 영역(DynamicIslandExpandedRegion)으로 나눌 수 있습니다. 정의 가능한 영역은 leading (왼쪽), trailing (오른쪽), bottom (하단), center (중앙) 입니다. context를 통해 attributes state에 접근하여 UI를 구성합니다. 특정 영역(Leading)이 bottom까지 확장되도록 하는 .dynamicIsland(verticalPlacement: .belowIfTooWide) 속성을 사용할 수 있습니다. 다이나믹 아일랜드의 배경색은 변경할 수 없습니다.
    축소형 (Compact): 다이나믹 아일랜드 영역이 작을 때 표시되는 UI로, compactLeading compactTrailing으로 나뉘어 정의합니다.
    Minimal: 다른 곳(예: 타이머 시작)에서 Live Activity가 실행될 때 보여지는 최소화된 UI입니다. 다이나믹 아일랜드에는 .keylineTint와 같은 추가 속성을 적용할 수 있습니다.
     
     

    활용 예시

    //예시
    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 - 마진
    

    https://inf.run/V3b51

     

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

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

    www.inflearn.com

     

     

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

    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에서 딥링크 처리만 상호작용으로 가능합니다. .widgetURL(URL(string: "딥링크 URL"))을 사용하여 딥링크를 지정할 수 있으며, 앱의 SceneDelegate에서 처리합니다.

     

    iOS 17 및 iPadOS 17 이상: 앱을 열지 않고 앱의 기능을 호출하는 상호작용이 가능해집니다. 제약 조건이 있으며, Button과 Toggle만 사용할 수 있습니다. 또한, 잠금 해제 인증을 먼저 요구하며, expanded View 및 잠금 화면 View에서만 상호작용이 가능합니다.

     

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

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

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

     

    이벤트 처리

    Live Activity는 생성, 읽기, 업데이트, 종료의 생명주기를 가집니다. 이 과정은 주로 iOS 16.2 이상에서 호출됩니다.

     

    1. 라이브 액티비티 생성

    생성: Activity.request 메서드를 사용합니다. attributes (정적 데이터)와 content (동적 상태 및 만료 시점 등)를 인자로 넘겨줍니다. ActivityContentstate (Attributes.ContentState에 정의된 동적 상태)와 staleDate (outdate 시점), relevanceScore (우선순위)를 포함합니다. 액티비티 생성이 가능한지 ActivityAuthorizationInfo().areActivitiesEnabled로 확인할 수 있습니다.

     

    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. 읽기 ( 현재 액티비티를 가져올수 있음)

    읽기: 현재 활성화된 액티비티를 가져올 수 있습니다. Activity<LivaActivityAttributes>.activities.last를 사용하면 가장 최근 액티비티를 가져올 수 있습니다. 가져온 activity 객체는 id, attributes, content, activityState, pushToken 등의 속성을 가집니다. activityStateactive, dismissed, ended, stale 상태를 나타냅니다. activityUpdates를 통해 새로운 액티비티가 추가되는 것을 수신받을 수 있습니다. contentUpdates, activityStateUpdates, pushTokenUpdates 등의 변동 사항 업데이트도 수신 가능합니다.

     

    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. 업데이트

    업데이트: currentActivity.update 메서드를 사용합니다. 새로운 ActivityContent 객체 (업데이트할 state 포함)를 인자로 넘겨주어 상태를 갱신합니다.
     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 상태)

    종료: currentActivity.end 메서드를 사용합니다. 종료해도 사용자가 직접 지우기 전까지는 잠금 화면에 남아있을 수 있습니다. 종료 상태도 포함시켜야 합니다. dismissalPolicy.immediate로 지정하면 잠금 화면에서 바로 지워지게 할 수 있습니다.
    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 생성 테스트

    Live Activity 구현을 위해 widget extension을 생성해야 합니다. 생성 시 infoPlist를 수정하고 extension의 target OS를 필요에 따라 조정할 수 있습니다.
    생성된 코드들은 대부분 16.1 이상 기능이므로 @available(iOS 16.1, *) 어노테이션을 추가해야 합니다.
    액티비티 생성, 업데이트, 종료 호출부는 iOS 16.2 이상에서 이루어집니다.
    @available(iOS 16.1, *)
    
    
    1. 호출부(액티비티 생성, 업데이트, 종료) 에서는 16.2 이상
     if #available(iOS 16.2, *) 
    

    에러 발생

    1. 접근 문제

    접근 문제: 앱(호출부)에서 extension의 타입을 찾지 못하는 경우 (Cannot find type, Undefined symbol)는 접근 제한자(public) 및 extension 타겟 설정을 확인해야 합니다. 위젯 관련 코드나 preview를 제거하고, extension에서 public으로 설정된 부분만 타겟에 포함시킵니다. @main 어노테이션이 있는 부분은 충돌 방지를 위해 유지해야 합니다.

     

    앱(호출부) 에서 타입을 못불러올때
    
    Cannot find type 'LivaActivityAttributes' in scope
    -> public 접근제한 수정 
    Undefined symbol: nominal type descriptor for LivaActivityExtension.LivaActivityAttributes
    -> extension 접근제한 수정
    -> 위젯관련 코드 제거, preview 제거
    
    -> extension 에서 타겟 설정 (public 한 놈들만 설정)
    @main 어노테이션 있는부분은 냅둬야 앱 @main 이랑 충돌 안남 
    

     

    2. 다크 모드 문제

    다크 모드 문제: colorScheme 환경 변수를 사용하여 배경색을 동적으로 변경하는 것은 가능합니다. 하지만 이미지 변경이나 텍스트 색상 변경을 조건문으로 처리하는 것은 실패할 수 있습니다.
    Live Activity가 시작될 때의 화면 모드에 따라 결정되고 이후 변경이 적용되지 않는 현상이 있을 수 있습니다.
     
    해결 방법은 Image Set이나 Color Set에 다크 모드용 리소스를 직접 적용하는 것입니다.
    Assets 카탈로그에서 이미지나 색상의 Appearance 설정을 Any, Dark로 변경하고 각 모드에 맞는 리소스를 넣어주면 코드상의 조건문 없이 자동으로 대응됩니다.
     

    다크모드 대응을 위해 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도 마찬가지로 대응

     

    https://inf.run/iDaq4

     

    SwiftUI + TCA: 실전 프로젝트로 완성하는 차세대 iOS 아키텍처 강의 | 덤벨로퍼 - 인프런

    덤벨로퍼 | 복잡한 SwiftUI 상태 관리, TCA (The Composable Architecture)로 깔끔하고 견고한 앱을 만드세요. 실전 프로젝트 예제로 핵심만 빠르게 배웁니다. , SwiftUI + TCA: 실전 프로젝트로 완성하는 차세대

    www.inflearn.com

     

    댓글

Designed by Tistory.