[React Native] iOS 위젯 - 구현

Jiwoo JEONG·2023년 4월 8일
13

React Native iOS Widget

목록 보기
2/2
post-thumbnail

들어가며

시리즈의 설계편에 이어 구현에 대해서만 포스팅합니다.
간단한 Todo 앱을 예시로 설명합니다🥰
위젯은 앱 내 Todo의 값이 변하면 새로운 Timeline을 불러오도록 구현합니다.
저도 아직 모든 개념이 머리에 있지는 않아 100% 정확하지 않을 수 있어요. 양해 부탁드립니다.🙏
Github Repository - jiwooIncludeJeong/react-native-ios-widget

1. Create React Native App

우선 React Native App 생성!

npx react-native@latest init ReactNativeIOSWidget

2. 간단한 Todo 앱 작성

아주 간단한 Todo 앱을 작성해보았습니다! 복사 붙여넣기도 좋아요 왜냐하면 이 포스팅은 위젯이 주요한 포스팅이니까요!

//App.tsx
import React, {useEffect, useState} from 'react';
import {
  SafeAreaView,
  ScrollView,
  StatusBar,
  Text,
  TouchableOpacity,
  useColorScheme,
  View,
} from 'react-native';
import {Colors} from 'react-native/Libraries/NewAppScreen';

type Todo = {
  id: number;
  isCompleted: boolean;
  text: string;
};

type TodoProps = Todo & {
  onPress: (id: number) => void;
};

function Todo({isCompleted, text, id, onPress}: TodoProps): JSX.Element {
  const isDarkMode = useColorScheme() === 'dark';
  return (
    <TouchableOpacity onPress={() => onPress(id)}>
      <View
        style={{
          width: '100%',
          paddingHorizontal: 20,
          paddingVertical: 12,
          display: 'flex',
          flexDirection: 'row',
          alignItems: 'center',
        }}>
        <View
          style={{
            width: 20,
            height: 20,
            borderWidth: 2,
            borderColor: 'black',
            backgroundColor: isCompleted ? 'black' : Colors.lighter,
          }}
        />

        <Text
          style={{
            color: isDarkMode ? Colors.white : Colors.black,
            fontSize: 24,
            fontWeight: '600',
            marginLeft: 8,
          }}>
          {text}
        </Text>
      </View>
    </TouchableOpacity>
  );
}

function App(): JSX.Element {
  const isDarkMode = useColorScheme() === 'dark';

  const backgroundStyle = {
    backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
    paddingTop: 40,
    flex: 1,
  };

  const [todos, setTodos] = useState<Todo[]>([
    {
      id: 1,
      text: 'First Todo',
      isCompleted: false,
    },
    {
      id: 2,
      text: 'Second Todo',
      isCompleted: false,
    },
    {
      id: 3,
      text: 'Third Todo',
      isCompleted: false,
    },
    {
      id: 4,
      text: 'Fourth Todo',
      isCompleted: false,
    },
  ]);

  const handlePress = (id: number) => {
    setTodos(prev =>
      prev.map(i => (i.id === id ? {...i, isCompleted: !i.isCompleted} : i)),
    );
  };

  return (
    <SafeAreaView style={backgroundStyle}>
      <StatusBar
        barStyle={isDarkMode ? 'light-content' : 'dark-content'}
        backgroundColor={backgroundStyle.backgroundColor}
      />
      <ScrollView
        contentInsetAdjustmentBehavior="automatic"
        style={backgroundStyle}>
        {todos.map(t => (
          <Todo key={t.id} {...t} onPress={handlePress} />
        ))}
      </ScrollView>
    </SafeAreaView>
  );
}

export default App;

그럼 다음과 같은 화면이 그려집니다.

3. 위젯 만들기

3-1. Widget Target 만들기

File > New > Target 을 통해 Widget Extension을 추가해주세요 !

그리고 Product Name을 추가하고 Include Live ActivityInclude Configuration Intent를 해제하고 Finish 클릭. 저는 Product Name: Example로 작성하였어요!

