iOS ネイティブ UI コンポーネント
ネイティブモジュールとネイティブコンポーネントは、レガシーアーキテクチャで使用されている安定した技術です。新しいアーキテクチャが安定した段階で、これらは廃止される予定です。新しいアーキテクチャでは、Turbo ネイティブモジュールとFabric ネイティブコンポーネントを使用して同様の結果を実現します。
最新のアプリで利用できるネイティブ UI ウィジェットは数多く存在します。プラットフォームに組み込まれているもの、サードパーティライブラリとして提供されているもの、あるいは過去のアプリで自作したものなど様々です。React Native は、ScrollView
や TextInput
など、最も重要なプラットフォームコンポーネントのいくつかを既にラップしていますが、すべてを網羅しているわけではなく、過去のアプリで自作したコンポーネントは当然含まれていません。幸いなことに、既存のコンポーネントをラップして React Native アプリケーションとシームレスに統合することができます。
ネイティブモジュールのガイドと同様に、これも iOS プログラミングにある程度精通していることを前提とした高度なガイドです。このガイドでは、ネイティブ UI コンポーネントの構築方法を、コア React Native ライブラリで利用可能な既存の MapView
コンポーネントのサブセットの実装を通して説明します。
iOS MapView の例
アプリにインタラクティブなマップを追加したいとしましょう。 MKMapView
を使用すれば、JavaScript から利用できるようにするだけで済みます。
ネイティブビューは、RCTViewManager
のサブクラスによって作成および操作されます。これらのサブクラスは機能的にはビューコントローラに似ていますが、本質的にはシングルトンであり、ブリッジによって各インスタンスが 1 つだけ作成されます。ネイティブビューを 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 のフレームワークは 2 文字のプレフィックスを使用し、React Native はプレフィックスとして RCT
を使用します。名前の衝突を避けるために、独自のクラスでは RCT
以外の 3 文字のプレフィックスを使用することをお勧めします。
次に、これを使いやすい 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 で完成しました。ただし、まだ JavaScript から制御することはできません。
プロパティ
このコンポーネントをより使いやすくするためにできる最初のことは、いくつかのネイティブプロパティをブリッジすることです。ズームを無効にして、表示領域を指定できるようにしたいとしましょう。ズームの無効化はブール値なので、次の 1 行を追加します。
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: {
/**
* Whether the user may use pinch gestures to zoom in and out.
*/
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 から region を設定したときにビューがアニメーションするようにカスタムコードがあります。提供する関数本体内で、json
は JS から渡された生の値を指します。また、マネージャのビューインスタンスにアクセスできる view
変数と、JS から null セントリネルが送信された場合にプロパティをデフォルト値にリセットするために使用する defaultView
があります。
ビューに合わせて任意の変換関数を記述できます。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: {
/**
* 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} />;
}
これで、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
に新しいプロパティを追加することはできないため、View に使用する 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
イベントハンドラブロックを呼び出すと、JavaScript で同じコールバックプロパティが呼び出されます。このコールバックは生のイベントで呼び出されます。通常、API を簡素化するためにラッパーコンポーネントで処理します。
// ...
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} />;
}
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>
この例では、クラス 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
の特定のインスタンスを使用できるようになりました。これで、ボタンはどの 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 メソッドです。このメソッドは、3 つのパラメータを必要とする UIManager.dispatchViewManagerCommand
を使用します。
(nonnull NSNumber \*)reactTag
- react ビューの ID。commandID:(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
という 1 つのパラメータのみが含まれています。このエクスポートされた関数は、viewRegistry
パラメータを含む addUIBlock
を使用して特定のビューを見つけ、reactTag
に基づいてコンポーネントを返し、正しいコンポーネントでメソッドを呼び出すことができます。
スタイル
React のネイティブビューはすべて 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),
}
};
}
このガイドでは、カスタムネイティブコンポーネントのブリッジに関する多くの側面を説明しましたが、サブビューの挿入とレイアウトのカスタムフックなど、考慮すべき点がさらにあります。さらに深く理解したい場合は、実装済みコンポーネントのソースコードをご覧ください。