[React Native] RN에서 Dynamic island 위젯 만들기

profile image pIutos 2024. 5. 24. 16:33

들어가며

React Native에서 Dynamic Island 위젯을 만드는 방법에 대해 작성했습니다.

Info.plist

파일 수정

프로젝트에서 Live Activity를 사용하기 위해, 프로젝트의 info.plist에 Supports Live Activities: YES 옵션을 추가합니다.

새로운 Widget Extension 생성하기

File > New > Target으로 들어가서, Widget Extension을 선택합니다.

이후 'Include Live Activity' 옵션을 체크한 다음, 원하는 이름으로 위젯을 생성합니다. 저는 이름을 DynamicIslandWidget으로 생성했습니다. (App Intent 옵션은 끄는 것을 추천합니다.)

App Group 생성하기

앱에서 쓴 데이터인 UserDefaults를 위젯에서도 사용할 수 있도록 App group을 만들어야 합니다.

앱에 App Group이 없다면, Apple Developer > Identifier에서 새로운 App Group을 생성합니다.

이후 xcode 프로젝트에서 RN project의 target과 DynamicIslandWidget target에 Signing & Capabilities > '+ Capability'를 눌러 App Groups를 생성한 다음, 아까 생성한 group identifier를 선택합니다.

Dynamic Island 작성하기

기본적으로 새로운 Widget Extension Target을 생성하면 사진과 같은 파일이 생성됩니다.

기본적으로 DynamicIslandWidget.swift 파일도 같이 생성되는데, 저는 LiveActivity만 사용하고 싶기 때문에 삭제했습니다. 위젯도 같이 작업하신다면 지우지 않으셔도 됩니다.

~Bundle.swift 파일 수정하기

// DynamicIslandWidgetBundle.swift

import WidgetKit
import SwiftUI

@main
struct DynamicIslandWidgetBundle: WidgetBundle {
    var body: some Widget {
        DynamicIslandWidget() // 사용하지 않는다면 파일과 함께 삭제
        DynamicIslandWidgetLiveActivity()
    }
}
// DynamicIslandWidgetBundle.swift

import WidgetKit
import SwiftUI

@main
struct DynamicIslandWidgetBundle: WidgetBundle {
    var body: some Widget {
      if #available(iOS 16.1, *) {
        DynamicIslandWidgetLiveActivity()
      }
    }
}

Project의 Deployment Target이 아래와 같이 16.1 이하로 설정되어있다면 DynamicIslandWidgetLiveActivity 해당 조건문을 추가합니다.

~LiveActivity.swift 파일 수정하기

우선 앱에서 해당 파일의 Dynamic Island를 사용하기 위해서 Target Membership에 RN project target도 체크해줍니다.

이후 해당 파일에서 swiftUI를 이용해 원하는 ui와 기능으로 코드를 작성하면 됩니다.

// DynamicIslandWidgetLiveActivity.swift

@available(iOS 16.1, *)
struct DynamicIslandWidgetLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DynamicIslandWidgetAttributes.self) { context in
          LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                  LeadingView()
                }
                DynamicIslandExpandedRegion(.trailing) {
                  TrailingView(context: context)
                }

              DynamicIslandExpandedRegion(.bottom) {
                  ContentView(context: context)
                }
            } compactLeading: {
                CompactLeadingView(context: context)
            } compactTrailing: {
                CompactTrailingView(context: context)
            } minimal: {
                EmptyView()
            }
        }
    }
}

추가적으로 Dynamic Island는 이미지와 같이 Expanded View와 Compact View, Minimal View로 구분되어 있고, 각 View에는 세부적으로 영역이 구분되어있습니다.

RN과 통신을 위한 module과 bridge 작성하기

module 파일 생성

새로운 Swift File을 생성합니다. 이름은 DynamicIslandModule로 지었습니다. 해당 모듈의 target은 RN project만 체크해주세요.

해당 모듈에서 Dynamic Island를 컨트롤하는 함수를 작성합니다. 필요한 함수는 아래와 같습니다.

  • startActivity
  • updateActivity
  • endActivity
// DynamicIslandModule.swift

import Foundation
import ActivityKit

@available(iOS 16.2, *)

@objc(DynamicIslandModule)
class DynamicIslandModule: NSObject {
  @objc(startActivity:withSeatNumber:withIsUsing:withDateInterval:)
  func startActivity(seatRoomName: String, seatNumber: String, isUsing: Bool, dateInterval: NSNumber) -> Void {
    do {
      let ActivityAttributes = DynamicIslandWidgetAttributes(seatRoomName: seatRoomName, seatNumber: seatNumber)
      let ActivityContentState = DynamicIslandWidgetAttributes.ContentState(isUsing: isUsing, dateInterval: Double(truncating: dateInterval))

      let _ = try Activity<DynamicIslandWidgetAttributes>.request(attributes: ActivityAttributes, contentState: ActivityContentState, pushType: nil)
    } catch {
      print("Error")
    }
  }

