Skip to main content

iOS 原生 UI 组件

信息

Native Module 和 Native Components 是我们旧版架构使用的稳定技术。当新架构稳定后,它们将被弃用。新架构使用 Turbo Native 模块Fabric 原生组件 来实现类似的结果。

¥Native Module and Native Components are our stable technologies used by the legacy architecture. They will be deprecated in the future when the New Architecture will be stable. The New Architecture uses Turbo Native Module and Fabric Native Components to achieve similar results.

有大量原生 UI 小部件可供在最新应用中使用 - 其中一些是平台的一部分,其他一些可作为第三方库使用,还有更多可能会在你自己的产品组合中使用。React Native 已经封装了几个最关键的平台组件,例如 ScrollViewTextInput,但不是全部,当然也不是你自己为以前的应用编写的组件。幸运的是,我们可以封装这些现有组件,以便与你的 React Native 应用无缝集成。

¥There are tons of native UI widgets out there ready to be used in the latest apps - some of them are part of the platform, others are available as third-party libraries, and still more might be in use in your very own portfolio. React Native has several of the most critical platform components already wrapped, like ScrollView and TextInput, but not all of them, and certainly not ones you might have written yourself for a previous app. Fortunately, we can wrap up these existing components for seamless integration with your React Native application.

与原生模块指南一样,这也是一个更高级的指南,假设你对 iOS 编程有些熟悉。本指南将向你展示如何构建原生 UI 组件,引导你完成核心 React Native 库中可用的现有 MapView 组件子集的实现。

¥Like the native module guide, this too is a more advanced guide that assumes you are somewhat familiar with iOS programming. This guide will show you how to build a native UI component, walking you through the implementation of a subset of the existing MapView component available in the core React Native library.

iOS MapView 示例

¥iOS MapView example

假设我们想向我们的应用添加交互式地图 - 不妨使用 MKMapView,我们只需要使其可以从 JavaScript 中使用即可。

¥Let's say we want to add an interactive Map to our app - might as well use MKMapView, we only need to make it usable from JavaScript.

原生视图由 RCTViewManager 的子类创建和操作。这些子类在功能上与视图控制器类似,但本质上是单例 - 桥只创建每个实例的一个实例。它们向 RCTUIManager 公开原生视图,RCTUIManager 委托回它们以根据需要设置和更新视图的属性。RCTViewManager 通常也是视图的代表,通过桥将事件发送回 JavaScript。

¥Native views are created and manipulated by subclasses of RCTViewManager. These subclasses are similar in function to view controllers, but are essentially singletons - only one instance of each is created by the bridge. They expose native views to the RCTUIManager, which delegates back to them to set and update the properties of the views as necessary. The RCTViewManagers are also typically the delegates for the views, sending events back to JavaScript via the bridge.

要公开视图,你可以:

¥To expose a view you can:

  • 子类 RCTViewManager 为你的组件创建管理器。

    ¥Subclass RCTViewManager to create a manager for your component.

  • 添加 RCT_EXPORT_MODULE() 标记宏。

    ¥Add the RCT_EXPORT_MODULE() marker macro.

  • 实现 -(UIView *)view 方法。

    ¥Implement the -(UIView *)view method.

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 组件的布局属性。如果你需要这种控制粒度,最好将你想要设置样式的 UIView 实例封装在另一个 UIView 中,然后返回封装器 UIView。有关更多背景信息,请参阅 第 2948 期

¥Do not attempt to set the frame or backgroundColor properties on the UIView instance that you expose through the -view method. React Native will overwrite the values set by your custom class in order to match your JavaScript component's layout props. If you need this granularity of control it might be better to wrap the UIView instance you want to style in another UIView and return the wrapper UIView instead. See Issue 2948 for more context.

信息

在上面的示例中,我们在类名前添加了 RNT 前缀。前缀用于避免与其他框架发生名称冲突。Apple 框架使用两个字母的前缀,React Native 使用 RCT 作为前缀。为了避免名称冲突,我们建议在你自己的类中使用除 RCT 之外的三字母前缀。

¥In the example above, we prefixed our class name with RNT. Prefixes are used to avoid name collisions with other frameworks. Apple frameworks use two-letter prefixes, and React Native uses RCT as a prefix. In order to avoid name collisions, we recommend using a three-letter prefix other than RCT in your own classes.

然后你需要一点 JavaScript 来使它成为一个可用的 React 组件:

¥Then you need a little bit of JavaScript to make this a usable React component:

MapView.tsx
import {requireNativeComponent} from 'react-native';

export default requireNativeComponent('RNTMap');

requireNativeComponent 函数自动将 RNTMap 解析为 RNTMapManager 并导出我们的原生视图以供 JavaScript 使用。

¥The requireNativeComponent function automatically resolves RNTMap to RNTMapManager and exports our native view for use in JavaScript.

MyApp.tsx
import MapView from './MapView.tsx';

