跳到主要内容

iOS 原生 UI 组件

信息

原生模块和原生组件是我们通过旧版架构使用的稳定技术。当新版架构稳定后,它们将被弃用。新版架构使用 Turbo Native ModuleFabric Native Components 来实现类似的功能。

有大量的原生 UI 小部件可供在最新应用程序中使用——其中一些是平台的一部分,一些可作为第三方库使用,还有一些可能已经存在于您自己的项目中。React Native 已经封装了其中一些最关键的平台组件,例如 ScrollViewTextInput,但并非全部,当然也包括您可能为先前应用程序编写的组件。幸运的是,我们可以封装这些现有组件,以便与您的 React Native 应用程序无缝集成。

与原生模块指南一样,这篇也是一篇更高级的指南,假定您对 iOS 编程有一定的了解。本指南将向您展示如何构建原生 UI 组件,并引导您完成核心 React Native 库中现有 MapView 组件子集的实现。

iOS MapView 示例

假设我们想在应用程序中添加一个交互式地图——我们可以使用 MKMapView,我们只需要使其可以从 JavaScript 使用。

原生视图由 RCTViewManager 的子类创建和操作。这些子类的功能类似于视图控制器,但本质上是单例——桥接器每个只创建一个实例。它们将原生视图暴露给 RCTUIManagerRCTUIManager 会委托给它们,以根据需要设置和更新视图的属性。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。有关更多详细信息,请参阅 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 来转换桥接通信中的各种数据类型,不正确的值会显示方便的“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 哨兵。

您可以为您的视图编写任何您想要的转换函数——这里是通过 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>

在此示例中,MyNativeView 类是 NativeComponent 的包装器,并公开了将在 iOS 平台上调用的方法。MyNativeView 定义在 MyNativeView.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];
}];

}

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

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