Skip to main content

iOS 네이티브 UI 컴포넌트

info

네이티브 모듈(Native Module)과 네이티브 컴포넌트(Native Components)는 기존 아키텍처에서 사용하던 안정적인 기술이다. 새로운 아키텍처가 안정화되면 이 기술들은 점차 사용되지 않을 예정이다. 새로운 아키텍처는 터보 네이티브 모듈패브릭 네이티브 컴포넌트를 사용해 유사한 결과를 달성한다.

최신 앱에서 사용할 수 있는 다양한 네이티브 UI 위젯이 존재한다. 이 중 일부는 플랫폼의 일부로 제공되고, 다른 것들은 서드파티 라이브러리로 제공된다. 또한 여러분의 포트폴리오에서 사용 중인 위젯도 있을 수 있다. React Native는 ScrollViewTextInput 같은 가장 중요한 플랫폼 컴포넌트를 이미 래핑해 제공하고 있지만, 모든 컴포넌트를 포함하고 있지는 않다. 특히 이전 앱에서 직접 작성한 컴포넌트는 포함되지 않을 가능성이 높다. 다행히 기존 컴포넌트를 래핑해 React Native 애플리케이션과 원활하게 통합할 수 있다.

네이티브 모듈 가이드와 마찬가지로, 이 가이드도 iOS 프로그래밍에 어느 정도 익숙하다고 가정한 고급 가이드다. 이 가이드에서는 React Native 코어 라이브러리에 포함된 MapView 컴포넌트의 일부를 구현하는 과정을 통해 네이티브 UI 컴포넌트를 만드는 방법을 단계별로 설명한다.

iOS MapView 예제

앱에 인터랙티브한 지도를 추가하려면 MKMapView를 사용하면 된다. 이를 자바스크립트에서 사용할 수 있도록 만드는 방법을 알아보자.

네이티브 뷰는 RCTViewManager의 서브클래스를 통해 생성되고 조작된다. 이 서브클래스는 뷰 컨트롤러와 유사한 기능을 하지만, 기본적으로 싱글톤이다. 즉, 브릿지에 의해 각각의 인스턴스가 하나만 생성된다. 이들은 네이티브 뷰를 RCTUIManager에 노출시키며, RCTUIManager는 필요에 따라 뷰의 속성을 설정하고 업데이트하기 위해 다시 이들에게 위임한다. RCTViewManager는 일반적으로 뷰의 델리게이트 역할도 하며, 이벤트를 브릿지를 통해 자바스크립트로 전달한다.

뷰를 노출시키려면 다음과 같은 단계를 거친다:

  • RCTViewManager를 서브클래싱하여 컴포넌트의 매니저를 생성한다.
  • RCT_EXPORT_MODULE() 마커 매크로를 추가한다.
  • -(UIView *)view 메서드를 구현한다.
RNTMapManager.m
#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
note

-view 메서드를 통해 노출시키는 UIView 인스턴스의 frame이나 backgroundColor 속성을 직접 설정하려고 하지 마라. React Native는 자바스크립트 컴포넌트의 레이아웃 속성과 일치시키기 위해 커스텀 클래스에서 설정한 값을 덮어쓸 것이다. 만약 이 정도의 세밀한 제어가 필요하다면, 스타일을 적용하려는 UIView 인스턴스를 다른 UIView로 감싸고 그 래퍼 UIView를 반환하는 것이 더 나을 수 있다. 더 자세한 내용은 Issue 2948을 참고하라.

info

위 예제에서 클래스 이름 앞에 RNT라는 접두사를 사용했다. 접두사는 다른 프레임워크와의 이름 충돌을 방지하기 위해 사용된다. Apple 프레임워크는 두 글자 접두사를 사용하고, React Native는 RCT를 접두사로 사용한다. 이름 충돌을 피하기 위해, 자신의 클래스에서는 RCT가 아닌 세 글자 접두사를 사용하는 것을 권장한다.

이제 이 네이티브 뷰를 사용 가능한 React 컴포넌트로 만들기 위해 약간의 자바스크립트 코드가 필요하다:

MapView.tsx
import {requireNativeComponent} from 'react-native';

export default requireNativeComponent('RNTMap');

requireNativeComponent 함수는 자동으로 RNTMapRNTMapManager로 해석하고, 네이티브 뷰를 자바스크립트에서 사용할 수 있도록 내보낸다.

MyApp.tsx
import MapView from './MapView.tsx';

