مكونات واجهة المستخدم الأصيلة (iOS) في React Native
هناك الكثير من أدوات واجهة المستخدم (UI widgets) الأصيلة الجاهزة للاستخدام في أحدث التطبيقات، بعضها جزء من المنصّة، والبعض الآخر متاح كمكتبات تابعة لجهات طرف ثالث (third-party libraries)، إضافة إلى ما تستخدمه أنت كذلك. يحتوي React Native على العديد من مكوّنات المنصّة الأكثر أهمية، وهي جاهزة للاستخدام، مثل ScrollView
وTextInput
، ولكن ليس جميعها، وبالطبع، فهذا لا يشمل تلك التي قد تكون كتبتها بنفسك. لحسن الحظ، من السهل جدًا تغليف هذه المكونات الحالية لإدماجها بسلاسة مع تطبيق React Native الخاص بك.
على غرار صفحة الوحدات الأصيلة، فهذا الدليل كذلك أكثر تقدمًا، ويَفترِض أنك معتاد إلى حد ما على برمجة iOS. سيوضح لك هذا الدليل كيفية إنشاء مكون واجهة مستخدم أصيل، وسيرشدك إلى كتابة مجموعة فرعيّة من مكون MapView
الموجود في مكتبة React Native الأساسية (the core React Native library).
مثال MapView لنظام iOS
لنفترض أننا نريد إضافة خريطة تفاعلية إلى تطبيقنا، لنستخدم مكتبة MKMapView
، سنحتاج فقط إلى جعلها قابلة للاستخدام من جهة JavaScript.
تُنشأ العروض الأصيلة وتُعالَج (أو تُدارُ) بواسطة أصناف فرعية من الصنف RCTViewManager
. تشبه هذه الصفات الفرعية في وظيفتها مُتحكّماتِ العروض (view controllers)، لكنّها أساسًا مفردة (singletons)، أي أن الجسر يُنشئ نسخة واحدة فقط من كل منها. وتُوفّر الوصول إلى العروض الأصيلة للصنف RCTUIManager
، الذي يُفوِّضها مرة أخرى لتعيين وتحديث خاصيات العروض حسب الضرورة. عادةً ما تكون أصناف RCTViewManager
هي المفوَّضَةُ لتمثيل العروض (delegates for the views)، مُرسِلةً الأحداثَ إلى JavaScript مُجدّدًا عبر الجسر.
عمليّة توفير الوصول إلى عرضٍ بسيطة:
- أنشئ صنفًا فرعيًّا من
RCTViewManager
لإنشاء مُعالجٍ لإدارة مُكوِّنك. - أضف ماكرو التعليم (marker macro) لتصدير الوحدة بالسطر
RCT_EXPORT_MODULE()
. - اكتب التابع
-(UIView *)view
.
// 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
ملاحظة: لا تحاول تعيين الخاصيّة frame
أو backgroundColor
على نسخة UIView
التي وفَّرْتَ الوصول إليها من خلال التابع -view
. سيكتب React Native فوق القيم التي تُعيِّنها بواسطة صنفك المُخصَّص للتوافق مع خاصيّات JavaScript المسؤولة عن تخطيط (layout) مكوِّنِك. إذا كنت بحاجة إلى دقة التحكم هذه، فقد يكون من الأفضل تغليف نسخةِ الصنف UIView
الذي تريد تصميمه في صنف UIView
آخر وإعادة الصّنف UIView
المُغلَّف بدلاً من ذلك. انظر هذه الصفحة للاستزادة.
في المثال أعلاه، قمنا باستعمال البادئة RNT
في اسم الصنف الذي أنشأناه. تُستخدَم البادئات لتجنب تضارب الأسماء (name collisions) مع الأطر الأخرى. تستخدم أطر عمل Apple بادئات مؤلفة من حرفين، ويستخدم React Native المقطع RCT
كبادئة. نوصي باستخدام بادئة من ثلاثة أحرف بخلاف RCT
في أصنافك الخاصّة لتفادي تضارب الأسماء.
بعد ذلك، ستحتاج فقط إلى القليل من شيفرة JavaScript لجعله مكونَ React جاهز للاستخدام:
// MapView.js
import { requireNativeComponent } from 'react-native';
// requireNativeComponent automatically resolves 'RNTMap' to 'RNTMapManager'
module.exports = requireNativeComponent('RNTMap');
// MyApp.js
import MapView from './MapView.js';
...
render() {
return <MapView style={{ flex: 1 }} />;
}
تأكد من استخدام RNTMap
هنا. نريد طَلَب (أو استيراد: require) المُعالج هنا، والذي سيُوفِّر الوصول إلى عرض المُعالج لاستخدامه في JavaScript.
ملاحظة: عند التصيير (rendering)، لا تنس تمديد العرض كي لا تظهر شاشةٌ فارغة.
render() {
return <MapView style={{flex: 1}} />;
}
أصبح هذا المكون الآن عرضَ خريطة أصيلٍ كاملِ الوظائف قابلٍ للاستخدام في JavaScript، مع دعم كامل المميّزات كالقَرْصِ للتكبير وغيرها من الإيماءات الأصيلة. لكن للأسف لا يمكننا التحكم به من JavaScript حتى الآن.
الخاصيّات
أول ما يمكننا القيام به لجعل هذا المكوّن قابلًا للاستخدام أكثر هو نقل بعض الخاصيّات الأصيلة عبر الجسر. لنقل أننا نريد التمكّن من تعطيل التكبير وتحديد المنطقة المرئية. يُمكن تعطيل التكبير بقيمة منطقيّة بسيطة، لذا سنضيف هذا السطر:
// RNTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(zoomEnabled, BOOL)
لاحظ أننا نحدّد النّوع بوضوح على أنّه قيمة منطقيّة (BOOL
) لأنّ React Native يستخدم RCTConvert
خلف الكواليس لتحويل العديد من أنواع البيانات المختلفة عند نقل البيانات عبر الجسر، وسوف تعرض القيم السيئة رسائل خطأِ صندوقٍ أحمر "RedBox" لإعلامك بوجود مشكلة حالًا. وإن سار كل شيء على ما يُرام كهذه الحالة، فسيعتني هذا الماكرو بالأمر.
لتعطيل التكبير الآن، عيِّن الخاصيّة في JavaScript:
// MyApp.js
<MapView zoomEnabled={false} style={{flex: 1}} />
لتوثيق الخاصيات (والقيم التي تقبلها) لمكوّن MapView
الخاص بنا، سنضيف مكونًا مُغلِّفًا (wrapper component) وسنوثّق الواجهة باستعمال خاصيّة PropTypes
في React:
// MapView.js
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
};
var RNTMap = requireNativeComponent('RNTMap', MapView);
module.exports = MapView;
لدينا الآن مكوّن مُغلَّف وموثَّق سهلُ الاستخدام. لاحظ أننا غيَّرنا معامل الدالة requireNativeComponent
الثاني من null
إلى المكوّن MapView
المُغلِّف الجديد. يسمح هذا للبنية التحتيّة بالتحقق من أنّ أنواع propTypes تتطابق مع الخاصيات الأصيلة لتقليل فرص عدم التطابق بين شيفرة Objective-C وشيفرة JavaScript.
بعد ذلك، لِنُضِف الخاصيّة region
الأعقَد. نبدأ بإضافة الشيفرة الأصيلة:
// RNTMapManager.m
RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, MKMapView)
{
[view setRegion:json ? [RCTConvert MKCoordinateRegion:json] : defaultView.region animated:YES];
}
هذا أعقد من حالة BOOL
البسيطة سابقًا. لدينا الآن نوعٌ باسم MKCoordinateRegion
يحتاج إلى دالّة تحويل (conversion function)، ولدينا شيفرة مخصصّة لتحريك العرض عند ضبط المنطقة (region) من JavaScript. داخل جسم الدالّة التي نُقدِّمها، يشير json
إلى القيمة الأولية (raw value) التي مُرِّرَت من JavaScript. هناك أيضًا متغيّرُ view
يتيح لنا الوصول إلى نسخة مُعالج (أو مُدير) العرض، ونستخدم defaultView
لإعادة تعيين الخاصية إلى القيمة الافتراضية إذا أرسلت شيفرة JavaScript القيمة null
.
يمكنك كتابة أي دالّة تحويلٍ تريدها لعَرضِك، إليك إجراء MKCoordinateRegion
عبر فئةٍ (category) على RCTConvert
. يستخدم هذا فئةً موجودة بالفعل من 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
دوال التحويل هذه مُصمَّمةٌ لمعالجة بيانات JSON بأمان، والتي قد ترميها JavaScript عبر عرض رسائل أخطاء الصندوق الأحمر وعند إرجاع قيم التهيئة القياسية (standard initialization values) عند فقدان المفاتيح أو أخطاء تطوير الأخرى.
لإكمال دعم الخاصيّة region
، نحتاج إلى توثيقها باستخدام propTypes
(أو سنحصل على رسالة خطأ أن الخاصية الأصيلة غير مُوثَّقة)، بعدها يُمكننا ضبطها مثل أي خاصيّة أخرى:
// MapView.js
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,
}),
};
// MyApp.js
render() {
var region = {
latitude: 37.48,
longitude: -122.16,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
};
return (
<MapView
region={region}
zoomEnabled={false}
style={{ flex: 1 }}
/>
);
}
يمكنك هنا أن ترى أن شكل المنطقة واضح في توثيق JavaScript، مثاليًّا يمكننا توليد جزء من هذه الشيفرة تلقائيًّا، لكن هذه الميّزة غير متوفّرة حاليًّا.
أحيانًا، سيكون للمكوّن الأصيل بعض الخاصيات الخاصة التي لا تريد أن تكون جزءًا من واجهة برمجة التطبيقات لمكون React ذو العلاقة. مثلًا، يحتوي Switch
على معالج onChange
مخصَّص للحدث الأصيل الأوليّ (raw native event)، ويُوفّر الوصولَ إلى خاصيّة المعالجة onValueChange
التي تُستدعى بالقيمة المنطقية فقط بدلاً من الحدث الأوليّ. ولأنّك لا تريد أن تكون هذه الخاصيات (الأصيلة حصرًا) جزءًا من واجهة برمجة التطبيقات، فأنت لا تريد وضعها في أنواع propTypes
، ولكن إذا لم تفعل فستحصل على خطأ. الحل هو ببساطة إضافتها إلى الخيار nativeOnly
، مثلًا:
var RCTSwitch = requireNativeComponent('RCTSwitch', Switch, {
nativeOnly: { onChange: true }
});
الأحداث (Events)
لدينا الآن مكوّن خريطة أصيل يمكننا التحكم فيه بسهولة من JavaScript، لكن كيف نتعامل مع أحداث المستخدم (User events)، مثل القرص للتكبير أو للتصغير أو المسح (panning) لتغيير المنطقة المرئية؟
إلى الآن، قمنا فقط بإرجاع نسخة MKMapView
من تابع -(UIView *)view
الخاص بمعالج الإدارة (manager). لا يمكننا إضافة خاصيات جديدة إلى MKMapView
لذلك علينا إنشاء صنف فرعيّ جديد من MKMapView
، والذي سنستخدمه لعرضنا. يُمكننا بعد ذلك إضافة دالة ردّ نداءٍ (callback) باسم onRegionChange
إلى هذا الصنف الفرعيّ:
// 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
. بعد ذلك، صرِّح (declare) عن خاصية معالجة أحداث (event handler property) على RNTMapManager
، واجعلها مفوَّضةً لجميع العروض التي توفّر الوصول إليها، وأعِد توجيه الأحداث إلى JavaScript عن طريق استدعاء كتلة معالج الأحداث (event handler block) من العرض الأصيل.
// 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. تُستدعى دالّة رد النداء هذه مع الحدث الأولي، والذي نقوم بمعالجته عادةً في المكوِّن المُغلِّف لتبسيط واجهة برمجة التطبيقات:
// MapView.js
class MapView extends React.Component {
_onRegionChange = (event) => {
if (!this.props.onRegionChange) {
return;
}
// معالجة الحدث الأوليّ
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,
...
};
// MyApp.js
class MyApp extends React.Component {
onRegionChange(event) {
// Do stuff with event.region.latitude, etc.
// تعامل مع القيمة
// event.region.latitude
// وغيرها من القيم
}
render() {
var 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.
عندما يتفاعل المستخدم مع المكون بالنقر على الزر يتغير backgroundColor الخاص بــ MyNativeView، في هذه الحالة لن يعلم UIManager أي MyNativeView يجب أن يعالج وأي منها سيغير backgroundColor، ستحل هذه المشكلة بالطريقة التالية:
<View>
<MyNativeView ref={this.myNativeReference} />
<MyNativeView ref={this.myNativeReference2} />
<Button
onPress={() => {
this.myNativeReference.callNativeMethod();
}}
/>
</View>
الأى يحوي المكون مرجعًا إلى MyNativeView
محدّد مما يسمح لنا باستخدام نسخة محددة من MyNativeView
. الآن يستطيع الزر أن يعرف لأي MyNativeView
يجب أن يغير backgroundColor
.في هذا المثال لنفرض أن callNativeMethod
هي التي تغير backgroundColor
سيحوي MyNativeView.ios.js
شيفرة كما يلي:
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 والتي تغير backgroundColor
كمثال، والتي تعرض من خلال MyNativeView
. تستخدم هذه الدالة UIManager.dispatchViewManagerCommand والتي تحتاج ثلاثة معاملات:
(nonnull NSNumber \*)reactTag
: الرقم المعرف لعرض react.commandID:(NSInteger)commandID
: الرقم المعرف للدالة الأصيلةالتي يجب استدعاؤها.commandArgs:(NSArray<id> \*)commandArgs
: معاملات الدالة الأصيلة التي يمكن أن نمررها من JS إلى 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
مما يسمح له باستدعاء الدالة على المكون الصحيح.
الأنماط (Styles)
لأنّ جميع عروض React الأصيلة الخاصة بنا هي أصناف فرعية من الصنف UIView
، فإنّ معظم سمات الأنماط (style attributes) ستعمل كالمتوقّع تلقائيًّا. لكن ستحتاج بعض المكونات إلى نمط افتراضي، مثلًا، UIDatePicker
يحتاج إلى حجم ثابت. هذا النمط الافتراضي مهم لتعمل خوارزمية التخطيط كما يجب، لكننا نريد أيضًا التمكّن من الكتابة فوق النمط الافتراضي وتغييره عند استخدام المكوّن. يقوم المكوّن DatePickerIOS
بهذا عن طريق تغليف المكون الأصيل في عرض إضافي يتميّز بتنسيق مرن، وباستخدام نمط ثابت (الذي يولَّد بثوابت تُمرَّر من الجزء الأصيل) على المكون الأصيل الداخلي:
// DatePickerIOS.ios.js
import { UIManager } from 'react-native';
var RCTDatePickerIOSConsts = 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
من الجزء الأصيل عبر الحصول على الإطار (frame) الفعلي للمكون الأصيل كما يلي:
// 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),
}
};
}
غطَّى هذا الدليل العديد من جوانب تجسير المكونات الأصيلة المخصصة، ولكن هناك الكثير مما قد تحتاج إلى أخذه على عين الاعتبار، مثل الخطافات المخصصة (custom hooks) لإدراج العروض الفرعية وتخطيطها. إذا أردت التعمق أكثر، انظر الشيفرة المصدريّة لبعض من مكوّنات React Native.