איך Android מושך צפיות

כדאי לנסות את התכונה 'כתיבה מהירה'
'Jetpack פיתוח נייטיב' היא ערכת הכלים המומלצת לממשק המשתמש ל-Android. מידע על השלבים של Compose

מסגרת Android מבקשת מ-Activity לצייר את הפריסה שלו כש-Activity מקבל את המיקוד. מסגרת Android מטפלת בתהליך הציור, אבל ה-Activity חייב לספק את צומת השורש של היררכיית הפריסה שלו.

מסגרת Android מצייר את צומת הבסיס של הפריסה וממדדת ומציירת את עץ הפריסה. הוא מתבצע על ידי הליכה בעץ ורינדור של כל View שחוצה את האזור הלא תקין. כל ViewGroup אחראי לבקש שכל אחד מהילדים שלו יופיע באמצעות השיטה draw(), וכל View אחראי לצייר את עצמו. מכיוון שהעץ עובר טרaverse לפי סדר מראש, המסגרת מציירת את ההורים לפני – במילים אחרות, מאחורי – הצאצאים שלהם, ומציירת אחים ואחיות לפי הסדר שבו הם מופיעים בעץ.

במסגרת Android, הפריסה מיושמת בתהליך של שני מעבר: כרטיס מדידה וכרטיס פריסה. המסגרת מבצעת את העברת המדד ב-measure(int, int) ומבצעת סריקה מלמעלה למטה של עץ View. כל View דוחף את מפרטי המאפיינים במורד העץ במהלך הרקורסיה. בסוף סבב המדידות, כל View שומר את המדידות שלו. ה-framework מבצע את המעבר השני ב-layout(int, int, int, int) והוא גם מלמעלה-למטה. במהלך הטרנספורמציה הזו, כל רכיב הורה אחראי למיקום של כל רכיבי הצאצא שלו באמצעות המידות שחושבו במעבר המדידה.

שני המעברים של תהליך הפריסה מתוארים בפירוט בסעיפים הבאים.

הפעלת אישור מדידה

כשהשיטה measure() של אובייקט View מחזירה, מגדירים את הערכים getMeasuredWidth() ו-getMeasuredHeight() שלו, יחד עם הערכים של כל הצאצאים של אובייקט View. ערכי רוחב ועומק המדידה של אובייקט View חייבים לעמוד באילוצים שהוטלו על ידי ההורים של אובייקט View. כך אפשר לוודא שבסוף תהליך המדידה, כל ההורים יאשרו את כל המדידות של הילדים שלהם.

הורה View עשוי להתקשר ל-measure() יותר מפעם אחת בצבירת הצאצא שלו. לדוגמה, ההורה יכול למדוד את הילדים פעם אחת עם מידות לא ספציפיות כדי לקבוע את המידות המועדפות עליהם. אם הסכום של מידות הילדים ללא הגבלות גדול מדי או קטן מדי, ההורה יכול לקרוא שוב ל-measure() עם ערכים שמגבילים את המידות של הילדים.

כרטיס המדידה משתמש בשני סיווגים כדי לציין את המאפיינים. באמצעות הכיתה ViewGroup.LayoutParams, אובייקטים מסוג View יכולים להעביר את המיקומים והגדלים המועדפים עליהם. בכיתה הבסיסית ViewGroup.LayoutParams מתוארים הרוחב והגובה המועדפים של View. לכל מאפיין אפשר לציין אחד מהפרטים הבאים:

  • מאפיין מדויק.
  • MATCH_PARENT, כלומר הגודל המועדף של View הוא הגודל של הורה שלו, בניכוי הרווח הפנימי.
  • WRAP_CONTENT, כלומר הגודל המועדף של View הוא גדול מספיק כדי להכיל את התוכן שלו, בתוספת מרווח פנימי.

יש תת-סוגים של ViewGroup.LayoutParams לתת-סוגים שונים של ViewGroup. לדוגמה, ל-RelativeLayout יש תת-סוג משלו של ViewGroup.LayoutParams שכולל את היכולת למרכז אובייקטים View צאצאים אופקית ואנכית.

