مكونات واجهة المستخدم الأصيلة لنظام iOS في React Native

من موسوعة حسوب
مراجعة 13:50، 9 أكتوبر 2021 بواسطة جميل-بيلوني (نقاش | مساهمات)
(فرق) → مراجعة أقدم | المراجعة الحالية (فرق) | مراجعة أحدث ← (فرق)

هناك الكثير من أدوات واجهة المستخدم (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) المُعالج (manager) هنا، والذي سيُوفِّر الوصول إلى واجهة المُعالج لاستخدامه في 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 الأصيلة (native react views) الخاصة بنا هي أصناف فرعية من الصنف 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.

مصادر