iOS 네이티브 UI 컴포넌트
Native Module과 Native Components는 기존 아키텍처에서 사용하던 안정적인 기술이다.
새로운 아키텍처가 안정화되면 앞으로 지원이 중단될 예정이다. 새로운 아키텍처에서는 Turbo Native Module과 Fabric Native Components를 사용해 유사한 결과를 달성한다.
최신 앱에서 사용할 수 있는 다양한 네이티브 UI 위젯이 존재한다. 일부는 플랫폼에 내장되어 있고, 다른 것들은 서드파티 라이브러리로 제공되며, 여러분이 이전 앱에서 직접 개발한 위젯도 있을 수 있다. React Native는 ScrollView
와 TextInput
같은 핵심 플랫폼 컴포넌트를 이미 래핑해 제공하지만, 모든 컴포넌트를 지원하지는 않는다. 특히 여러분이 직접 개발한 컴포넌트는 포함되어 있지 않다. 다행히도, 기존 컴포넌트를 래핑해 React Native 애플리케이션에 통합할 수 있다.
네이티브 모듈 가이드와 마찬가지로, 이 가이드도 iOS 프로그래밍에 어느 정도 익숙하다는 전제하에 진행된다. 이 가이드는 네이티브 UI 컴포넌트를 만드는 방법을 설명하며, React Native 코어 라이브러리에 포함된 MapView
컴포넌트의 일부를 구현하는 과정을 단계별로 안내한다.
iOS MapView 예제
앱에 인터랙티브 맵을 추가하려는 경우, MKMapView
를 사용할 수 있다. 이를 JavaScript에서 사용할 수 있도록 만들어야 한다.
네이티브 뷰는 RCTViewManager
의 서브클래스로 생성되고 조작된다. 이 서브클래스는 뷰 컨트롤러와 유사한 기능을 하지만, 기본적으로 싱글톤이다. 브리지가 각각의 인스턴스를 하나만 생성한다. 이들은 네이티브 뷰를 RCTUIManager
에 노출시키며, RCTUIManager
는 필요에 따라 뷰의 속성을 설정하고 업데이트하기 위해 다시 이들에게 위임한다. RCTViewManager
는 일반적으로 뷰의 대리자 역할도 하며, 이벤트를 브리지를 통해 JavaScript로 전송한다.
뷰를 노출시키려면 다음 단계를 따르면 된다:
RCTViewManager
를 서브클래싱하여 컴포넌트의 매니저를 생성한다.RCT_EXPORT_MODULE()
마커 매크로를 추가한다.-(UIView *)view
메서드를 구현한다.
#import <MapKit/MapKit.h>
#import <React/RCTViewManager.h>
@interface RNTMapManager : RCTViewManager
@end
@implementation RNTMapManager
RCT_EXPORT_MODULE(RNTMap)
- (UIView *)view
{
return [[MKMapView alloc] init];
}
@end
-view
메서드를 통해 노출시킨 UIView
인스턴스의 frame
이나 backgroundColor
속성을 설정하려고 시도하지 말아야 한다.
React Native는 JavaScript 컴포넌트의 레이아웃 속성과 일치시키기 위해 커스텀 클래스에서 설정한 값을 덮어쓴다.
이런 세부적인 제어가 필요하다면, 스타일을 적용하려는 UIView
인스턴스를 다른 UIView
로 감싸고, 그 래퍼 UIView
를 반환하는 것이 더 나을 수 있다.
더 많은 내용은 Issue 2948을 참고하라.
위 예제에서 클래스 이름 앞에 RNT
접두사를 붙였다. 이는 다른 프레임워크와의 이름 충돌을 피하기 위함이다.
Apple 프레임워크는 두 글자 접두사를 사용하고, React Native는 RCT
접두사를 사용한다. 이름 충돌을 피하려면, 자신의 클래스에서 RCT
가 아닌 세 글자 접두사를 사용하는 것을 권장한다.
이제 JavaScript에서 사용할 수 있는 React 컴포넌트를 만들기 위해 약간의 JavaScript 코드가 필요하다:
import {requireNativeComponent} from 'react-native';
export default requireNativeComponent('RNTMap');
requireNativeComponent
함수는 RNTMap
을 자동으로 RNTMapManager
로 해석하고, 네이티브 뷰를 JavaScript에서 사용할 수 있도록 내보낸다.
import MapView from './MapView.tsx';
export default function MyApp() {
return <MapView style={{flex: 1}} />;
}
렌더링할 때 뷰를 늘려주는 것을 잊지 말아야 한다. 그렇지 않으면 빈 화면만 보게 될 것이다.
이제 이 컴포넌트는 핀치 줌 및 기타 네이티브 제스처를 지원하는 완전히 기능하는 네이티브 맵 뷰 컴포넌트가 되었다. 그러나 아직 JavaScript에서 이를 제어할 수는 없다.
속성
이 컴포넌트를 더 유용하게 만들기 위해 가장 먼저 할 수 있는 일은 일부 네이티브 속성을 연결하는 것이다. 예를 들어, 줌 기능을 비활성화하고 표시할 영역을 지정할 수 있도록 해보자. 줌 비활성화는 불리언 값이므로, 다음과 같이 한 줄을 추가한다:
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)
여기서 타입을 BOOL
로 명시적으로 지정했다. React Native는 브릿지를 통해 통신할 때 다양한 데이터 타입을 변환하기 위해 내부적으로 RCTConvert
를 사용하며, 잘못된 값이 전달되면 즉시 "RedBox" 오류를 표시해 문제를 알려준다. 이처럼 간단한 경우에는 이 매크로가 모든 구현을 처리해준다.
이제 실제로 줌을 비활성화하려면 JavaScript에서 속성을 설정한다:
import MapView from './MapView.tsx';
export default function MyApp() {
return <MapView zoomEnabled={false} style={{flex: 1}} />;
}
MapView 컴포넌트의 속성(그리고 어떤 값을 받는지)을 문서화하기 위해 래퍼 컴포넌트를 추가하고 TypeScript로 인터페이스를 정의한다:
import {requireNativeComponent} from 'react-native';
const RNTMap = requireNativeComponent('RNTMap');
export default function MapView(props: {
/**
* 사용자가 핀치 제스처를 사용해 확대/축소할 수 있는지 여부.
*/
zoomEnabled?: boolean;
}) {
return <RNTMap {...props} />;
}
이제 잘 문서화된 래퍼 컴포넌트를 사용할 수 있다.
다음으로, 더 복잡한 region
속성을 추가해보자. 먼저 네이티브 코드를 추가한다:
RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, MKMapView)
{
[view setRegion:json ? [RCTConvert MKCoordinateRegion:json] : defaultView.region animated:YES];
}
이전의 BOOL
케이스보다 더 복잡하다. 이제 MKCoordinateRegion
타입이 필요하며, 변환 함수가 필요하다. 또한 JS에서 영역을 설정할 때 애니메이션을 적용하기 위해 커스텀 코드를 추가했다. 제공한 함수 본문 내에서 json
은 JS에서 전달된 원시 값을 참조한다. 또한 view
변수는 매니저의 뷰 인스턴스에 접근할 수 있게 해주며, defaultView
는 JS가 null 값을 보낼 때 속성을 기본값으로 재설정하는 데 사용된다.
뷰를 위해 원하는 변환 함수를 작성할 수 있다. 여기서는 RCTConvert
에 카테고리를 통해 MKCoordinateRegion
을 구현한다. 이는 ReactNative의 RCTConvert+CoreLocation
카테고리를 사용한다:
#import "RCTConvert+Mapkit.h"
#import <MapKit/MapKit.h>
#import <React/RCTConvert.h>
#import <CoreLocation/CoreLocation.h>
#import <React/RCTConvert+CoreLocation.h>
@interface RCTConvert (Mapkit)
+ (MKCoordinateSpan)MKCoordinateSpan:(id)json;
+ (MKCoordinateRegion)MKCoordinateRegion:(id)json;
@end
@implementation RCTConvert(MapKit)
+ (MKCoordinateSpan)MKCoordinateSpan:(id)json
{
json = [self NSDictionary:json];
return (MKCoordinateSpan){
[self CLLocationDegrees:json[@"latitudeDelta"]],
[self CLLocationDegrees:json[@"longitudeDelta"]]
};
}
+ (MKCoordinateRegion)MKCoordinateRegion:(id)json
{
return (MKCoordinateRegion){
[self CLLocationCoordinate2D:json],
[self MKCoordinateSpan:json]
};
}
@end
이 변환 함수들은 JS에서 전달된 JSON을 안전하게 처리하도록 설계되었다. 누락된 키나 다른 개발자 오류가 발생하면 "RedBox" 오류를 표시하고 표준 초기화 값을 반환한다.
region
속성 지원을 마무리하기 위해 TypeScript로 문서화한다:
import {requireNativeComponent} from 'react-native';
const RNTMap = requireNativeComponent('RNTMap');
export default function MapView(props: {
/**
* 지도에 표시할 영역.
*
* 영역은 중심 좌표와 표시할 좌표의 범위로 정의된다.
*/
region?: {
/**
* 지도의 중심 좌표.
*/
latitude: number;
longitude: number;
/**
* 표시할 최소 위도/경도와 최대 위도/경도 간의 거리.
*/
latitudeDelta: number;
longitudeDelta: number;
};
/**
* 사용자가 핀치 제스처를 사용해 확대/축소할 수 있는지 여부.
*/
zoomEnabled?: boolean;
}) {
return <RNTMap {...props} />;
}
이제 MapView
에 region
속성을 제공할 수 있다:
import MapView from './MapView.tsx';
export default function MyApp() {
const region = {
latitude: 37.48,
longitude: -122.16,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
};
return (
<MapView
region={region}
zoomEnabled={false}
style={{flex: 1}}
/>
);
}
이벤트 처리
이제 JS에서 자유롭게 제어할 수 있는 네이티브 맵 컴포넌트를 갖게 되었다. 하지만 사용자의 핀치 줌이나 패닝과 같은 이벤트는 어떻게 처리할까? 이러한 동작은 맵의 보이는 영역을 변경한다.
지금까지는 매니저의 -(UIView *)view
메서드에서 MKMapView
인스턴스를 반환했다. MKMapView
에 새로운 속성을 추가할 수 없기 때문에, MKMapView
를 상속받는 새로운 서브클래스를 만들어야 한다. 이 서브클래스에 onRegionChange
콜백을 추가한다.
#import <MapKit/MapKit.h>
#import <React/RCTComponent.h>
@interface RNTMapView: MKMapView
@property (nonatomic, copy) RCTBubblingEventBlock onRegionChange;
@end
#import "RNTMapView.h"
@implementation RNTMapView
@end
모든 RCTBubblingEventBlock
은 on
으로 시작해야 한다. 다음으로, RNTMapManager
에 이벤트 핸들러 속성을 선언하고, 노출된 모든 뷰의 델리게이트로 설정한다. 그리고 네이티브 뷰에서 이벤트 핸들러 블록을 호출해 이벤트를 JS로 전달한다.
#import <MapKit/MapKit.h>
#import <React/RCTViewManager.h>
#import "RNTMapView.h"
#import "RCTConvert+Mapkit.h"
@interface RNTMapManager : RCTViewManager <MKMapViewDelegate>
@end
@implementation RNTMapManager
RCT_EXPORT_MODULE()
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)
RCT_EXPORT_VIEW_PROPERTY(onRegionChange, RCTBubblingEventBlock)
RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, MKMapView)
{
[view setRegion:json ? [RCTConvert MKCoordinateRegion:json] : defaultView.region animated:YES];
}
- (UIView *)view
{
RNTMapView *map = [RNTMapView new];
map.delegate = self;
return map;
}
#pragma mark MKMapViewDelegate
- (void)mapView:(RNTMapView *)mapView regionDidChangeAnimated:(BOOL)animated
{
if (!mapView.onRegionChange) {
return;
}
MKCoordinateRegion region = mapView.region;
mapView.onRegionChange(@{
@"region": @{
@"latitude": @(region.center.latitude),
@"longitude": @(region.center.longitude),
@"latitudeDelta": @(region.span.latitudeDelta),
@"longitudeDelta": @(region.span.longitudeDelta),
}
});
}
@end
델리게이트 메서드 -mapView:regionDidChangeAnimated:
에서 해당 뷰의 이벤트 핸들러 블록을 호출해 지역 데이터를 전달한다. onRegionChange
이벤트 핸들러 블록을 호출하면 JS에서 동일한 콜백 prop이 실행된다. 이 콜백은 원시 이벤트와 함께 호출되며, 일반적으로 API를 단순화하기 위해 래퍼 컴포넌트에서 처리한다.
// ...
type RegionChangeEvent = {
nativeEvent: {
latitude: number;
longitude: number;
latitudeDelta: number;
longitudeDelta: number;
};
};
export default function MapView(props: {
// ...
/**
* 사용자가 맵을 드래그할 때 지속적으로 호출되는 콜백.
*/
onRegionChange: (event: RegionChangeEvent) => unknown;
}) {
return <RNTMap {...props} onRegionChange={onRegionChange} />;
}
import MapView from './MapView.tsx';
export default function MyApp() {
// ...
const onRegionChange = useCallback(event => {
const {region} = event.nativeEvent;
// `region.latitude` 등을 사용해 작업 수행
});
return (
<MapView
// ...
onRegionChange={onRegionChange}
/>
);
}
여러 네이티브 뷰 처리하기
React Native에서 뷰 트리는 여러 자식 뷰를 가질 수 있다. 예를 들어:
<View>
<MyNativeView />
<MyNativeView />
<Button />
</View>
이 예제에서 MyNativeView
클래스는 NativeComponent
를 감싸는 래퍼이며, iOS 플랫폼에서 호출될 메서드를 노출한다. MyNativeView
는 MyNativeView.ios.js
파일에 정의되어 있고, NativeComponent
의 프록시 메서드를 포함한다.
사용자가 버튼을 클릭하는 등의 상호작용을 할 때, MyNativeView
의 backgroundColor
가 변경된다. 이 경우 UIManager
는 어떤 MyNativeView
를 처리해야 하고, 어떤 뷰의 backgroundColor
를 변경해야 하는지 알 수 없다. 이 문제를 해결하는 방법은 다음과 같다:
<View>
<MyNativeView ref={this.myNativeReference} />
<MyNativeView ref={this.myNativeReference2} />
<Button
onPress={() => {
this.myNativeReference.callNativeMethod();
}}
/>
</View>
이제 위의 컴포넌트는 특정 MyNativeView
에 대한 참조를 가지며, 이를 통해 특정 인스턴스를 사용할 수 있다. 이제 버튼은 어떤 MyNativeView
의 backgroundColor
를 변경할지 제어할 수 있다. 이 예제에서 callNativeMethod
가 backgroundColor
를 변경한다고 가정한다.
class MyNativeView extends React.Component {
callNativeMethod = () => {
UIManager.dispatchViewManagerCommand(
ReactNative.findNodeHandle(this),
UIManager.getViewManagerConfig('RNCMyNativeView').Commands
.callNativeMethod,
[],
);
};
render() {
return <NativeComponent ref={NATIVE_COMPONENT_REF} />;
}
}
callNativeMethod
는 MyNativeView
를 통해 노출된 backgroundColor
를 변경하는 커스텀 iOS 메서드이다. 이 메서드는 UIManager.dispatchViewManagerCommand
를 사용하며, 이 메서드는 세 가지 파라미터를 필요로 한다:
(nonnull NSNumber \*)reactTag
- React 뷰의 IDcommandID:(NSInteger)commandID
- 호출할 네이티브 메서드의 IDcommandArgs:(NSArray<id> \*)commandArgs
- JS에서 네이티브로 전달할 메서드의 인자
#import <React/RCTViewManager.h>
#import <React/RCTUIManager.h>
#import <React/RCTLog.h>
RCT_EXPORT_METHOD(callNativeMethod:(nonnull NSNumber*) reactTag) {
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *,UIView *> *viewRegistry) {
NativeView *view = viewRegistry[reactTag];
if (!view || ![view isKindOfClass:[NativeView class]]) {
RCTLogError(@"Cannot find NativeView with tag #%@", reactTag);
return;
}
[view callNativeMethod];
}];
}
여기서 callNativeMethod
는 RNCMyNativeViewManager.m
파일에 정의되어 있으며, (nonnull NSNumber*) reactTag
라는 하나의 파라미터를 가진다. 이 내보낸 함수는 addUIBlock
을 사용해 특정 뷰를 찾고, viewRegistry
파라미터를 통해 reactTag
에 해당하는 컴포넌트를 반환하여 올바른 컴포넌트에서 메서드를 호출할 수 있게 한다.
스타일
React Native의 모든 네이티브 뷰는 UIView
의 서브클래스이기 때문에 대부분의 스타일 속성은 기대한 대로 작동한다. 하지만 UIDatePicker
처럼 고정된 크기를 가진 컴포넌트의 경우 기본 스타일이 필요하다. 이 기본 스타일은 레이아웃 알고리즘이 예상대로 작동하는 데 중요하지만, 컴포넌트를 사용할 때 기본 스타일을 재정의할 수 있어야 한다. DatePickerIOS
는 네이티브 컴포넌트를 추가 뷰로 감싸고, 이 뷰에는 유연한 스타일을 적용하며, 내부 네이티브 컴포넌트에는 고정된 스타일을 사용한다. 이 고정 스타일은 네이티브에서 전달된 상수를 기반으로 생성된다:
import {UIManager} from 'react-native';
const RCTDatePickerIOSConsts = UIManager.RCTDatePicker.Constants;
...
render: function() {
return (
<View style={this.props.style}>
<RCTDatePickerIOS
ref={DATEPICKER}
style={styles.rkDatePickerIOS}
...
/>
</View>
);
}
});
const styles = StyleSheet.create({
rkDatePickerIOS: {
height: RCTDatePickerIOSConsts.ComponentHeight,
width: RCTDatePickerIOSConsts.ComponentWidth,
},
});
RCTDatePickerIOSConsts
상수는 네이티브 컴포넌트의 실제 프레임을 가져와 네이티브에서 내보낸다:
- (NSDictionary *)constantsToExport
{
UIDatePicker *dp = [[UIDatePicker alloc] init];
[dp layoutIfNeeded];
return @{
@"ComponentHeight": @(CGRectGetHeight(dp.frame)),
@"ComponentWidth": @(CGRectGetWidth(dp.frame)),
@"DatePickerModes": @{
@"time": @(UIDatePickerModeTime),
@"date": @(UIDatePickerModeDate),
@"datetime": @(UIDatePickerModeDateAndTime),
}
};
}
이 가이드는 커스텀 네이티브 컴포넌트를 연결하는 다양한 측면을 다뤘지만, 서브뷰를 삽입하고 레이아웃을 잡는 커스텀 훅과 같은 추가 고려사항도 있다. 더 깊이 알고 싶다면 구현된 컴포넌트의 소스 코드를 참고하길 바란다.