iOS 原生 UI 组件
原生模块和原生组件是我们稳定技术,由旧架构使用。当新架构稳定后,它们将被弃用。新架构使用 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
中,并返回包装器 UIView
。有关更多上下文,请参阅 问题 2948。
在上面的示例中,我们在类名前添加了 RNT
。前缀用于避免与其他框架的名称冲突。Apple 框架使用两个字母的前缀,React Native 使用 RCT
作为前缀。为了避免名称冲突,我们建议您在自己的类中使用除 RCT
之外的三个字母的前缀。
然后你需要一些 JavaScript 代码来使它成为一个可用的 React 组件
import {requireNativeComponent} from 'react-native';
// requireNativeComponent automatically resolves 'RNTMap' to 'RNTMapManager'
module.exports = requireNativeComponent('RNTMap');
import MapView from './MapView.tsx';
...
render() {
return <MapView style={{flex: 1}} />;
}
确保在此处使用 RNTMap
。我们希望在此处要求管理器,它将公开我们管理器的视图以供 JavaScript 使用。
渲染时,不要忘记拉伸视图,否则你会看到一个空白屏幕。
render() {
return <MapView style={{flex: 1}} />;
}
这现在是一个在 JavaScript 中完全可用的原生地图视图组件,包括捏缩放和其他原生手势支持。不过,我们还无法从 JavaScript 中控制它 :(
属性
我们可以做的第一件事是使这个组件更易用,就是桥接一些原生属性。假设我们希望能够禁用缩放并指定可见区域。禁用缩放是一个布尔值,因此我们添加这一行
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)
请注意,我们明确地将类型指定为 BOOL
- React Native 在幕后使用 RCTConvert
来转换各种数据类型,以便通过桥接进行通信,并且错误的值将显示方便的“RedBox”错误,以便让您知道尽快出现问题。当事情像这样很简单时,整个实现将由这个宏为您处理。
现在要实际禁用缩放,我们在 JS 中设置属性
<MapView zoomEnabled={false} style={{flex: 1}} />
为了记录我们 MapView 组件的属性(以及它们接受的值),我们将添加一个包装组件并使用 React PropTypes
来记录接口。
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
属性。我们先添加原生代码。
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
属性的支持,我们需要在 propTypes
中记录它。
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,
}),
};
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
回调。
#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
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,
...
};
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>
在此示例中,类 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
常量通过获取本机组件的实际框架从本机导出,如下所示
- (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),
}
};
}
本指南涵盖了跨越自定义原生组件的许多方面,但您可能还需要考虑更多内容,例如用于插入和布局子视图的自定义钩子。如果您想更深入地了解,请查看 一些已实现组件的源代码。