跳到主要內容

iOS 原生 UI 組件

資訊

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

市面上有大量的原生 UI 小工具,隨時可用於最新的應用程式中 - 其中一些是平台的一部分,另一些則以第三方函式庫的形式提供,還有更多可能已在您自己的作品集中使用。React Native 已經封裝了幾個最關鍵的平台組件,例如 ScrollViewTextInput,但並非全部,當然也不包括您可能為先前的應用程式自行編寫的組件。幸運的是,我們可以封裝這些現有的組件,以便與您的 React Native 應用程式無縫整合。

如同原生模組指南,這也是一份更進階的指南,假設您對 iOS 程式設計有一定程度的熟悉。本指南將向您展示如何建構原生 UI 組件,引導您完成核心 React Native 函式庫中現有 MapView 組件子集的實作。

iOS MapView 範例

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

原生視圖是由 RCTViewManager 的子類別建立和操作的。這些子類別在功能上類似於視圖控制器,但本質上是單例 - 每個子類別只有一個實例由 bridge 建立。它們將原生視圖公開給 RCTUIManager,後者委派回這些子類別以設定和更新視圖的屬性(如有必要)。RCTViewManager 通常也是視圖的委派,透過 bridge 將事件傳送回 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。請參閱 Issue 2948 以取得更多背景資訊。

資訊

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

然後您需要一些 JavaScript 才能使其成為可用的 React 組件

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 控制它。

屬性

為了讓這個組件更實用,我們可以做的第一件事是橋接一些原生屬性。假設我們想要能夠停用縮放並指定可見區域。停用縮放是一個布林值,因此我們新增這行程式碼

RNTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)

請注意,我們明確指定類型為 BOOL - React Native 在底層使用 RCTConvert,以便在透過 bridge 通訊時轉換各種不同的資料類型,而不正確的值會顯示方便的「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 變數,讓我們可以存取管理器的視圖實例,以及一個 defaultView,如果 JS 傳送給我們 null sentinel,我們可以使用它將屬性重設回預設值。

您可以為您的視圖編寫任何您想要的轉換函數 - 這是透過 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} />;
}

我們現在可以將 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}}
/>
);
}

事件

所以現在我們有一個可以從 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 中呼叫相同的回呼屬性。此回呼會使用原始事件叫用,我們通常會在包裝器組件中處理該事件,以簡化 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>

在此範例中,類別 MyNativeViewNativeComponent 的包裝器,並公開將在 iOS 平台上呼叫的方法。MyNativeView 定義於 MyNativeView.ios.js 中,並包含 NativeComponent 的 Proxy 方法。

當使用者與組件互動時,例如點擊按鈕,MyNativeViewbackgroundColor 會變更。在這種情況下,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 是我們的自訂 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];
}];

}

這裡的 callNativeMethod 定義於 RNCMyNativeViewManager.m 檔案中,且僅包含一個參數,即 (nonnull NSNumber*) reactTag。此匯出的函數將使用 addUIBlock 尋找特定視圖,其中包含 viewRegistry 參數,並根據 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),
}
};
}

本指南涵蓋了橋接自訂原生組件的許多方面,但您可能還需要考慮更多事項,例如用於插入和版面配置子視圖的自訂 Hook。如果您想更深入瞭解,請查看一些已實作組件的原始碼