
시리즈의 설계편에 이어 구현에 대해서만 포스팅합니다.
간단한 Todo 앱을 예시로 설명합니다🥰
위젯은 앱 내 Todo의 값이 변하면 새로운 Timeline을 불러오도록 구현합니다.
저도 아직 모든 개념이 머리에 있지는 않아 100% 정확하지 않을 수 있어요. 양해 부탁드립니다.🙏
Github Repository - jiwooIncludeJeong/react-native-ios-widget
우선 React Native App 생성!
npx react-native@latest init ReactNativeIOSWidget
아주 간단한 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;
그럼 다음과 같은 화면이 그려집니다.

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

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

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

앱에서 쓴 데이터인 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의 +버튼을 눌러 그룹명 추가합니다. ❗️위에서 입력한 같은 그룹명을 입력해주세요!
File > New > File을 통해 Header File과 Objective-C File을 만들어줍니다. 둘의 네이밍은 통일하는 것이 좋고, 저는 각각 SharedDefaults.h와 SharedDefaults.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 함수를 받는 것입니다.
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]);
...
}
...
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을 새롭게 만듭니다.
이렇게 따라 작성하면 다음과 같은 결과를 얻을 수 있습니다.




jiwooIncludeJeong/react-native-ios-widget
좋은 글 감사합니다 ~~ 🤩