Include Live Activity은 iOS 16부터 생긴 다이나믹 아일랜드에 대응하기 위한 것으로 보입니다.
Include Configuration Intent는 유저가 위젯을 편집하기 해줄 기능을 포함할 것이냐인데, 현재 예제에서는 포함하지 않을 것이라 해제합니다.

그럼 아래와 같이 ExampleExtension이라는 WidgetExtension이 추가되었습니다.

3-2. 데이터를 공유할 UserDefaults group 만들기

앱에서 쓴 데이터인 UserDefaults를 위젯에서도 사용할 수 있도록 App group을 만들어서 해당 App group이 등록되어있는 앱에서만 데이터를 공유할 수 있도록 합니다.

가장 왼쪽의 ReactNativeIOSWIdget > TARGETS에서 ReactNativeIOSWidget > Signing & Capabilities > + Capability > App groups 추가 > Signing 아래에 App Groups 영역이 추가 > App Groups+버튼을 눌러 그룹명 추가.
저는 group.react.native.widget.example로 대-충 지어봤어요ㅎㅎ 😋
같은 과정을 TARGETS에서 ExampleExtension > Signing & Capabilities > + Capability > App groups 추가 > Signing 아래에 App Groups 영역이 추가 > App Groups+버튼을 눌러 그룹명 추가합니다. ❗️위에서 입력한 같은 그룹명을 입력해주세요!

3-3. UserDefaults를 Write할 수 있는 React Native의 Native Module 만들기

File > New > File을 통해 Header FileObjective-C File을 만들어줍니다. 둘의 네이밍은 통일하는 것이 좋고, 저는 각각 SharedDefaults.hSharedDefaults.m으로 생성하였습니다.
참고: React Native iOS Native Modules

//SharedDefaults.h
#if __has_include("RCTBridgeModule.h")
#import "RCTBridgeModule.h"
#else
#import <React/RCTBridgeModule.h>
#endif

@interface SharedDefaults : NSObject<RCTBridgeModule>

@end
//SharedDefaults.m
#import <Foundation/Foundation.h>
#import "SharedDefaults.h"

@implementation SharedDefaults

-(dispatch_queue_t)methodQueue {
  return dispatch_get_main_queue();
}

RCT_EXPORT_MODULE(SharedDefaults);

RCT_EXPORT_METHOD(set:(NSString *)data
                  resolver:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)
{
  @try{
    NSUserDefaults *shared = [[NSUserDefaults alloc]initWithSuiteName:@"group.react.native.widget.example"]; //App Group명
    [shared setObject:data forKey:@"data"]; // data를 저장할 key 값
    [shared synchronize];
    resolve(@"true");
  }@catch(NSException *exception){
    reject(@"get_error",exception.reason, nil);
  }

}

@end

❗️주의할 점: App Group명인 group.react.native.widget.example와 data를 저장할 key 값인 data를 잘 확인해주세요❗️
이제 React Native에서 SharedDefaults라는 NativeModule을 사용할 수 있습니다!
SharedDefaults는 set()이라는 method를 가지며 NSString type을 가집니다. resolver와 rejecter는 각각 성공, 실패에 대한 callback, fallback 함수를 받는 것입니다.

4. React Native에서 UserDefaults Write하기

SharedDefaults라는 class를 만들었습니다.

//SharedDefaults.ts
import {NativeModules} from 'react-native';

const NativeSharedDefaults = NativeModules.SharedDefaults;

class SharedDefaults {
  public async set(obj: Record<string, any>) {
    try {
      //UserDefaults는 NSString을 받기 때문에 JSON.stringify()하여 Write
      const res: boolean = await NativeSharedDefaults.set(JSON.stringify(obj));
      return res;
    } catch (e) {
      console.warn('[SHARED DEFAULTS]', e);
      return false;
    }
  }
}

export default new SharedDefaults();

그리고 App.tsx에서 useEffect를 통해 todos 상태가 변할 때마다 SharedDefaults.set(todos)를 호출합니다.

//App.tsx
...
function App():JSX.Element {
  ...
  
  useEffect(() => {
  	SharedDefaults.set(todos);
  }, [todos]);
  
  ...
}
...

5. Widet에서 UserDefaults Read하기

Xcode에서 아까 생성한 WidgetExtension인 Example 디렉터리의 Example.swift 파일을 엽니다.
우리가 Widget에서 사용할 데이터 타입(모델)을 먼저 정의했습니다.

