iOS 原生 UI 组件
原生模块(Native Module)和原生组件(Native Components)是传统架构中使用的稳定技术。当新架构(New Architecture)稳定后,它们将在未来被废弃。新架构使用 Turbo 原生模块 和 Fabric 原生组件 来实现类似的结果。
有大量现成的原生 UI 组件可用于最新的应用——其中一些是平台的一部分,另一些可作为第三方库使用,还有更多可能正在你自己的作品中。React Native 已经封装了几个最关键的平台组件,例如 ScrollView
和 TextInput
,但并非所有组件都已封装,更不用说你可能为之前的应用自行编写的组件了。幸运的是,我们可以封装这些现有组件,以便与你的 React Native 应用无缝集成。
与原生模块指南一样,这篇指南也更高级,假设你对 iOS 编程有一定了解。本指南将向你展示如何构建一个原生 UI 组件,通过实现核心 React Native 库中现有 MapView
组件的一个子集来逐步指导你。
iOS MapView 示例
假设我们想为应用添加一个交互式地图——最好使用 MKMapView
,我们只需要让它可以在 JavaScript 中使用。
原生视图由 RCTViewManager
的子类创建和操作。这些子类在功能上类似于视图控制器,但本质上是单例——桥接器只创建每个类的一个实例。它们将原生视图暴露给 RCTUIManager
,后者委托给它们来根据需要设置和更新视图的属性。RCTViewManager
通常也是视图的委托,通过桥接器将事件发送回 JavaScript。
要暴露一个视图,你可以
- 子类化
RCTViewManager
为你的组件创建一个管理器。 - 添加
RCT_EXPORT_MODULE()
标记宏。 - 实现
-(UIView *)view
方法。
#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
实例上设置 frame
或 backgroundColor
属性。React Native 将会覆盖你的自定义类设置的值,以便与 JavaScript 组件的布局属性匹配。如果你需要这种粒度控制,最好将你想要设置样式的 UIView
实例包装在另一个 UIView
中,并返回包装视图。请参阅 问题 2948 以获取更多上下文。
在上面的示例中,我们以 RNT
作为类名的前缀。前缀用于避免与其他框架发生名称冲突。Apple 框架使用两个字母的前缀,React Native 使用 RCT
作为前缀。为了避免名称冲突,我们建议在你的类中使用非 RCT
的三个字母前缀。
然后你需要一些 JavaScript 来使其成为一个可用的 React 组件
import {requireNativeComponent} from 'react-native';
export default requireNativeComponent('RNTMap');
requireNativeComponent
函数自动将 RNTMap
解析为 RNTMapManager
,并导出我们的原生视图供 JavaScript 使用。
import MapView from './MapView.tsx';
export default function MyApp() {
return <MapView style={{flex: 1}} />;
}
渲染时,别忘了拉伸视图,否则你将只能看到一个空白屏幕。
现在,这是一个完全功能的 JavaScript 原生地图视图组件,具备捏合缩放和其他原生手势支持。但是,我们还不能真正通过 JavaScript 控制它。
属性
为了使这个组件更易用,我们可以做的第一件事是桥接一些原生属性。假设我们希望能够禁用缩放并指定可见区域。禁用缩放是一个布尔值,因此我们只需添加这一行
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)
请注意,我们明确将类型指定为 BOOL
——React Native 在桥接器下层使用 RCTConvert
来转换各种不同的数据类型,不良值将显示方便的“红框”错误,以便让你尽快知道存在问题。当事情像这样简单明了时,整个实现都会由这个宏为你处理。
现在要实际禁用缩放,我们在 JavaScript 中设置属性
import MapView from './MapView.tsx';
export default function MyApp() {
return <MapView zoomEnabled={false} style={{flex: 1}} />;
}
为了文档化我们的 MapView 组件的属性(以及它们接受的值),我们将添加一个包装组件,并使用 TypeScript 文档化接口
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
属性。我们首先添加原生代码
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
类别
#import "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 文档化它
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
属性
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
回调
#import <MapKit/MapKit.h>
#import <React/RCTComponent.h>
@interface RNTMapView: MKMapView
@property (nonatomic, copy) RCTBubblingEventBlock onRegionChange;
@end
#import "RNTMapView.h"
@implementation RNTMapView
@end
请注意,所有 RCTBubblingEventBlock
都必须以 on
为前缀。接下来,在 RNTMapManager
上声明一个事件处理程序属性,使其成为它暴露的所有视图的委托,并通过从原生视图调用事件处理程序块将事件转发到 JS。
#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
// ...
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} />;
}
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 视图在视图树中可以有多个子视图,例如:
<View>
<MyNativeView />
<MyNativeView />
<Button />
</View>
在这个例子中,类 MyNativeView
是 NativeComponent
的包装器,并暴露了一些方法,这些方法将在 iOS 平台上调用。MyNativeView
在 MyNativeView.ios.js
中定义,包含 NativeComponent
的代理方法。
当用户与组件交互时,比如点击按钮,MyNativeView
的 backgroundColor
会改变。在这种情况下,UIManager
不知道应该处理哪个 MyNativeView
,也不知道应该改变哪个的 backgroundColor
。下面你会找到解决这个问题的方法
<View>
<MyNativeView ref={this.myNativeReference} />
<MyNativeView ref={this.myNativeReference2} />
<Button
onPress={() => {
this.myNativeReference.callNativeMethod();
}}
/>
</View>
现在上面的组件引用了特定的 MyNativeView
,这允许我们使用 MyNativeView
的特定实例。现在按钮可以控制哪个 MyNativeView
应该改变它的 backgroundColor
。在这个例子中,我们假设 callNativeMethod
改变 backgroundColor
。
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
- 应该被调用的原生方法的 IDcommandArgs:(NSArray<id> \*)commandArgs
- 我们可以从 JS 传递给原生的原生方法的参数。
#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
。这个导出的函数将使用包含 viewRegistry
参数的 addUIBlock
找到特定的视图,并根据 reactTag
返回组件,从而允许在正确的组件上调用方法。
样式
由于我们的所有原生 React 视图都是 UIView
的子类,大多数样式属性都会像你期望的那样直接工作。但是,某些组件会需要默认样式,例如固定大小的 UIDatePicker
。这个默认样式对于布局算法按预期工作非常重要,但我们也希望在使用组件时能够覆盖默认样式。DatePickerIOS
通过将原生组件包装在另一个视图中来实现这一点,这个包装视图具有灵活的样式,并在内部原生组件上使用固定样式(由从原生传递过来的常量生成)
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 来从原生导出,如下所示
- (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),
}
};
}
本指南涵盖了桥接自定义原生组件的许多方面,但你可能还需要考虑更多内容,例如插入和布局子视图的自定义钩子。如果你想更深入了解,请查看一些已实现的组件的源代码。