メインコンテンツにスキップ

iOS ネイティブ UI コンポーネント

情報

ネイティブモジュールとネイティブコンポーネントは、レガシーアーキテクチャで使用されている安定した技術です。新しいアーキテクチャが安定した段階で、これらは廃止される予定です。新しいアーキテクチャでは、Turbo ネイティブモジュールFabric ネイティブコンポーネントを使用して同様の結果を実現します。

最新のアプリで利用できるネイティブ UI ウィジェットは数多く存在します。プラットフォームに組み込まれているもの、サードパーティライブラリとして提供されているもの、あるいは過去のアプリで自作したものなど様々です。React Native は、ScrollViewTextInput など、最も重要なプラットフォームコンポーネントのいくつかを既にラップしていますが、すべてを網羅しているわけではなく、過去のアプリで自作したコンポーネントは当然含まれていません。幸いなことに、既存のコンポーネントをラップして React Native アプリケーションとシームレスに統合することができます。

ネイティブモジュールのガイドと同様に、これも iOS プログラミングにある程度精通していることを前提とした高度なガイドです。このガイドでは、ネイティブ UI コンポーネントの構築方法を、コア React Native ライブラリで利用可能な既存の MapView コンポーネントのサブセットの実装を通して説明します。

iOS MapView の例

アプリにインタラクティブなマップを追加したいとしましょう。 MKMapView を使用すれば、JavaScript から利用できるようにするだけで済みます。

ネイティブビューは、RCTViewManager のサブクラスによって作成および操作されます。これらのサブクラスは機能的にはビューコントローラに似ていますが、本質的にはシングルトンであり、ブリッジによって各インスタンスが 1 つだけ作成されます。ネイティブビューを RCTUIManager に公開し、RCTUIManager は必要に応じてビューのプロパティを設定および更新するために、ネイティブビューに委任します。また、RCTViewManager は通常、ビューのデリゲートでもあり、ブリッジを介して JavaScript にイベントを送信します。

ビューを公開するには、次の手順を実行します。

  • 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
注記

-view メソッドで公開する UIView インスタンスの frame プロパティまたは backgroundColor プロパティを設定しないでください。React Native は、JavaScript コンポーネントのレイアウトプロパティと一致させるために、カスタムクラスによって設定された値を上書きします。このレベルの制御が必要な場合は、スタイルを設定する UIView インスタンスを別の UIView でラップし、代わりにラッパー UIView を返すことをお勧めします。詳細については、Issue 2948 を参照してください。

情報

上記の例では、クラス名のプレフィックスに RNT を使用しました。プレフィックスは、他のフレームワークとの名前の衝突を回避するために使用されます。Apple のフレームワークは 2 文字のプレフィックスを使用し、React Native はプレフィックスとして RCT を使用します。名前の衝突を避けるために、独自のクラスでは RCT 以外の 3 文字のプレフィックスを使用することをお勧めします。

次に、これを使いやすい React コンポーネントにするために、少し JavaScript を追加する必要があります。

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

export default requireNativeComponent('RNTMap');

requireNativeComponent 関数は、RNTMapRNTMapManager に自動的に解決し、JavaScript で使用するためにネイティブビューをエクスポートします。

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

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

レンダリングする際には、ビューをストレッチすることを忘れないでください。そうしないと、空白の画面が表示されます。

これで、ピンチズームやその他のネイティブジェスチャのサポートを備えた、完全に機能するネイティブマップビューコンポーネントが JavaScript で完成しました。ただし、まだ JavaScript から制御することはできません。

プロパティ

このコンポーネントをより使いやすくするためにできる最初のことは、いくつかのネイティブプロパティをブリッジすることです。ズームを無効にして、表示領域を指定できるようにしたいとしましょう。ズームの無効化はブール値なので、次の 1 行を追加します。

RNTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)

型を BOOL として明示的に指定していることに注意してください。React Native は、ブリッジを介して通信する際に、RCTConvert を使用してあらゆる種類のデータ型を変換します。不正な値が指定された場合は、便利な「RedBox」エラーが表示され、問題があることをすぐに知らせてくれます。このように単純な場合は、実装全体がこのマクロによって処理されます。

実際にズームを無効にするには、JavaScript でプロパティを設定します。

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: {
/**
* Whether the user may use pinch gestures to zoom in and out.
*/
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 変数と、JS から null セントリネルが送信された場合にプロパティをデフォルト値にリセットするために使用する defaultView があります。

ビューに合わせて任意の変換関数を記述できます。RCTConvert のカテゴリを介した MKCoordinateRegion の実装を次に示します。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: {
/**
* The region to be displayed by the map.
*
* The region is defined by the center coordinates and the span of
* coordinates to display.
*/
region?: {
/**
* Coordinates for the center of the map.
*/
latitude: number;
longitude: number;

/**
* Distance between the minimum and the maximum latitude/longitude
* to be displayed.
*/
latitudeDelta: number;
longitudeDelta: number;
};
/**
* Whether the user may use pinch gestures to zoom in and out.
*/
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 に新しいプロパティを追加することはできないため、View に使用する 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

すべての RCTBubblingEventBlock には、プレフィックスとして on を付ける必要があることに注意してください。次に、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 イベントハンドラブロックを呼び出すと、JavaScript で同じコールバックプロパティが呼び出されます。このコールバックは生のイベントで呼び出されます。通常、API を簡素化するためにラッパーコンポーネントで処理します。

MapView.tsx
// ...

type RegionChangeEvent = {
nativeEvent: {
latitude: number;
longitude: number;
latitudeDelta: number;
longitudeDelta: number;
};
};

export default function MapView(props: {
// ...
/**
* Callback that is called continuously when the user is dragging the map.
*/
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;
// Do something with `region.latitude`, etc.
});

return (
<MapView
// ...
onRegionChange={onRegionChange}
/>
);
}

複数のネイティブビューの処理

React Native ビューは、ビュートゥリーに複数の子ビューを持つことができます。例:

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

この例では、クラス MyNativeViewNativeComponent のラッパーであり、iOS プラットフォームで呼び出されるメソッドを公開します。 MyNativeViewMyNativeView.ios.js で定義され、NativeComponent のプロキシメソッドが含まれています。

ユーザーがボタンをクリックするなど、コンポーネントと対話すると、MyNativeViewbackgroundColor が変更されます。この場合、UIManager はどの MyNativeView を処理し、どれが backgroundColor を変更する必要があるかを知りません。この問題の解決策を以下に示します。

<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} />;
}
}

callNativeMethod は、たとえば MyNativeView を介して公開される backgroundColor を変更するカスタム iOS メソッドです。このメソッドは、3 つのパラメータを必要とする 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 という 1 つのパラメータのみが含まれています。このエクスポートされた関数は、viewRegistry パラメータを含む addUIBlock を使用して特定のビューを見つけ、reactTag に基づいてコンポーネントを返し、正しいコンポーネントでメソッドを呼び出すことができます。

スタイル

React のネイティブビューはすべて 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),
}
};
}

このガイドでは、カスタムネイティブコンポーネントのブリッジに関する多くの側面を説明しましたが、サブビューの挿入とレイアウトのカスタムフックなど、考慮すべき点がさらにあります。さらに深く理解したい場合は、実装済みコンポーネントのソースコードをご覧ください。