iOS 原生 UI 组件
原生模块和原生组件是我们的稳定技术,由旧架构使用。当新架构稳定后,它们将在未来被弃用。新架构使用 Turbo Native Module 和 Fabric Native Components 来实现类似的结果。
有大量的原生 UI 小部件可以用于最新的应用程序 - 其中一些是平台的一部分,另一些作为第三方库提供,还有更多可能在您自己的作品集中使用。React Native 已经封装了几个最关键的平台组件,例如 ScrollView
和 TextInput
,但并非全部,当然也不包括您可能为之前的应用程序编写的组件。幸运的是,我们可以封装这些现有组件,以便与您的 React Native 应用程序无缝集成。
与原生模块指南类似,这也是一个更高级的指南,假设您对 iOS 编程有一定了解。本指南将向您展示如何构建原生 UI 组件,引导您完成核心 React Native 库中现有 MapView
组件子集的实现。
iOS MapView 示例
假设我们想在我们的应用程序中添加一个交互式地图 - 不妨使用 MKMapView
,我们只需要使其可以从 JavaScript 中使用。
原生视图由 RCTViewManager
的子类创建和操作。这些子类在功能上类似于视图控制器,但本质上是单例 - 每个子类只有一个实例由桥创建。它们将原生视图暴露给 RCTUIManager
,RCTUIManager
将委托回它们以根据需要设置和更新视图的属性。RCTViewManager
s 通常也是视图的委托,通过桥将事件发送回 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 向我们发送一个 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,方法是显示“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),
}
};
}
本指南涵盖了桥接自定义原生组件的许多方面,但您可能还需要考虑更多,例如用于插入和布局子视图的自定义钩子。如果您想更深入地了解,请查看 源代码 的一些已实现组件。