export default function MyApp() {
return <MapView style={{flex: 1}} />;
}
注意

渲染时,不要忘记拉伸视图,否则你将看到一个空白屏幕。

¥When rendering, don't forget to stretch the view, otherwise you'll be staring at a blank screen.

现在,这是一个功能齐全的 JavaScript 原生地图视图组件,配有捏合缩放和其他原生手势支持。不过,我们还不能真正从 JavaScript 中控制它。

¥This is now a fully-functioning native map view component in JavaScript, complete with pinch-zoom and other native gesture support. We can't really control it from JavaScript yet, though.

属性

¥Properties

为了使该组件更可用,我们可以做的第一件事是桥接一些原生属性。假设我们希望能够禁用缩放并指定可见区域。禁用缩放是一个布尔值,因此我们添加这一行:

¥The first thing we can do to make this component more usable is to bridge over some native properties. Let's say we want to be able to disable zooming and specify the visible region. Disabling zoom is a boolean, so we add this one line:

RNTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)

请注意,我们明确指定类型为 BOOL - React Native 在通过桥进行通信时在底层使用 RCTConvert 来转换各种不同的数据类型,错误的值将显示方便的 "RedBox" 错误,让你尽快知道存在问题。当事情像这样简单时,整个实现都会由这个宏为你处理。

¥Note that we explicitly specify the type as BOOL - React Native uses RCTConvert under the hood to convert all sorts of different data types when talking over the bridge, and bad values will show convenient "RedBox" errors to let you know there is an issue ASAP. When things are straightforward like this, the whole implementation is taken care of for you by this macro.

现在要真正禁用缩放,我们在 JavaScript 中设置属性:

¥Now to actually disable zooming, we set the property in JavaScript:

MyApp.tsx
import MapView from './MapView.tsx';

export default function MyApp() {
return <MapView zoomEnabled={false} style={{flex: 1}} />;
}

为了记录 MapView 组件的属性(以及它们接受哪些值),我们将添加一个封装器组件并使用 TypeScript 记录接口:

¥To document the properties (and which values they accept) of our MapView component we'll add a wrapper component and document the interface with 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} />;
}

现在我们有了一个记录良好的封装器组件可以使用。

¥Now we have a nicely documented wrapper component to work with.

接下来,让我们添加更复杂的 region 属性。我们首先添加原生代码:

¥Next, let's add the more complex region prop. We start by adding the native code:

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 向我们发送一个空哨兵,我们可以使用 defaultView 将属性重置回默认值。

¥Ok, this is more complicated than the BOOL case we had before. Now we have a MKCoordinateRegion type that needs a conversion function, and we have custom code so that the view will animate when we set the region from JS. Within the function body that we provide, json refers to the raw value that has been passed from JS. There is also a view variable which gives us access to the manager's view instance, and a defaultView that we use to reset the property back to the default value if JS sends us a null sentinel.

你可以为你的视图编写任何你想要的转换函数 - 这是通过 RCTConvert 上的类别实现 MKCoordinateRegion。它使用了 ReactNative RCTConvert+CoreLocation 已有的类别:

¥You could write any conversion function you want for your view - here is the implementation for MKCoordinateRegion via a category on RCTConvert. It uses an already existing category of 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" 错误并返回标准初始化值。

¥These conversion functions are designed to safely process any JSON that the JS might throw at them by displaying "RedBox" errors and returning standard initialization values when missing keys or other developer errors are encountered.

为了完成对 region prop 的支持,我们可以使用 TypeScript 记录它:

¥To finish up support for the region prop, we can document it with 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} />;
}

我们现在可以将 region prop 提供给 MapView

¥We can now supply the region prop to MapView:

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}}
/>
);
}

事件

¥Events

现在我们有了一个可以通过 JS 自由控制的原生地图组件,但是我们如何处理来自用户的事件,例如捏缩放或平移以更改可见区域?

¥So now we have a native map component that we can control freely from JS, but how do we deal with events from the user, like pinch-zooms or panning to change the visible region?

到目前为止,我们只从管理器的 -(UIView *)view 方法返回了 MKMapView 实例。我们无法向 MKMapView 添加新属性,因此我们必须从 MKMapView 创建一个新的子类,用于我们的视图。然后我们可以在这个子类上添加 onRegionChange 回调:

¥Until now we've only returned a MKMapView instance from our manager's -(UIView *)view method. We can't add new properties to MKMapView so we have to create a new subclass from MKMapView which we use for our View. We can then add a onRegionChange callback on this subclass:

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。

¥Note that all RCTBubblingEventBlock must be prefixed with on. Next, declare an event handler property on RNTMapManager, make it a delegate for all the views it exposes, and forward events to JS by calling the event handler block from the native view.

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 事件处理程序块会导致在 JavaScript 中调用相同的回调属性。此回调通过原始事件调用,我们通常在封装器组件中处理该事件以简化 API:

