跳到主要内容

iOS 原生 UI 组件

信息

原生模块(Native Module)和原生组件(Native Components)是传统架构中使用的稳定技术。当新架构(New Architecture)稳定后,它们将在未来被废弃。新架构使用 Turbo 原生模块Fabric 原生组件 来实现类似的结果。

有大量现成的原生 UI 组件可用于最新的应用——其中一些是平台的一部分,另一些可作为第三方库使用,还有更多可能正在你自己的作品中。React Native 已经封装了几个最关键的平台组件,例如 ScrollViewTextInput,但并非所有组件都已封装,更不用说你可能为之前的应用自行编写的组件了。幸运的是,我们可以封装这些现有组件,以便与你的 React Native 应用无缝集成。

与原生模块指南一样,这篇指南也更高级,假设你对 iOS 编程有一定了解。本指南将向你展示如何构建一个原生 UI 组件,通过实现核心 React Native 库中现有 MapView 组件的一个子集来逐步指导你。

iOS MapView 示例

假设我们想为应用添加一个交互式地图——最好使用 MKMapView,我们只需要让它可以在 JavaScript 中使用。

原生视图由 RCTViewManager 的子类创建和操作。这些子类在功能上类似于视图控制器,但本质上是单例——桥接器只创建每个类的一个实例。它们将原生视图暴露给 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 中,并返回包装视图。请参阅 问题 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 来转换各种不同的数据类型,不良值将显示方便的“红框”错误,以便让你尽快知道存在问题。当事情像这样简单明了时,整个实现都会由这个宏为你处理。

现在要实际禁用缩放,我们在 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 标记,我们使用它将属性重置回默认值。

你可以为你的视图编写任何想要的转换函数——这里是通过 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,通过显示“红框”错误并在遇到缺失键或其他开发人员错误时返回标准初始化值。

为了完成对 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} />;
}

我们现在可以向 MapView 提供 region 属性

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 平台上调用。MyNativeViewMyNativeView.ios.js 中定义,包含 NativeComponent 的代理方法。

当用户与组件交互时,比如点击按钮,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];
}];

}

这里,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 常量通过获取原生组件的实际 frame 来从原生导出,如下所示

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

本指南涵盖了桥接自定义原生组件的许多方面,但你可能还需要考虑更多内容,例如插入和布局子视图的自定义钩子。如果你想更深入了解,请查看一些已实现的组件的源代码