跳至主内容
版本:当前版本

iOS 原生 UI 组件

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

信息

原生模块(Native Module)和原生组件(Native Components)是旧架构中使用的稳定技术。它们将在新架构稳定后被弃用。新架构采用 Turbo 原生模块Fabric 原生组件 来实现类似功能。

当前有大量现成的原生 UI 组件可供最新应用使用——其中部分属于平台内置组件,部分来自第三方库,还有些可能就存在于你自己的组件库中。React Native 已封装了若干关键平台组件(如 ScrollViewTextInput),但并未覆盖全部组件,尤其不包括你可能为旧应用编写的自定义组件。幸运的是,我们可以将这些现有组件封装起来,实现与 React Native 应用的无缝集成。

与原生模块指南类似,本文同样面向已具备 iOS 编程基础的中高级开发者。本指南将带您逐步实现 React Native 核心库中现有 MapView 组件的部分功能,演示如何构建原生 UI 组件。

iOS MapView 示例

假设我们需要在应用中添加交互式地图——不妨使用 MKMapView,只需使其能在 JavaScript 中调用即可。

原生视图由 RCTViewManager 的子类创建和操作。这些子类功能类似视图控制器,但本质上是单例——桥接层(bridge)仅为每个子类创建一个实例。它们将原生视图暴露给 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 组件的布局属性(layout props)。 如需精细控制,建议将需要设置样式的 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 传递空哨兵值时将属性重置为默认值。

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

现在我们可以给 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 事件处理块会触发 JS 中的同名回调 prop。该回调会接收原始事件,我们通常在包装组件中处理以简化 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 平台调用的方法。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,它需要三个参数:

  • (nonnull NSNumber \*)reactTag  -  react 视图的标识符

  • 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 查找特定视图,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 常量通过以下方式从原生组件获取实际 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),
}
};
}

本指南涵盖了自定义原生组件桥接的诸多方面,但仍有更多内容需要考虑,例如用于插入和布局子视图的自定义钩子。如需深入探索,可参考已实现组件的源代码