رفرفة. RenderObject - قياس وقهر

مرحباً بالجميع ، اسمي ديمتري أندريانوف. أنا مطور Flutter في Surf. مكتبة Flutter الرئيسية كافية لبناء واجهة مستخدم فعالة ومنتجة. ولكن هناك أوقات تحتاج فيها إلى تنفيذ حالات محددة ، ثم يتعين عليك التعمق في الأمر.







استهلالي



هناك شاشة بها العديد من الحقول النصية. يمكن أن يكون هناك 5 أو 30 منهم ، ويمكن أن يكون بينهم العديد من الأدوات.







مهمة



  • ضع كتلة مع زر "التالي" فوق لوحة المفاتيح للتبديل إلى الحقل التالي.
  • عند تغيير التركيز ، مرر الحقل إلى الكتلة باستخدام الزر "التالي".


مشكلة



تتداخل الكتلة التي بها الزر مع حقل النص. من الضروري تنفيذ التمرير التلقائي حسب حجم المساحة المتداخلة لحقل النص.







التحضير لحل



1. لنأخذ شاشة من 20 حقلاً.



الرمز:



List<String> list = List.generate(20, (index) => index.toString());

@override
Widget build(BuildContext context) {
 return Scaffold(
   body: SingleChildScrollView(
     child: SafeArea(
       child: Padding(
         padding: const EdgeInsets.all(20),
         child: Column(
           children: <Widget>[
             for (String value in list)
               TextField(
                 decoration: InputDecoration(labelText: value),
               )
           ],
         ),
       ),
     ),
   ),
 );
}


مع التركيز في حقل النص ، نرى الصورة التالية:







الحقل مرئي تمامًا وكل شيء في محله.



2. إضافة كتلة مع زر. يتم







استخدام التراكب لعرض الكتلة . يسمح لك هذا بإظهار اللوحة بشكل مستقل عن عناصر الواجهة على الشاشة وعدم استخدام أغلفة Stack. في الوقت نفسه ، ليس لدينا تفاعل مباشر بين الحقول والكتلة "التالية". مقالة



لطيفة على التراكب. باختصار: يتيح لك التراكب تراكب عناصر واجهة المستخدم فوق عناصر واجهة المستخدم الأخرى ، من خلال مكدس التراكب. يتيح لك OverlayEntry التحكم في التراكب المقابل. الرمز:











bool _isShow = false;
OverlayEntry _overlayEntry;

KeyboardListener _keyboardListener;

@override
void initState() {
 SchedulerBinding.instance.addPostFrameCallback((_) {
   _overlayEntry = OverlayEntry(builder: _buildOverlay);
   Overlay.of(context).insert(_overlayEntry);
   _keyboardListener = KeyboardListener()
     ..addListener(onChange: _keyboardHandle);
 });
 super.initState();
}