¥In the delegate method -mapView:regionDidChangeAnimated: the event handler block is called on the corresponding view with the region data. Calling the onRegionChange event handler block results in calling the same callback prop in JavaScript. This callback is invoked with the raw event, which we typically process in the wrapper component to simplify the 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}
/>
);
}

处理多个原生视图

¥Handling multiple native views

React Native 视图在视图树中可以有多个子视图,例如。

¥A React Native view can have more than one child view in the view tree eg.

<View>
<MyNativeView />
<MyNativeView />
<Button />
</View>

在此示例中,类 MyNativeViewNativeComponent 的封装器并公开将在 iOS 平台上调用的方法。MyNativeView 是在 MyNativeView.ios.js 中定义的,包含 NativeComponent 的代理方法。

¥In this example, the class MyNativeView is a wrapper for a NativeComponent and exposes methods, which will be called on the iOS platform. MyNativeView is defined in MyNativeView.ios.js and contains proxy methods of NativeComponent.

当用户与组件交互时,例如单击按钮,backgroundColorMyNativeView 会发生变化。在这种情况下,UIManager 不知道应该处理哪个 MyNativeView 以及哪个应该更改 backgroundColor。下面你将找到该问题的解决方案:

¥When the user interacts with the component, like clicking the button, the backgroundColor of MyNativeView changes. In this case UIManager would not know which MyNativeView should be handled and which one should change backgroundColor. Below you will find a solution to this problem:

<View>
<MyNativeView ref={this.myNativeReference} />
<MyNativeView ref={this.myNativeReference2} />
<Button
onPress={() => {
this.myNativeReference.callNativeMethod();
}}
/>
</View>

现在上面的组件引用了特定的 MyNativeView,这允许我们使用 MyNativeView 的特定实例。现在该按钮可以控制哪个 MyNativeView 应更改其 backgroundColor。在此示例中,我们假设 callNativeMethod 更改了 backgroundColor

¥Now the above component has a reference to a particular MyNativeView which allows us to use a specific instance of MyNativeView. Now the button can control which MyNativeView should change its backgroundColor. In this example let's assume that callNativeMethod changes 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,需要 3 个参数:

¥callNativeMethod is our custom iOS method which for example changes the backgroundColor which is exposed through MyNativeView. This method uses UIManager.dispatchViewManagerCommand which needs 3 parameters:

  • (nonnull NSNumber \*)reactTag - 反应视图的 id。

    ¥(nonnull NSNumber \*)reactTag  -  id of react view.

  • commandID:(NSInteger)commandID - 应调用的原生方法的 ID

    ¥commandID:(NSInteger)commandID  -  Id of the native method that should be called

  • commandArgs:(NSArray<id> \*)commandArgs - 我们可以从 JS 传递给 Native 的 Native 方法的参数。

    ¥commandArgs:(NSArray<id> \*)commandArgs  -  Args of the native method that we can pass from JS to native.

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 查找包含 viewRegistry 参数的特定视图,并返回基于 reactTag 的组件,从而允许它调用正确组件上的方法。

¥Here the callNativeMethod is defined in the RNCMyNativeViewManager.m file and contains only one parameter which is (nonnull NSNumber*) reactTag. This exported function will find a particular view using addUIBlock which contains the viewRegistry parameter and returns the component based on reactTag allowing it to call the method on the correct component.

样式

¥Styles

由于我们所有的原生 React 视图都是 UIView 的子类,因此大多数样式属性都会像你期望的那样开箱即用。然而,某些组件需要默认样式,例如 UIDatePicker,它是固定大小的。这个默认样式对于布局算法按预期工作很重要,但我们也希望在使用组件时能够覆盖默认样式。DatePickerIOS 通过将原生组件封装在一个额外的视图中来实现此目的,该视图具有灵活的样式,并在内部原生组件上使用固定样式(通过从原生传入的常量生成):

¥Since all our native react views are subclasses of UIView, most style attributes will work like you would expect out of the box. Some components will want a default style, however, for example UIDatePicker which is a fixed size. This default style is important for the layout algorithm to work as expected, but we also want to be able to override the default style when using the component. DatePickerIOS does this by wrapping the native component in an extra view, which has flexible styling, and using a fixed style (which is generated with constants passed in from native) on the inner native component:

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 常量是通过抓取原生组件的实际框架从原生导出的,如下所示:

¥The RCTDatePickerIOSConsts constants are exported from native by grabbing the actual frame of the native component like so:

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),
}
};
}

本指南涵盖了桥接自定义原生组件的许多方面,但你可能还需要考虑更多方面,例如用于插入和布局子视图的自定义钩子。如果你想更深入,请查看一些已实现组件的 源代码

¥This guide covered many of the aspects of bridging over custom native components, but there is even more you might need to consider, such as custom hooks for inserting and laying out subviews. If you want to go even deeper, check out the source code of some of the implemented components.