مكونات واجهة المستخدم الأصيلة (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.m"

// 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({
    /**
     * إحداثيات مركز الخريطة
     */
    latitude: PropTypes.number.isRequired,
    longitude: PropTypes.number.isRequired,

    /**
     * Distance between the minimum and the maximum latitude/longitude
     * المسافة بين الحد الأدنى والأقصى لكل من خطي العرض والطول
     */
    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.m"

@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 = {
  /**
   * دالة رد نداء تُستدعى باستمرار عندما يسحب المستخدم الخريطة
   */
  onRegionChange: PropTypes.func,
  ...
};

// MyApp.js

class MyApp extends React.Component {
  onRegionChange(event) {
    // تعامل مع القيمة
    // 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}
      />
    );
  }
}

الأنماط (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.

مصادر