  @objc(updateActivity:withDateInterval:)
  func updateActivity(isUsing: Bool, dateInterval: NSNumber) -> Void {
    let ActivityContentState = DynamicIslandWidgetAttributes.ContentState(isUsing: isUsing, dateInterval: Double(truncating: dateInterval))
    Task {
      for activity in Activity<DynamicIslandWidgetAttributes>.activities {
        await activity.update(using: ActivityContentState)
      }
    }
  }


  @objc(endActivity)
  func endActivity() -> Void {
    Task {
      for activity in Activity<DynamicIslandWidgetAttributes>.activities {
        await activity.end(dismissalPolicy: .default)
      }
    }
  }
}

Bridge 만들기

위에서 작성한 Dynamic Island를 컨트롤하는 함수를 RN에서도 사용할 수 있게 해당 모듈을 내보내고 등록하는 과정이 필요합니다.

이를 위해 New File > Objective-C File을 선택을 통해 DynamicIslandBridge.m 파일을 생성합니다.

마찬가지로 target은 RN project를 체크해주세요.

// DynamicIslandBridge.m

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(DynamicIslandModule, NSObject)

RCT_EXTERN_METHOD(startActivity:(NSString *) seatRoomName  withSeatNumber:(NSString *) seatNumber  withIsUsing:(BOOL *) isUsing withDateInterval:(nonnull NSNumber *) dateInterval)
RCT_EXTERN_METHOD(updateActivity:(BOOL *) isUsing withDateInterval:(nonnull NSNumber *) dateInterval)
RCT_EXTERN_METHOD(endActivity)

+ (BOOL)requiresMainQueueSetup
{
  return NO;
}

@end

RCT_EXTERN_MODULE에 위에서 작성한 module을 담아주고, RCT_EXTERN_METHOD에 각 함수를 넣어주세요.

그리고 함수: 이후 구문에서 통신에 필요한 parameter를 정의할 수 있습니다. 저는 좌석번호, 사용 여부 등이 필요하여 넣어주었습니다.

필요한 parameter의 타입은 RN 공식문서의 Argument Type에서 확인할 수 있습니다.

React Native에서 module 호출하고 사용하기

저는 RN에서 LibraryDynamicIslandBridge class를 작성하여 사용했습니다.

react-native의 NativeModule을 호출하여 이전에 iOS에서 내보낸 DynamicIslandModule을 사용할 수 있습니다.

import { NativeModules } from 'react-native';

interface DynamicIslandModule {
  startActivity: (
    seatRoomName: string,
    seatNumber: string,
    isUsing: boolean,
    dateInterval: number,
  ) => void;
  updateActivity: (isUsing: boolean, dateInterval: number) => void;
  endActivity: () => void;
}

export class LibraryDynamicIslandBridge {
  static dynamicIslandModule(): DynamicIslandModule {
    return NativeModules.DynamicIslandModule;
  }

  static validateExistModule(func: keyof DynamicIslandModule): boolean {
    if (
      this.dynamicIslandModule() &&
      typeof this.dynamicIslandModule()[func] === 'function'
    )
      return true;
    console.warn('DynamicIslandModule not found');
    return false;
  }

  static onStartActivity({
    seatRoomName,
    seatNumber,
    isUsing,
    dateInterval,
  }: {
    seatRoomName: string;
    seatNumber: string;
    isUsing: boolean;
    dateInterval: number;
  }): void {
    if (!this.validateExistModule('startActivity')) return;
    this.dynamicIslandModule().startActivity(
      seatRoomName,
      seatNumber,
      isUsing,
      dateInterval,
    );
  }

  static onUpdateActivity({
    isUsing,
    dateInterval,
  }: {
    isUsing: boolean;
    dateInterval: number;
  }): void {
    if (!this.validateExistModule('updateActivity')) return;
    this.dynamicIslandModule().updateActivity(isUsing, dateInterval);
  }

  static onEndActivity(): void {
    if (!this.validateExistModule('endActivity')) return;
    this.dynamicIslandModule().endActivity();
  }
}

위 코드에서는 해당 모듈에 함수가 존재하는지 validate하고, 각 start와 end 등의 함수를 호출합니다.

사용 예시

// API가 호출되었을 경우
LibraryDynamicIslandBridge.onStartActivity({
  seatRoomName: response.seatRoomName,
  seatNumber: response.seatNo,
  isUsing: response.status === 'SEAT',
  // ...
});

// 상태가 변경되었을 경우
 LibraryDynamicIslandBridge.onUpdateActivity({
  isUsing: response.status === 'SEAT',
  dateInterval: ...
});

결과

 

참고문서

https://blog.stackademic.com/unleashing-ios-dynamic-islands-in-your-react-native-app-a-step-by-step-guide-eee3c5ed3059

https://reactnative.dev/docs/native-modules-ios

https://www.youtube.com/watch?v=BsJT26dkasA

https://github.com/hoaphantn7604/react-native-dynamic-island-tutorial