אובייקטים מסוג MeasureSpec משמשים להעברת דרישות במורד העץ מהורה לצאצא. ל-MeasureSpec יש שלושה מצבים אפשריים:

  • UNSPECIFIED: ההורה משתמש בזה כדי לקבוע את מאפיין היעד של הצאצא View. לדוגמה, LinearLayout עשוי לקרוא ל-measure() על הצאצא שלו כשהגובה מוגדר ל-UNSPECIFIED וברוחב EXACTLY 240 כדי לגלות מה הגובה של הילד או הילדה View, בהינתן רוחב של 240 פיקסלים.
  • EXACTLY: ההורה משתמש בזה כדי לכפות גודל מדויק על הצאצא. הצאצא חייב להשתמש בגודל הזה ולהבטיח שכל הצאצאים שלו יתאימו לגודל הזה.
  • AT MOST: ההורה משתמש בהגדרה הזאת כדי להגדיר מגבלת גודל על הילד או הילדה. הצאצא חייב להבטיח שהוא וכל הצאצאים שלו יהיו בגודל הזה.

הפעלת ריצה של פריסה

כדי להתחיל פריסה, צריך להפעיל את הפונקציה requestLayout(). בדרך כלל, השיטה הזו נקראת על ידי View על עצמו כשהיא חושבת שהיא כבר לא יכולה להתאים למגבלות שלה.

הטמעת לוגיקה מותאמת אישית למדידות ולפריסה

אם רוצים להטמיע לוגיקה מותאמת אישית למדידות או לפריסה, צריך לשנות את השיטה שבה הלוגיקה מוטמעת: onMeasure(int, int) ו-onLayout(boolean, int, int, int, int). השיטות האלה נקראות על ידי measure(int, int) ו-layout(int, int, int, int), בהתאמה. אל תנסו לשנות את השיטה measure(int, int) או layout(int, int) – שתי השיטות האלה הן final, ולכן אי אפשר לשנות אותן.

בדוגמה הבאה מוסבר איך לעשות זאת בכיתה SplitLayout מהאפליקציה לדוגמה WindowManager. אם ל-SplitLayout יש שתי תצוגות צאצא או יותר, והמסך מכיל ציר קיפול, שתי תצוגות הצאצא ממוקמות משני צידי הציר. בדוגמה הבאה מוצג תרחיש לדוגמה של שינוי מברירת המחדל של המדידה והפריסה, אבל בסביבת הייצור צריך להשתמש ב-SlidingPaneLayout אם רוצים את ההתנהגות הזו.

Kotlin

/**
 * An example of split-layout for two views, separated by a display
 * feature that goes across the window. When both start and end views are
 * added, it checks whether there are display features that separate the area
 * in two—such as a fold or hinge—and places them side-by-side or
 * top-bottom.
 */
class SplitLayout : FrameLayout {
   private var windowLayoutInfo: WindowLayoutInfo? = null
   private var startViewId = 0
   private var endViewId = 0

   private var lastWidthMeasureSpec: Int = 0
   private var lastHeightMeasureSpec: Int = 0

   ...

   fun updateWindowLayout(windowLayoutInfo: WindowLayoutInfo) {
      this.windowLayoutInfo = windowLayoutInfo
      requestLayout()
   }

   override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
      val startView = findStartView()
      val endView = findEndView()
      val splitPositions = splitViewPositions(startView, endView)