export default function MyApp() {
return <MapView style={{flex: 1}} />;
}
note

렌더링할 때 뷰를 늘리는 것을 잊지 마라. 그렇지 않으면 빈 화면을 보게 될 것이다.

이제 이 컴포넌트는 핀치 줌 및 기타 네이티브 제스처를 지원하는 완전히 동작하는 네이티브 맵 뷰 컴포넌트가 되었다. 그러나 아직 자바스크립트에서 이를 제어할 수는 없다.

속성

이 컴포넌트를 더 유용하게 만들기 위해 가장 먼저 할 수 있는 일은 일부 네이티브 속성을 연결하는 것이다. 예를 들어, 줌 기능을 비활성화하고 표시할 영역을 지정할 수 있도록 만들고 싶다. 줌 비활성화는 불리언 값이므로 다음과 같이 한 줄을 추가한다:

RNTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)

여기서 타입을 BOOL로 명시적으로 지정한다. React Native는 브릿지를 통해 통신할 때 다양한 데이터 타입을 변환하기 위해 내부적으로 RCTConvert를 사용하며, 잘못된 값이 들어오면 "RedBox" 에러를 표시해 문제를 즉시 알려준다. 이렇게 간단한 경우에는 매크로가 모든 구현을 처리해준다.

이제 실제로 줌을 비활성화하려면 자바스크립트에서 속성을 설정한다:

MyApp.tsx
import MapView from './MapView.tsx';

export default function MyApp() {
return <MapView zoomEnabled={false} style={{flex: 1}} />;
}

MapView 컴포넌트의 속성(그리고 허용되는 값)을 문서화하기 위해 래퍼 컴포넌트를 추가하고 TypeScript로 인터페이스를 정의한다:

MapView.tsx
import {requireNativeComponent} from 'react-native';

const RNTMap = requireNativeComponent('RNTMap');

export default function MapView(props: {
/**
* 사용자가 핀치 제스처를 통해 확대/축소할 수 있는지 여부.
*/
zoomEnabled?: boolean;
}) {
return <RNTMap {...props} />;
}

이제 잘 문서화된 래퍼 컴포넌트가 준비되었다.

다음으로, 더 복잡한 region 속성을 추가해보자. 먼저 네이티브 코드를 추가한다:

RNTMapManager.m
RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, MKMapView)
{
[view setRegion:json ? [RCTConvert MKCoordinateRegion:json] : defaultView.region animated:YES];
}

이전의 BOOL 케이스보다 더 복잡하다. 이제 MKCoordinateRegion 타입에 대한 변환 함수가 필요하며, JS에서 region을 설정할 때 애니메이션 효과를 주기 위한 커스텀 코드도 있다. 제공한 함수 내부에서 json은 JS에서 전달된 원시 값을 참조한다. 또한 view 변수는 매니저의 뷰 인스턴스에 접근할 수 있게 해주며, defaultView는 JS에서 null 센티널을 보낼 경우 속성을 기본값으로 재설정하는 데 사용된다.

뷰를 위한 어떤 변환 함수든 작성할 수 있다. 다음은 MKCoordinateRegion에 대한 구현으로, RCTConvert에 대한 카테고리를 통해 이루어진다. 이는 ReactNative의 기존 카테고리인 RCTConvert+CoreLocation을 사용한다:

RNTMapManager.m
#import "RCTConvert+Mapkit.h"
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로 문서화한다:

MapView.tsx
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} />;
}

이제 MapViewregion 속성을 제공할 수 있다:

MyApp.tsx
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 콜백을 추가한다:

RNTMapView.h
#import <MapKit/MapKit.h>

#import <React/RCTComponent.h>

@interface RNTMapView: MKMapView

@property (nonatomic, copy) RCTBubblingEventBlock onRegionChange;

@end
RNTMapView.m
#import "RNTMapView.h"

@implementation RNTMapView

@end

모든 RCTBubblingEventBlockon으로 시작해야 한다. 다음으로, RNTMapManager에 이벤트 핸들러 프로퍼티를 선언하고, 노출된 모든 뷰의 델리게이트로 설정한다. 그리고 네이티브 뷰에서 이벤트 핸들러 블록을 호출해 JS로 이벤트를 전달한다.

RNTMapManager.m
#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에서 동일한 콜백 프로퍼티가 호출된다. 이 콜백은 원시 이벤트와 함께 호출되며, 일반적으로 API를 단순화하기 위해 래퍼 컴포넌트에서 처리한다:

