跳至主要内容

iOS 原生 UI 组件

信息

原生模块和原生组件是我们稳定技术,由旧架构使用。当新架构稳定后,它们将被弃用。新架构使用 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 中,并返回包装器 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 中完全可用的原生地图视图组件,包括捏缩放和其他原生手势支持。不过,我们还无法从 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 发送空哨兵时将属性重置为默认值。

你可以为你的视图编写任何你想要的转换函数——以下是通过 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 属性的支持,我们需要在 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 中调用相同的回调道具。此回调会使用原始事件调用,我们通常在包装组件中处理它以简化 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),
}
};
}

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