      if (startView != null && endView != null && splitPositions != null) {
            val startPosition = splitPositions[0]
            val startWidthSpec = MeasureSpec.makeMeasureSpec(startPosition.width(), EXACTLY)
            val startHeightSpec = MeasureSpec.makeMeasureSpec(startPosition.height(), EXACTLY)
            startView.measure(startWidthSpec, startHeightSpec)
            startView.layout(
               startPosition.left, startPosition.top, startPosition.right,
               startPosition.bottom
            )

            val endPosition = splitPositions[1]
            val endWidthSpec = MeasureSpec.makeMeasureSpec(endPosition.width(), EXACTLY)
            val endHeightSpec = MeasureSpec.makeMeasureSpec(endPosition.height(), EXACTLY)
            endView.measure(endWidthSpec, endHeightSpec)
            endView.layout(
               endPosition.left, endPosition.top, endPosition.right,
               endPosition.bottom
            )
      } else {
            super.onLayout(changed, left, top, right, bottom)
      }
   }

   /**
   * Gets the position of the split for this view.
   * @return A rect that defines of split, or {@code null} if there is no split.
   */
   private fun splitViewPositions(startView: View?, endView: View?): Array? {
      if (windowLayoutInfo == null || startView == null || endView == null) {
            return null
      }

      // Calculate the area for view's content with padding.
      val paddedWidth = width - paddingLeft - paddingRight
      val paddedHeight = height - paddingTop - paddingBottom

      windowLayoutInfo?.displayFeatures
            ?.firstOrNull { feature -> isValidFoldFeature(feature) }
            ?.let { feature ->
               getFeaturePositionInViewRect(feature, this)?.let {
                  if (feature.bounds.left == 0) { // Horizontal layout.
                        val topRect = Rect(
                           paddingLeft, paddingTop,
                           paddingLeft + paddedWidth, it.top
                        )
                        val bottomRect = Rect(
                           paddingLeft, it.bottom,
                           paddingLeft + paddedWidth, paddingTop + paddedHeight
                        )

                        if (measureAndCheckMinSize(topRect, startView) &&
                           measureAndCheckMinSize(bottomRect, endView)
                        ) {
                           return arrayOf(topRect, bottomRect)
                        }
                  } else if (feature.bounds.top == 0) { // Vertical layout.
                        val leftRect = Rect(
                           paddingLeft, paddingTop,
                           it.left, paddingTop + paddedHeight
                        )
                        val rightRect = Rect(
                           it.right, paddingTop,
                           paddingLeft + paddedWidth, paddingTop + paddedHeight
                        )

                        if (measureAndCheckMinSize(leftRect, startView) &&
                           measureAndCheckMinSize(rightRect, endView)
                        ) {
                           return arrayOf(leftRect, rightRect)
                        }
                  }
               }
            }

      // You previously tried to fit the children and measure them. Since they
      // don't fit, measure again to update the stored values.
      measure(lastWidthMeasureSpec, lastHeightMeasureSpec)
      return null
   }

   override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
      super.onMeasure(widthMeasureSpec, heightMeasureSpec)
      lastWidthMeasureSpec = widthMeasureSpec
      lastHeightMeasureSpec = heightMeasureSpec
   }

   /**
   * Measures a child view and sees if it fits in the provided rect.
   * This method calls [View.measure] on the child view, which updates its
   * stored values for measured width and height. If the view ends up with
   * different values, measure again.
   */
   private fun measureAndCheckMinSize(rect: Rect, childView: View): Boolean {
      val widthSpec = MeasureSpec.makeMeasureSpec(rect.width(), AT_MOST)
      val heightSpec = MeasureSpec.makeMeasureSpec(rect.height(), AT_MOST)
      childView.measure(widthSpec, heightSpec)
      return childView.measuredWidthAndState and MEASURED_STATE_TOO_SMALL == 0 &&
               childView.measuredHeightAndState and MEASURED_STATE_TOO_SMALL == 0
   }

   private fun isValidFoldFeature(displayFeature: DisplayFeature) =
      (displayFeature as? FoldingFeature)?.let { feature ->
            getFeaturePositionInViewRect(feature, this) != null
      } ?: false
}

Java

/**
* An example of split-layout for two views, separated by a display feature
* that goes across the window. When both start and end views are added, it checks
* whether there are display features that separate the area in two—such as
* fold or hinge—and places them side-by-side or top-bottom.
*/
public class SplitLayout extends FrameLayout {
   @Nullable
   private WindowLayoutInfo windowLayoutInfo = null;
   private int startViewId = 0;
   private int endViewId = 0;

   private int lastWidthMeasureSpec = 0;
   private int lastHeightMeasureSpec = 0;

   ...

   void updateWindowLayout(WindowLayoutInfo windowLayoutInfo) {
      this.windowLayoutInfo = windowLayoutInfo;
      requestLayout();
   }

   @Override
   protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
      @Nullable
      View startView = findStartView();
      @Nullable
      View endView = findEndView();
      @Nullable
      List splitPositions = splitViewPositions(startView, endView);

