시리즈의 설계편에 이어 구현에 대해서만 포스팅합니다.
간단한 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
좋은 글 감사합니다 ~~ 🤩