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 已经封装了几个最关键的平台组件,例如 ScrollView
和 TextInput
,但不是全部,当然也不是你自己为以前的应用编写的组件。幸运的是,我们可以封装这些现有组件,以便与你的 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 RCTViewManager
s 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.
#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 期。
¥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:
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.
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:
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:
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:
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:
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
:
#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" 错误并返回标准初始化值。
¥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:
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
:
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:
#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。
¥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.
#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:
// ...
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}
/>
);
}
处理多个原生视图
¥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>
在此示例中,类 MyNativeView
是 NativeComponent
的封装器并公开将在 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
.
当用户与组件交互时,例如单击按钮,backgroundColor
和 MyNativeView
会发生变化。在这种情况下,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
.
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.
#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:
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:
- (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.