      if (startView != null && endView != null && splitPositions != null) {
            Rect startPosition = splitPositions.get(0);
            int startWidthSpec = MeasureSpec.makeMeasureSpec(startPosition.width(), EXACTLY);
            int startHeightSpec = MeasureSpec.makeMeasureSpec(startPosition.height(), EXACTLY);
            startView.measure(startWidthSpec, startHeightSpec);
            startView.layout(
                  startPosition.left,
                  startPosition.top,
                  startPosition.right,
                  startPosition.bottom
            );

            Rect endPosition = splitPositions.get(1);
            int endWidthSpec = MeasureSpec.makeMeasureSpec(endPosition.width(), EXACTLY);
            int endHeightSpec = MeasureSpec.makeMeasureSpec(endPosition.height(), EXACTLY);
            startView.measure(endWidthSpec, endHeightSpec);
            startView.layout(
                  endPosition.left,
                  endPosition.top,
                  endPosition.right,
                  endPosition.bottom
            );
      } else {
            super.onLayout(changed, left, top, right, bottom);
      }
   }

   /**
   * Gets the position of the split for this view.
   * @return A rect that defines of split, or {@code null} if there is no split.
   */
   @Nullable
   private List splitViewPositions(@Nullable View startView, @Nullable View endView) {
      if (windowLayoutInfo == null || startView == null || endView == null) {
            return null;
      }

      int paddedWidth = getWidth() - getPaddingLeft() - getPaddingRight();
      int paddedHeight = getHeight() - getPaddingTop() - getPaddingBottom();

      List displayFeatures = windowLayoutInfo.getDisplayFeatures();

      @Nullable
      DisplayFeature feature = displayFeatures
               .stream()
               .filter(item ->
                  isValidFoldFeature(item)
               )
               .findFirst()
               .orElse(null);

      if (feature != null) {
            Rect position = SampleToolsKt.getFeaturePositionInViewRect(feature, this, true);
            Rect featureBounds = feature.getBounds();
            if (featureBounds.left == 0) { // Horizontal layout.
               Rect topRect = new Rect(
                        getPaddingLeft(),
                        getPaddingTop(),
                        getPaddingLeft() + paddedWidth,
                        position.top
               );
               Rect bottomRect = new Rect(
                        getPaddingLeft(),
                        position.bottom,
                        getPaddingLeft() + paddedWidth,
                        getPaddingTop() + paddedHeight
               );
               if (measureAndCheckMinSize(topRect, startView) &&
                        measureAndCheckMinSize(bottomRect, endView)) {
                  ArrayList rects = new ArrayList();
                  rects.add(topRect);
                  rects.add(bottomRect);
                  return rects;
               }
            } else if (featureBounds.top == 0) { // Vertical layout.
               Rect leftRect = new Rect(
                        getPaddingLeft(),
                        getPaddingTop(),
                        position.left,
                        getPaddingTop() + paddedHeight
               );
               Rect rightRect = new Rect(
                        position.right,
                        getPaddingTop(),
                        getPaddingLeft() + paddedWidth,
                        getPaddingTop() + paddedHeight
               );
               if (measureAndCheckMinSize(leftRect, startView) &&
                        measureAndCheckMinSize(rightRect, endView)) {
                  ArrayList rects = new ArrayList();
                  rects.add(leftRect);
                  rects.add(rightRect);
                  return rects;
               }
            }
      }

      // You previously tried to fit the children and measure them. Since
      // they don't fit, measure again to update the stored values.
      measure(lastWidthMeasureSpec, lastHeightMeasureSpec);
      return null;
   }

   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
      super.onMeasure(widthMeasureSpec, heightMeasureSpec);
      lastWidthMeasureSpec = widthMeasureSpec;
      lastHeightMeasureSpec = heightMeasureSpec;
   }

   /**
   * Measures a child view and sees if it fits in the provided rect.
   * This method calls [View.measure] on the child view, which updates
   * its stored values for measured width and height. If the view ends up with
   * different values, measure again.
   */
   private boolean measureAndCheckMinSize(Rect rect, View childView) {
      int widthSpec = MeasureSpec.makeMeasureSpec(rect.width(), AT_MOST);
      int heightSpec = MeasureSpec.makeMeasureSpec(rect.height(), AT_MOST);
      childView.measure(widthSpec, heightSpec);
      return (childView.getMeasuredWidthAndState() & MEASURED_STATE_TOO_SMALL) == 0 &&
               (childView.getMeasuredHeightAndState() & MEASURED_STATE_TOO_SMALL) == 0;
   }

   private boolean isValidFoldFeature(DisplayFeature displayFeature) {
      if (displayFeature instanceof FoldingFeature) {
            return SampleToolsKt.getFeaturePositionInViewRect(displayFeature, this, true) != null;
      } else {
            return false;
      }
   }
}