public struct TodoModel:Codable {
  let id:Int, isCompleted: Bool, text: String;
}

그리고 우리가 위젯에서 실질적으로 그릴 TimelineEntry를 정의하였습니다.
TodoModel 배열을 받아서 반복문으로 그려줄 것이기 때문에 todos는 배열로, date는 TimelineEntry 타입의 required라서 다음과 같이 정의합니다.

struct SimpleEntry: TimelineEntry {
  let date: Date, todos: [TodoModel]
}

전체 코드 github은 올려드릴 것이지만 대부분이 UI, Timeline 관련입니다.
UserDefaults를 Read하는 부분에 대해서 설명드리겠습니다.

struct Provider: TimelineProvider {
	...
     func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
      
        let userDefaults = UserDefaults(suiteName: "group.react.native.widget.example")
        let jsonText = userDefaults?.string(forKey: "data")
      
        var todos : [TodoModel] = []
        
        do {
          if jsonText != nil {
            let jsonData = Data(jsonText?.utf8 ?? "".utf8)
            let valueData = try JSONDecoder().decode([TodoModel].self, from: jsonData)
            
            todos = valueData
          }
        } catch {
          print(error)
        }
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, todos: todos)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}
let userDefaults = UserDefaults(suiteName: "group.react.native.widget.example")
let jsonText = userDefaults?.string(forKey: "data")

를 통해 만들어진 Timeline 기준으로 UserDefaults에 있는 값을 가지고 옵니다. 여기서 App Group명을 적어야 우리가 Write한 데이터를 가져올 수 있습니다. 그리고 우리가 Write한 key값인 data를 통해 value를 가져옵니다. 당연히 NSString이었으니 swift에서도 String이라 JSON parsing이 필요합니다.

 var todos : [TodoModel] = []
        
do {
     if jsonText != nil {
     let jsonData = Data(jsonText?.utf8 ?? "".utf8)
     let valueData = try JSONDecoder().decode([TodoModel].self, from: jsonData)
            
     todos = valueData
    }
  } catch {
    print(error)
  }

JSONDecoder()JSON parsing한 값을 통해 SimpleEntry 객체를 생성하여entries.append(entry)를 통해 Timeline을 새롭게 만듭니다.

이렇게 따라 작성하면 다음과 같은 결과를 얻을 수 있습니다.



Github Repository

jiwooIncludeJeong/react-native-ios-widget

느낀 점

  • 정말 안되는 건 없다라는 걸 느꼈던 것 같아요. 처음엔 막막했고, 설계에 따라 자체적으로 버저닝도 해가며 개발했습니다. 첫번째, 두번째 설계가 적합하지 못하다는 것을 깨닫고 어떻게 해야하나라는 생각에도 잠시 빠졌던 것 같아요. 내가 아니라 다른 분이 구현했으면 더 높은 퀄리티의 코드와 더 빠른 속도로 짤 수 있었을까라는 상실감에도 잠시 빠졌었는데요..😭 금방 극복해서 그래도 배포까지 할 수 있었던 것 같습니다!
  • React Native에서 iOS 위젯을 개발하기 위해 편의성을 제공하는 패키지는 아직 없는 것 같아요. 꾸준히 해당 repository를 발전시켜서 reloadAlltimes()와 같이 WidgetKit에서 제공하는 Method를 RN단에서 호출 할 수 있게 만들 생각이고, 더 나아가 RN이 그린 UI를 SwiftUI로 변환하는 것을 개발할 수 있다면 RN에서 사용하는 간단한 flex와 View 개념들로 SwiftUI를 그려 편리한 위젯 오픈 소스를 만들어보고 싶다는 생각도 들었어요!
  • 물론 부정확한 개념이 있을 수 있고, 모든 개념이 저에게 들어오지는 않았다고 생각해요. 하지만 이 글이 누군가에게는 도움이 되길 바랍니다 🫶
profile
FE Developer as Efficiency Maker

2개의 댓글

comment-user-thumbnail
2023년 4월 11일

좋은 글 감사합니다 ~~ 🤩

1개의 답글