MapView.tsx
// ...

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} />;
}
MyApp.tsx
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 뷰는 뷰 트리에서 하나 이상의 자식 뷰를 가질 수 있다. 예를 들어:

tsx
<View>
<MyNativeView />
<MyNativeView />
<Button />
</View>

이 예제에서 MyNativeView 클래스는 NativeComponent를 감싸는 래퍼이며, iOS 플랫폼에서 호출될 메서드를 노출한다. MyNativeViewMyNativeView.ios.js 파일에 정의되어 있고, NativeComponent의 프록시 메서드를 포함한다.

사용자가 버튼을 클릭하는 등 컴포넌트와 상호작용할 때, MyNativeViewbackgroundColor가 변경된다. 이 경우 UIManager는 어떤 MyNativeView를 처리해야 하는지, 그리고 어떤 뷰의 backgroundColor를 변경해야 하는지 알 수 없다. 이 문제를 해결하는 방법은 다음과 같다:

tsx
<View>
<MyNativeView ref={this.myNativeReference} />
<MyNativeView ref={this.myNativeReference2} />
<Button
onPress={() => {
this.myNativeReference.callNativeMethod();
}}
/>
</View>

이제 위 컴포넌트는 특정 MyNativeView에 대한 참조를 가지고 있어, MyNativeView의 특정 인스턴스를 사용할 수 있다. 이제 버튼은 어떤 MyNativeViewbackgroundColor를 변경할지 제어할 수 있다. 이 예제에서는 callNativeMethodbackgroundColor를 변경한다고 가정한다.

MyNativeView.ios.tsx
class MyNativeView extends React.Component {
callNativeMethod = () => {
UIManager.dispatchViewManagerCommand(
ReactNative.findNodeHandle(this),
UIManager.getViewManagerConfig('RNCMyNativeView').Commands
.callNativeMethod,
[],
);
};

render() {
return <NativeComponent ref={NATIVE_COMPONENT_REF} />;
}
}

callNativeMethodMyNativeView를 통해 노출된 커스텀 iOS 메서드로, 예를 들어 backgroundColor를 변경한다. 이 메서드는 UIManager.dispatchViewManagerCommand를 사용하며, 이 메서드는 세 가지 매개변수를 필요로 한다:

  • (nonnull NSNumber \*)reactTag  -  React 뷰의 ID.
  • commandID:(NSInteger)commandID  -  호출할 네이티브 메서드의 ID.
  • commandArgs:(NSArray<id> \*)commandArgs  -  JS에서 네이티브로 전달할 수 있는 네이티브 메서드의 인자.
RNCMyNativeViewManager.m
#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];
}];

}

여기서 callNativeMethodRNCMyNativeViewManager.m 파일에 정의되어 있으며, (nonnull NSNumber*) reactTag라는 하나의 매개변수를 가진다. 이 내보낸 함수는 viewRegistry 매개변수를 포함하는 addUIBlock을 사용하여 특정 뷰를 찾고, reactTag를 기반으로 컴포넌트를 반환하여 올바른 컴포넌트에서 메서드를 호출할 수 있게 한다.

스타일

React Native의 모든 네이티브 뷰는 UIView의 하위 클래스이기 때문에, 대부분의 스타일 속성은 기본적으로 예상대로 동작한다. 하지만 UIDatePicker와 같이 고정된 크기를 가진 컴포넌트는 기본 스타일이 필요하다. 이 기본 스타일은 레이아웃 알고리즘이 예상대로 작동하는 데 중요하지만, 컴포넌트를 사용할 때 기본 스타일을 재정의할 수 있어야 한다. DatePickerIOS는 네이티브 컴포넌트를 추가 뷰로 감싸서 이 문제를 해결한다. 외부 뷰는 유연한 스타일을 가지며, 내부 네이티브 컴포넌트에는 네이티브에서 전달된 상수를 사용해 고정된 스타일을 적용한다.

DatePickerIOS.ios.tsx
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 상수는 네이티브 컴포넌트의 실제 프레임을 가져와서 네이티브에서 다음과 같이 내보낸다.

RCTDatePickerManager.m
- (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),
}
};
}

이 가이드에서는 커스텀 네이티브 컴포넌트를 연결하는 다양한 측면을 다뤘지만, 서브뷰를 삽입하고 레이아웃을 구성하기 위한 커스텀 훅과 같은 추가 고려사항도 있다. 더 깊이 알고 싶다면, 구현된 컴포넌트의 소스 코드를 확인해 보면 좋다.