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

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

情報

ネイティブモジュールとネイティブコンポーネントは、レガシーアーキテクチャで使用されている安定したテクノロジーです。これらは、新しいアーキテクチャが安定すると、将来的に非推奨になります。新しいアーキテクチャは、同様の結果を達成するために、Turbo Native ModuleFabric Native Componentsを使用します。

最新のアプリで使用できるネイティブ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`です。
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`関数は、`RNTMap`を`RNTMapManager`に自動的に解決し、ネイティブビューを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から地域を設定するとビューがアニメーションするようにカスタムコードが必要です。提供する関数本体内では、`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

これらの変換関数は、キーの欠落やその他の開発者エラーが発生した場合に「RedBox」エラーを表示し、標準の初期化値を返すことで、JSがそれらにスローする可能性のある任意のJSONを安全に処理するように設計されています。

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

これで、`region`プロップを`MapView`に提供できるようになりました。

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

イベント

これでJavaScriptから自由に制御できるネイティブマップコンポーネントができました。しかし、ピンチズームやパン操作で表示領域を変更するなど、ユーザーからのイベントにはどう対応すればよいでしょうか?

これまでは、マネージャーの`-(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

すべての`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ビューは、ビューツリー内に複数の子ビューを持つことができます。例:

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

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

ユーザーがボタンをクリックするなどしてコンポーネントを操作すると、`MyNativeView`の`backgroundColor`が変わります。この場合、`UIManager`はどの`MyNativeView`を処理し、どの`backgroundColor`を変更すべきかを知りません。以下にこの問題の解決策を示します。

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

これで、上記のコンポーネントは特定の`MyNativeView`への参照を持つことになり、これにより`MyNativeView`の特定のインスタンスを使用できるようになります。これでボタンは、どの`MyNativeView`が`backgroundColor`を変更すべきかを制御できます。この例では、`callNativeMethod`が`backgroundColor`を変更すると仮定します。

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

}

ここで、`callNativeMethod`は`RNCMyNativeViewManager.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),
}
};
}

このガイドでは、カスタムネイティブコンポーネントをブリッジするための多くの側面について説明しましたが、サブビューの挿入やレイアウトのためのカスタムフックなど、さらに考慮すべき点があります。さらに深く掘り下げたい場合は、実装されているコンポーネントのソースコードを確認してください。