React Native使用指南-原生UI元件
在如今的App中,已經有成千上萬的原生UI部件了——其中的一些是平臺的一部分,另一些可能來自於一些第三方庫,而且可能你自己還收藏了很多。React Native已經封裝了大部分最常見的元件,譬如ScrollView
和TextInput
,但不可能封裝全部元件。而且,說不定你曾經為自己以前的App還封裝過一些元件,React
Native肯定沒法包含它們。幸運的是,在React Naitve應用程式中封裝和植入已有的元件非常簡單。
和原生模組嚮導一樣,本嚮導也是一個相對高階的嚮導,我們假設你已經對iOS程式設計頗有經驗。本嚮導會引導你如何構建一個原生UI元件,帶領你瞭解React Native核心庫中MapView
iOS MapView樣例
假設我們要把地圖元件植入到我們的App中——我們用到的是MKMapView
,而現在只需要讓它可以被Javascript重用。
原生檢視都需要被一個RCTViewManager
的子類來建立和管理。這些管理器在功能上有些類似“檢視控制器”,但它們本質上都是單例 - React Native只會為每個管理器建立一個例項。它們建立原生的檢視並提供給RCTUIManager
,RCTUIManager
則會反過來委託它們在需要的時候去設定和更新檢視的屬性。RCTViewManager
還會代理檢視的所有委託,並給JavaScript發回對應的事件。
提供原生檢視很簡單:
- 首先建立一個子類
- 新增
RCT_EXPORT_MODULE()
標記巨集 - 實現
-(UIView *)view
方法
// RCTMapManager.m
#import <MapKit/MapKit.h>
#import "RCTViewManager.h"
@interface RCTMapManager : RCTViewManager
@end
@implementation RCTMapManager
RCT_EXPORT_MODULE()
- (UIView *)view
{
return [[MKMapView alloc] init];
}
@end
接下來你需要一些Javascript程式碼來讓這個檢視變成一個可用的React元件:
// MapView.js
var { requireNativeComponent } = require('react-native');
// requireNativeComponent 自動把這個元件提供給 "RCTMapManager"
module.exports = requireNativeComponent('RCTMap', null);
現在我們就已經實現了一個完整功能的地圖元件了,諸如捏放和其它的手勢都已經完整支援。但是現在我們還不能真正的從Javascript端控制它。(╯﹏╰)
屬性
我們能讓這個元件變得更強大的第一件事情就是要能夠封裝一些原生屬性供Javascript使用。舉例來說,我們希望能夠禁用手指捏放操作,然後指定一個初始的地圖可見區域。禁用捏放操作只需要一個布林值型別的屬性就行了,所以我們新增這麼一行:
// RCTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(pitchEnabled, BOOL)
注意我們現在把型別宣告為BOOL
型別——React Native用RCTConvert
來在JavaScript和原生程式碼之間完成型別轉換。如果轉換無法完成,會產生一個“紅屏”的報錯提示,這樣你就能立即知道程式碼中出現了問題。如果一切進展順利,上面這個巨集就已經包含了匯出屬性的全部實現。
現在要想禁用捏放操作,我們只需要在JS裡設定對應的屬性:
// MyApp.js
<MapView pitchEnabled={false} />
但這樣並不能很好的說明這個元件的用法——使用者要想知道我們的元件有哪些屬性可以用,以及可以取什麼樣的值,他不得不一路翻到Objective-C的程式碼。要解決這個問題,我們可以建立一個封裝元件,並且通過PropTypes
來說明這個元件的介面。
// MapView.js
var React = require('react-native');
var { requireNativeComponent } = React;
class MapView extends React.Component {
render() {
return <RCTMap {...this.props} />;
}
}
MapView.propTypes = {
/**
* 當這個屬性被設定為true,並且地圖上綁定了一個有效的可視區域的情況下,
* 可以通過捏放操作來改變攝像頭的偏轉角度。
* 當這個屬性被設定成false時,攝像頭的角度會被忽略,地圖會一直顯示為俯視狀態。
*/
pitchEnabled: React.PropTypes.bool,
};
var RCTMap = requireNativeComponent('RCTMap', MapView);
module.exports = MapView;
譯註:使用了封裝元件之後,你還需要注意到module.exports匯出的不再是requireNativeComponent的返回值,而是所建立的包裝元件。
現在我們有了一個封裝好的元件,還有了一些註釋文件,使用者使用起來也更方便了。注意我們現在把requireNativeComponent
的第二個引數從null變成了用於封裝的元件MapView
。這使得React
Native的底層框架可以檢查原生屬性和包裝類的屬性是否一致,來減少出現問題的可能。
現在,讓我們新增一個更復雜些的region
屬性。我們首先新增原生程式碼:
// RCTMapManager.m
RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, RCTMap)
{
[view setRegion:json ? [RCTConvert MKCoordinateRegion:json] : defaultView.region animated:YES];
}
這段程式碼比剛才的一個簡單的BOOL
要複雜的多了。現在我們多了一個需要做型別轉換的MKCoordinateRegion
型別,還添加了一部分自定義的程式碼,這樣當我們在JS裡改變地圖的可視區域的時候,視角會平滑地移動過去。在我們提供的函式體內,json
代表了JS中傳遞的尚未解析的原始值。函式裡還有一個view
變數,使得我們可以訪問到對應的檢視例項。最後,還有一個defaultView
物件,這樣當JS給我們傳送null的時候,可以把檢視的這個屬性重置回預設值。
你可以為檢視編寫任何你所需要的轉換函式——下面就是MKCoordinateRegion
的轉換實現,它通過兩個RCTConvert的擴充套件來完成:
@implementation RCTConvert(CoreLocation)
RCT_CONVERTER(CLLocationDegrees, CLLocationDegrees, doubleValue);
RCT_CONVERTER(CLLocationDistance, CLLocationDistance, doubleValue);
+ (CLLocationCoordinate2D)CLLocationCoordinate2D:(id)json
{
json = [self NSDictionary:json];
return (CLLocationCoordinate2D){
[self CLLocationDegrees:json[@"latitude"]],
[self CLLocationDegrees:json[@"longitude"]]
};
}
@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]
};
}
這些轉換函式被設計為可以安全的處理任何JS扔過來的JSON:當有任何缺少的鍵或者其它問題發生的時候,顯示一個“紅屏”的錯誤提示。
為了完成region
屬性的支援,我們還需要在propTypes
裡新增相應的說明(否則我們會立刻收到一個錯誤提示),然後就可以像使用其他屬性一樣使用了:
// MapView.js
MapView.propTypes = {
/**
* 當這個屬性被設定為true,並且地圖上綁定了一個有效的可視區域的情況下,
* 可以通過捏放操作來改變攝像頭的偏轉角度。
* 當這個屬性被設定成false時,攝像頭的角度會被忽略,地圖會一直顯示為俯視狀態。
*/
pitchEnabled: React.PropTypes.bool,
/**
* 地圖要顯示的區域。
*
* 區域由中心點座標和區域範圍座標來定義。
*
*/
region: React.PropTypes.shape({
/**
* 地圖中心點的座標。
*/
latitude: React.PropTypes.number.isRequired,
longitude: React.PropTypes.number.isRequired,
/**
* 最小/最大經、緯度間的距離。
*/
latitudeDelta: React.PropTypes.number.isRequired,
longitudeDelta: React.PropTypes.number.isRequired,
}),
};
// MyApp.js
render() {
var region = {
latitude: 37.48,
longitude: -122.16,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
};
return <MapView region={region} />;
}
現在你可以看到region屬性的整個結構已經加上了文件說明——將來可能我們會自動生成一些類似的程式碼,但目前還沒有這樣的手段。
有時候你的原生元件有一些特殊的屬性希望匯出,但並不希望它成為公開的介面。舉個例子,Switch
元件可能會有一個onChange
屬性用來傳遞原始的原生事件,然後匯出一個onValueChange
屬性,這個屬性在呼叫的時候會帶上Switch
的狀態作為引數之一。這樣的話你可能不希望原生專用的屬性出現在API之中,也就不希望把它放到propTypes
裡。可是如果你不放的話,又會出現一個報錯。解決方案就是帶上額外的nativeOnly
引數,像這樣:
var RCTSwitch = requireNativeComponent('RCTSwitch', Switch, {
nativeOnly: { onChange: true }
});
事件
現在我們已經有了一個原生地圖元件,並且從JS可以很容易的控制它了。不過我們怎麼才能處理來自使用者的事件,譬如縮放操作或者拖動來改變可視區域?關鍵的步驟就在於讓RCTMapManager
來委託我們提供的所有檢視,然後把事件通過分發器傳遞給JavaScript。最終的程式碼看起來類似這樣(比起完整的實現有所簡化):
// RCTMapManager.m
#import "RCTMapManager.h"
#import <MapKit/MapKit.h>
#import "RCTBridge.h"
#import "RCTEventDispatcher.h"
#import "UIView+React.h"
@interface RCTMapManager() <MKMapViewDelegate>
@end
@implementation RCTMapManager
RCT_EXPORT_MODULE()
- (UIView *)view
{
MKMapView *map = [[MKMapView alloc] init];
map.delegate = self;
return map;
}
#pragma mark MKMapViewDelegate
- (void)mapView:(RCTMap *)mapView regionDidChangeAnimated:(BOOL)animated
{
MKCoordinateRegion region = mapView.region;
NSDictionary *event = @{
@"target": mapView.reactTag,
@"region": @{
@"latitude": @(region.center.latitude),
@"longitude": @(region.center.longitude),
@"latitudeDelta": @(region.span.latitudeDelta),
@"longitudeDelta": @(region.span.longitudeDelta),
}
};
[self.bridge.eventDispatcher sendInputEventWithName:@"topChange" body:event];
}
如你所見,我們剛才配置了管理器,委託它代理建立的所有檢視,並且在委託方法-mapView:regionDidChangeAnimated:
中,把地圖目前的區域以及reactTag
目標封裝成了一個事件,這樣我們的事件就可以通過sendInputEventWithName:body:
傳送到正確的React元件例項上。事件名@"topChange"
對應的是JavaScript端的onChange
回撥屬性。這個回撥會被原生事件執行,然後我們通常都會在封裝元件裡做一些處理,來使得API更簡明:
// MapView.js
class MapView extends React.Component {
constructor() {
this._onChange = this._onChange.bind(this);
}
_onChange(event: Event) {
if (!this.props.onRegionChange) {
return;
}
this.props.onRegionChange(event.nativeEvent.region);
}
render() {
return <RCTMap {...this.props} onChange={this._onChange} />;
}
}
MapView.propTypes = {
/**
* Callback that is called continuously when the user is dragging the map.
*/
onRegionChange: React.PropTypes.func,
...
};
樣式
因為我們所有的檢視都是UIView
的子類,大部分的樣式屬性應該直接就可以生效。但有一部分元件會希望使用自己定義的預設樣式,例如UIDatePicker
希望自己的大小是固定的。這個預設屬性對於佈局演算法的正常工作來說很重要,但我們也希望在使用這個元件的時候可以覆蓋這些預設的樣式。DatePickerIOS
實現這個功能的辦法是通過封裝一個擁有彈性樣式的額外檢視,然後在內層的檢視上應用一個固定樣式(通過原生傳遞來的常數生成):
// DatePickerIOS.ios.js
var RCTDatePickerIOSConsts = require('react-native').UIManager.RCTDatePicker.Constants;
...
render: function() {
return (
<View style={this.props.style}>
<RCTDatePickerIOS
ref={DATEPICKER}
style={styles.rkDatePickerIOS}
...
/>
</View>
);
}
});
var styles = StyleSheet.create({
rkDatePickerIOS: {
height: RCTDatePickerIOSConsts.ComponentHeight,
width: RCTDatePickerIOSConsts.ComponentWidth,
},
});
常量RCTDatePickerIOSConsts
在原生程式碼中匯出,從一個元件的實際佈局上獲取到:
// 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),
}
};
}
本嚮導覆蓋了包裝原生元件所需瞭解的許多方面,不過你可能還有很多知識需要了解,譬如特殊的方式來插入和佈局子檢視。如果你想更深入瞭解,可以閱讀RCTMapManager
和其它的元件的原始碼。