@override
void dispose() {
 _keyboardListener.dispose();
 _overlayEntry.remove();
 super.dispose();
}
Widget _buildOverlay(BuildContext context) {
 return Stack(
   children: <Widget>[
     Positioned(
       bottom: MediaQuery.of(context).viewInsets.bottom,
       left: 0,
       right: 0,
       child: AnimatedOpacity(
         duration: const Duration(milliseconds: 200),
         opacity: _isShow ? 1.0 : 0.0,
         child: NextBlock(
           onPressed: () {},
           isShow: _isShow,
         ),
       ),
     ),
   ],
 );
void _keyboardHandle(bool isVisible) {
 _isShow = isVisible;
 _overlayEntry?.markNeedsBuild();
}


3. كما هو متوقع ، تتداخل الكتلة مع الهامش.



أفكار الحلول



1. خذ موضع التمرير الحالي للشاشة من ScrollController وانتقل إلى الحقل.

حجم الحقل غير معروف ، خاصة إذا كان متعدد الأسطر ، فإن التمرير إليه سيعطي نتيجة غير دقيقة. لن يكون الحل مثاليًا أو مرنًا.



2. أضف أحجام الأدوات خارج القائمة وأخذ التمرير في الاعتبار.

إذا قمت بتعيين الأدوات على ارتفاع ثابت ، فعند معرفة موضع التمرير وحجم الأدوات ، ستعرف ما هو موجود الآن في منطقة الرؤية وكم تحتاج إلى التمرير لإظهار عنصر واجهة مستخدم معين.



سلبيات :



  • سيتعين عليك مراعاة جميع عناصر واجهة المستخدم خارج القائمة وتعيين أحجامها الثابتة التي سيتم استخدامها في العمليات الحسابية ، والتي لا تتوافق دائمًا مع التصميم المطلوب وسلوك الواجهة.

  • ستؤدي تعديلات واجهة المستخدم إلى مراجعات في العمليات الحسابية.


3. خذ موضع الحاجيات بالنسبة لشاشة الحقل وكتلة "التالي" واقرأ الفرق.



ناقص - لا يوجد مثل هذا الاحتمال خارج الصندوق.



4. استخدم طبقة عرض.



استنادًا إلى المقال ، يعرف Flutter كيفية ترتيب أحفاده في الشجرة ، مما يعني أنه يمكن سحب هذه المعلومات. RenderObject هو المسؤول عن التقديم ، وسوف نذهب إليه. يحتوي RenderBox على صندوق حجم بعرض القطعة وارتفاعها. يتم حسابها عند التقديم للأدوات: سواء كانت قوائم أو حاويات أو حقول نصية (حتى تلك متعددة الأسطر) ، إلخ.



يمكنك الحصول على RenderBox من خلال

context context.findRenderObject() as RenderBox


يمكنك استخدام GlobalKey للحصول على سياق الحقل.



ناقص :



GlobalKey ليس أسهل شيء. ومن الأفضل استخدامه بأقل قدر ممكن.



"الأدوات ذات المفاتيح العمومية تعيد رسم الأشجار الفرعية الخاصة بها أثناء انتقالها من موقع في الشجرة إلى موقع آخر. لإعادة رسم شجرتها الفرعية ، يجب أن تصل الأداة إلى موقعها الجديد في الشجرة في نفس إطار الرسوم المتحركة الذي تمت إزالته فيه من الموقع القديم.



المفاتيح العالمية باهظة الثمن نسبيًا من حيث الأداء. إذا لم تكن بحاجة إلى أي من الميزات المذكورة أعلاه ، ففكر في استخدام Key أو ValueKey أو ObjectKey أو UniqueKey.



لا يمكنك تضمين عنصرين في شجرة في نفس الوقت بنفس المفتاح العام. إذا حاولت القيام بذلك ، فسيكون هناك خطأ في وقت التشغيل. " المصدر .



في الواقع ، إذا احتفظت بـ 20 GlobalKey على الشاشة ، فلن يحدث شيء سيء ، ولكن نظرًا لأنه يوصى باستخدامه فقط عند الضرورة ، فسنحاول البحث عن طريقة أخرى.



حل بدون GlobalKey



سنستخدم طبقة تصيير. تتمثل الخطوة الأولى في التحقق مما إذا كان بإمكاننا سحب شيء ما من RenderBox وما إذا كانت هذه هي البيانات التي نحتاجها.



كود اختبار الفرضية:



FocusNode get focus => widget.focus;
 @override
 void initState() {
   super.initState();
   Future.delayed(const Duration(seconds: 1)).then((_) {
	// (1)
     RenderBox rb = (focus.context.findRenderObject() as RenderBox);
//(3)
     RenderBox parent = _getParent(rb);
//(4)
     print('parent = ${parent.size.height}');
   });
 }
 RenderBox _getParent(RenderBox rb) {
   return rb.parent is RenderWrapper ? rb.parent : _getParent(rb.parent);
 }

Widget build(BuildContext context) {
   return Wrapper(
     child: Container(
       color: Colors.red,
       width: double.infinity,
       height: 100,
       child: Center(
         child: TextField(
           focusNode: focus,
         ),
       ),
     ),
   );
}

//(2)
class Wrapper extends SingleChildRenderObjectWidget {
 const Wrapper({
   Key key,
   Widget child,
 }) : super(key: key, child: child);
 @override
 RenderWrapper createRenderObject(BuildContext context) {
   return RenderWrapper();
 }
}
class RenderWrapper extends RenderProxyBox {
 RenderWrapper({
   RenderBox child,
 }) : super(child);
}


(1) نظرًا لأنك بحاجة إلى التمرير إلى الحقل ، فأنت بحاجة إلى الحصول على سياقه (على سبيل المثال ، عبر FocusNode) ، والعثور على RenderBox وتحديد الحجم. ولكن هذا هو حجم مربع النص ، وإذا احتجنا أيضًا إلى عناصر واجهة مستخدم رئيسية (على سبيل المثال ، الحشو) ، فنحن بحاجة إلى أخذ RenderBox الرئيسي من خلال الحقل الأصلي.



(2) لقد ورثنا فئة RenderWrapper الخاصة بنا من SingleChildRenderObjectWidget وقمنا بإنشاء RenderProxyBox لها. يحاكي RenderProxyBox جميع خصائص الطفل ، ويعرضها عند عرض شجرة عنصر واجهة المستخدم.

غالبًا ما يستخدم Flutter نفسه ورثة SingleChildRenderObjectWidget:

Align و AnimatedSize و SizedBox و Opacity و Padding.



(3) اجتياز الوالدين بشكل متكرر عبر الشجرة حتى نواجه RenderWrapper.



(4) خذ أبعاد الوالدين - هذا سيعطي الارتفاع الصحيح. هذا هو الطريق الصحيح.



بالطبع ، لا يمكنك ترك هذا الطريق.



لكن النهج العودي له عيوبه أيضًا :



  • لا يضمن اجتياز الشجرة المتكرر أننا لن نصادف سلفًا لسنا مستعدين له. قد لا يناسب النوع وهذا كل شيء. بطريقة ما في الاختبارات واجهت RenderView وسقط كل شيء. يمكنك بالطبع تجاهل السلف غير المناسب ، لكنك تريد نهجًا أكثر موثوقية.
  • هذا حل لا يمكن إدارته ولا يزال غير مرن.


باستخدام RenderObject



هذا الأسلوب هو نتيجة حزمة render_metrics وقد استخدم منذ فترة طويلة في أحد تطبيقاتنا.



منطق العملية:



1. قم بلف عنصر واجهة المستخدم (تابع لفئة عنصر واجهة المستخدم) في RenderMetricsObject . لا يهم التعشيش والهدف المصغر.



RenderMetricsObject(
 child: ...,
)


2. بعد الإطار الأول ، ستكون مقاييسه متاحة لنا. إذا كان حجم الأداة أو موضعها بالنسبة إلى الشاشة (مطلق أو أثناء التمرير) ، فعند طلب المقاييس مرة أخرى ، ستكون هناك بيانات جديدة.



3. ليس من الضروري استخدام RenderManager ، ولكن عند استخدامه ، يجب عليك تمرير معرف عنصر واجهة المستخدم.



RenderMetricsObject(
 id: _text1Id,
 manager: renderManager,
 child: ...


4. يمكنك استخدام عمليات الاسترجاعات:



  • onMount - إنشاء RenderObject. يتلقى المعرف الذي تم تمريره (أو فارغ ، إذا لم يتم تمريره) ومثيل RenderMetricsBox المقابل كوسائط.
  • onUnMount - الإزالة من الشجرة.


في المعلمات ، تتلقى الوظيفة المعرف الذي تم تمريره إلى RenderMetricsObject. هذه الوظائف مفيدة عندما لا تحتاج إلى مدير و / أو عندما تحتاج إلى معرفة متى تم إنشاء RenderObject وإزالته من الشجرة.



RenderMetricsObject(
 id: _textBlockId,
 onMount: (id, box) {},
 onUnMount: (box) {},
 child...
)


5. الحصول على المقاييس. تطبق فئة RenderMetricsBox أداة جمع البيانات ، حيث تأخذ أبعادها عبر localToGlobal. يحول localToGlobal النقطة من نظام الإحداثيات المحلي لهذا RenderBox إلى نظام الإحداثيات العام بالنسبة للشاشة في وحدات البكسل المنطقية.







أ - عرض القطعة ، محولة إلى أقصى نقطة من الإحداثيات بالنسبة للشاشة.



ب - يتم تحويل الارتفاع إلى أدنى نقطة إحداثية بالنسبة للشاشة.



class RenderMetricsBox extends RenderProxyBox {
 RenderData get data {
   Size size = this.size;
   double width = size.width;
   double height = size.height;
   Offset globalOffset = localToGlobal(Offset(width, height));
   double dy = globalOffset.dy;
   double dx = globalOffset.dx;

   return RenderData(
     yTop: dy - height,
     yBottom: dy,
     yCenter: dy - height / 2,
     xLeft: dx - width,
     xRight: dx,
     xCenter: dx - width / 2,
     width: width,
     height: height,
   );
 }

 RenderMetricsBox({
   RenderBox child,
 }) : super(child);
}


6. RenderData هي ببساطة فئة بيانات توفر قيم x و y منفصلة كنقاط مزدوجة وتنسيقية مثل CoordsMetrics .



7. ComparisonDiff - طرح جهازي RenderData يؤدي إلى إرجاع مثيل ComparisonDiff مع الاختلاف بينهما. كما يوفر أداة جامع (diffTopToBottom) لفرق الموضع بين الجزء السفلي من الأداة الأولى وأعلى الثانية والعكس بالعكس (diffBottomToTop). diffLeftToRight و diffRightToLeft على التوالي.



8. RenderParametersManager هو سليل RenderManager. للحصول على مقاييس القطعة والفرق بينها.



الرمز:



class RenderMetricsScreen extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => _RenderMetricsScreenState();
}

class _RenderMetricsScreenState extends State<RenderMetricsScreen> {
 final List<String> list = List.generate(20, (index) => index.toString());
 ///    render_metrics
 ///      
 final _renderParametersManager = RenderParametersManager();
 final ScrollController scrollController = ScrollController();
 /// id    ""
 final doneBlockId = 'doneBlockId';
 final List<FocusNode> focusNodes = [];

 bool _isShow = false;
 OverlayEntry _overlayEntry;
 KeyboardListener _keyboardListener;
 ///   FocusNode,    
 FocusNode lastFocusedNode;

 @override
 void initState() {
   SchedulerBinding.instance.addPostFrameCallback((_) {
     _overlayEntry = OverlayEntry(builder: _buildOverlay);
     Overlay.of(context).insert(_overlayEntry);
     _keyboardListener = KeyboardListener()
       ..addListener(onChange: _keyboardHandle);
   });

   FocusNode node;

   for(int i = 0; i < list.length; i++) {
     node = FocusNode(debugLabel: i.toString());
     focusNodes.add(node);
     node.addListener(_onChangeFocus(node));
   }

   super.initState();
 }

 @override
 void dispose() {
   _keyboardListener.dispose();
   _overlayEntry.remove();
   focusNodes.forEach((node) => node.dispose());
   super.dispose();
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: SingleChildScrollView(
       controller: scrollController,
       child: SafeArea(
         child: Padding(
           padding: const EdgeInsets.all(20),
           child: Column(
             children: <Widget>[
               for (int i = 0; i < list.length; i++)
                 RenderMetricsObject(
                   id: focusNodes[i],
                   manager: _renderParametersManager,
                   child: TextField(
                     focusNode: focusNodes[i],
                     decoration: InputDecoration(labelText: list[i]),
                   ),
                 ),
             ],
           ),
         ),
       ),
     ),
   );
 }

 Widget _buildOverlay(BuildContext context) {
   return Stack(
     children: <Widget>[
       Positioned(
         bottom: MediaQuery.of(context).viewInsets.bottom,
         left: 0,
         right: 0,
         child: RenderMetricsObject(
           id: doneBlockId,
           manager: _renderParametersManager,
           child: AnimatedOpacity(
             duration: const Duration(milliseconds: 200),
             opacity: _isShow ? 1.0 : 0.0,
             child: NextBlock(
               onPressed: () {},
               isShow: _isShow,
             ),
           ),
         ),
       ),
     ],
   );
 }

 VoidCallback _onChangeFocus(FocusNode node) => () {
   if (!node.hasFocus) return;
   lastFocusedNode = node;
   _doScrollIfNeeded();
 };

 /// ,      
 /// .
 void _doScrollIfNeeded() async {
   if (lastFocusedNode == null) return;
   double scrollOffset;

   try {
     ///    id,  data    null
     scrollOffset = await _calculateScrollOffset();
   } catch (e) {
     return;
   }

   _doScroll(scrollOffset);
 }

 ///   
 void _doScroll(double scrollOffset) {
   double offset = scrollController.offset + scrollOffset;
   if (offset < 0) offset = 0;
   scrollController.position.animateTo(
     offset,
     duration: const Duration(milliseconds: 200),
     curve: Curves.linear,
   );
 }

 ///     .
 ///
 ///         ""  
 ///  (/).
 Future<double> _calculateScrollOffset() async {
   await Future.delayed(const Duration(milliseconds: 300));

   ComparisonDiff diff = _renderParametersManager.getDiffById(
     lastFocusedNode,
     doneBlockId,
   );

   lastFocusedNode = null;

   if (diff == null || diff.firstData == null || diff.secondData == null) {
     return 0.0;
   }
   return diff.diffBottomToTop;
 }

 void _keyboardHandle(bool isVisible) {
   _isShow = isVisible;
   _overlayEntry?.markNeedsBuild();
 }
}


النتيجة باستخدام render_metrics







النتيجة



حفر أعمق من مستوى عنصر واجهة المستخدم ، بمساعدة معالجات صغيرة باستخدام طبقة العرض ، حصلنا على وظائف مفيدة تسمح لك بكتابة واجهة مستخدم ومنطق أكثر تعقيدًا. تحتاج أحيانًا إلى معرفة حجم الأدوات الديناميكية أو موضعها أو مقارنة عناصر واجهة المستخدم المتداخلة. وتوفر هذه المكتبة كل هذه الميزات لحل المشكلات بشكل أسرع وأكثر كفاءة. حاولت في المقال أن أشرح آلية التشغيل ، وأعطيت مثالاً لمشكلة وحل. أتمنى أن تنفع المكتبة والمقالات وملاحظاتكم.



All Articles