iOS 原生 UI 组件
原生模块和原生组件是我们的稳定技术,在旧架构中使用。当新架构稳定后,它们将在未来被弃用。新架构使用 Turbo 原生模块 和 Fabric 原生组件 来实现类似的结果。
市面上有大量原生 UI 小部件可供最新应用使用 - 其中一些是平台的一部分,另一些作为第三方库提供,还有更多可能在你自己的作品集中使用。React Native 已经封装了几个最关键的平台组件,例如 ScrollView
和 TextInput
,但并非全部,当然也不包括你可能为之前的应用编写的组件。幸运的是,我们可以封装这些现有组件,以便与你的 React Native 应用程序无缝集成。
与原生模块指南类似,本指南也是一个更高级的指南,它假设你对 iOS 编程有一定程度的熟悉。本指南将向你展示如何构建原生 UI 组件,并引导你完成核心 React Native 库中现有 MapView
组件子集的实现。
iOS MapView 示例
假设我们想在我们的应用中添加一个交互式地图 - 不妨使用 MKMapView
,我们只需要使其可以从 JavaScript 中使用。
原生视图由 RCTViewManager
的子类创建和操作。这些子类在功能上类似于视图控制器,但本质上是单例 - 每个子类只有一个实例由桥接器创建。它们向 RCTUIManager
公开原生视图,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
中,并返回包装器 UIView
。有关更多上下文,请参阅 Issue 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
在桥接器上进行通信时转换各种不同的数据类型,并且错误的值将显示方便的“RedBox”错误,让你尽快知道存在问题。当事情像这样简单明了时,整个实现都由这个宏为你处理。
现在要实际禁用缩放,我们在 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 向我们发送空值标记,我们可以使用它将属性重置为默认值。
你可以为你的视图编写任何你想要的转换函数 - 这是通过 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,方法是显示“RedBox”错误并在遇到缺少键或其他开发者错误时返回标准初始化值。
为了完成对 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
创建一个新的子类,供我们的 View 使用。然后我们可以在这个子类上添加一个 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
。此导出的函数将使用 addUIBlock
查找特定视图,其中 addUIBlock
包含 viewRegistry
参数并根据 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
常量是从原生导出的,方法是获取原生组件的实际框架,如下所示
- (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),
}
};
}
本指南涵盖了桥接自定义原生组件的许多方面,但你可能还需要考虑更多,例如用于插入和布局子视图的自定义钩子。如果你想更深入地了解,请查看一些已实现组件的 源代码。