跳至主要內容

iOS 原生 UI 組件

資訊

原生模組和原生組件是我們的穩定技術,由舊版架構使用。當新架構穩定後,它們將在未來被棄用。新架構使用 Turbo 原生模組Fabric 原生組件 來達成類似的結果。

市面上有許多原生 UI 小工具可供最新應用程式使用,其中一些是平台的一部分,其他則作為第三方程式庫提供,而更多可能在您自己的作品集中使用。React Native 已包裝了幾個最關鍵的平台元件,例如 ScrollViewTextInput,但並非全部,更不用說您可能為先前應用程式自行撰寫的元件。很幸運地,我們可以包裝這些現有元件,以便與您的 React Native 應用程式無縫整合。

與原生模組指南一樣,這也是一個較進階的指南,假設您已熟悉 iOS 程式設計。本指南將說明如何建置原生 UI 元件,引導您逐步實作核心 React Native 程式庫中現有 MapView 元件的子集。

iOS MapView 範例

假設我們想要將互動式地圖新增至我們的應用程式,不妨使用 MKMapView,我們只需要讓它可從 JavaScript 使用即可。

原生檢視是由 RCTViewManager 的子類別建立和操作的。這些子類別在功能上類似於檢視控制器,但基本上是單例,橋接器只會建立每個子類別的一個執行個體。它們會將原生檢視公開給 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 執行個體上的 framebackgroundColor 屬性。React Native 會覆寫自訂類別設定的值,以符合 JavaScript 元件的配置屬性。如果您需要這種程度的控制,建議將您要套用樣式的 UIView 執行個體包裝在另一個 UIView 中,並傳回包裝的 UIView。請參閱 問題 2948 以取得更多背景資訊。

資訊

在上述範例中,我們使用 RNT 為類別名稱加上前綴。前綴用於避免與其他架構的名稱衝突。Apple 架構使用兩個字母的前綴,而 React Native 使用 RCT 作為前綴。為避免名稱衝突,建議您在自己的類別中使用 RCT 以外的三個字母前綴。

接著,您需要一點 JavaScript 才能讓這變成可用的 React 元件

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

// requireNativeComponent automatically resolves 'RNTMap' to 'RNTMapManager'
module.exports = requireNativeComponent('RNTMap');
MyApp.tsx
import MapView from './MapView.tsx';

...

render() {
return <MapView style={{flex: 1}} />;
}

請務必在此處使用 RNTMap。我們希望在此處需要管理員,這將公開我們的管理員檢視,以便在 JavaScript 中使用。

註解

在呈現時,別忘了延伸檢視,否則您會一直盯著空白螢幕。

  render() {
return <MapView style={{flex: 1}} />;
}

這現在是一個功能齊全的原生地圖檢視元件,具備縮放和支援其他原生手勢。不過,我們還無法從 JavaScript 控制它 :(

屬性

我們可以執行的第一件事,是將一些原生屬性橋接過來,讓這個元件更易於使用。假設我們想要停用縮放並指定可見區域。停用縮放是一個布林值,因此我們新增這一行

RNTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)

請注意,我們明確地將類型指定為 BOOL - React Native 在幕後使用 RCTConvert 在透過橋接進行通訊時轉換各種不同的資料類型,而錯誤的值會顯示方便的「RedBox」錯誤,讓您立即知道有問題。如果像這樣很直接,這個巨集會為您處理整個實作。

現在要實際停用縮放,我們在 JS 中設定屬性

MyApp.tsx
<MapView zoomEnabled={false} style={{flex: 1}} />

為了記錄 MapView 元件的屬性(以及它們接受哪些值),我們將新增一個包裝元件,並使用 React PropTypes 記錄介面

MapView.tsx
import PropTypes from 'prop-types';
import React from 'react';
import {requireNativeComponent} from 'react-native';

class MapView extends React.Component {
render() {
return <RNTMap {...this.props} />;
}
}

MapView.propTypes = {
/**
* A Boolean value that determines whether the user may use pinch
* gestures to zoom in and out of the map.
*/
zoomEnabled: PropTypes.bool,
};

const RNTMap = requireNativeComponent('RNTMap');

module.exports = MapView;

現在我們有一個記錄良好的包裝元件可以使用了。

接著,我們來新增更複雜的 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 變數,讓我們可以存取管理員的檢視執行個體,以及一個 defaultView,如果 JS 傳送給我們一個 null 守衛,我們會使用它將屬性重設回預設值。

您可以為您的檢視撰寫任何您想要的轉換函數 - 這是透過 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 prop 的支援,我們需要在 propTypes 中記錄它

MapView.tsx
MapView.propTypes = {
/**
* A Boolean value that determines whether the user may use pinch
* gestures to zoom in and out of the map.
*/
zoomEnabled: PropTypes.bool,

/**
* The region to be displayed by the map.
*
* The region is defined by the center coordinates and the span of
* coordinates to display.
*/
region: PropTypes.shape({
/**
* Coordinates for the center of the map.
*/
latitude: PropTypes.number.isRequired,
longitude: PropTypes.number.isRequired,

/**
* Distance between the minimum and the maximum latitude/longitude
* to be displayed.
*/
latitudeDelta: PropTypes.number.isRequired,
longitudeDelta: PropTypes.number.isRequired,
}),
};
MyApp.tsx
render() {
const region = {
latitude: 37.48,
longitude: -122.16,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
};
return (
<MapView
region={region}
zoomEnabled={false}
style={{flex: 1}}
/>
);
}

在這裡,您會看到區域的形狀在 JS 文件中是明確的。

事件

因此,現在我們有一個原生地圖元件,我們可以從 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

請注意,所有 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 中呼叫相同的回呼 prop。這個回呼會使用原始事件呼叫,我們通常會在包裝元件中處理這個事件,以簡化 API

MapView.tsx
class MapView extends React.Component {
_onRegionChange = event => {
if (!this.props.onRegionChange) {
return;
}

// process raw event...
this.props.onRegionChange(event.nativeEvent);
};
render() {
return (
<RNTMap
{...this.props}
onRegionChange={this._onRegionChange}
/>
);
}
}
MapView.propTypes = {
/**
* Callback that is called continuously when the user is dragging the map.
*/
onRegionChange: PropTypes.func,
...
};
MyApp.tsx
class MyApp extends React.Component {
onRegionChange(event) {
// Do stuff with event.region.latitude, etc.
}

render() {
const region = {
latitude: 37.48,
longitude: -122.16,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
};
return (
<MapView
region={region}
zoomEnabled={false}
onRegionChange={this.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 的特定執行個體。現在,按鈕可以控制哪個 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 是我們的自訂 iOS 方法,例如會變更透過 MyNativeView 公開的 backgroundColor。此方法使用 UIManager.dispatchViewManagerCommand,它需要 3 個參數

  • (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 視圖都是 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),
}
};
}

本指南涵蓋了許多橋接自訂原生元件的層面,但仍有許多你可能需要考慮的事項,例如用於插入和配置子檢視的自訂掛勾。如果你想深入了解,請查看已實作元件的 原始程式碼