Cách Android vẽ Khung hiển thị

Khung Android yêu cầu một Activity vẽ bố cục của nó khi Activity đó nhận được tiêu điểm. Khung Android sẽ xử lý quy trình vẽ nhưng Activity phải cung cấp nút gốc của hệ phân cấp bố cục.

Khung Android vẽ nút gốc của bố cục, cũng như đo lường và vẽ cây bố cục. Khung này vẽ bằng cách di chuyển trên cây và kết xuất từng View giao với khu vực không hợp lệ. Mỗi ViewGroup chịu trách nhiệm yêu cầu vẽ từng phần tử con của nhóm bằng phương thức draw(), còn mỗi View chịu trách nhiệm tự vẽ. Vì di chuyển trên cây theo thứ tự có sẵn, nên khung này sẽ vẽ các phần tử mẹ trước (hay nói cách khác là vẽ các phần tử con sau) và sẽ vẽ các phần tử đồng cấp theo thứ tự xuất hiện trên cây đó.

Khung Android vẽ bố cục theo quy trình 2 lượt: lượt đo lường và lượt bố cục. Khung này thực hiện lượt đo lường trong measure(int, int) và tiến hành di chuyển từ trên xuống dưới cây View. Mỗi View sẽ đẩy thông số kỹ thuật về chiều xuống cây trong quá trình đệ quy. Khi kết thúc lượt đo lường, mỗi View sẽ lưu trữ các thông tin đo lường. Khung này thực hiện lượt thứ hai trong layout(int, int, int, int) và cũng theo thứ tự từ trên xuống dưới. Trong lượt này, mỗi phần tử mẹ chịu trách nhiệm xác định vị trí của tất cả các phần tử con tương ứng bằng cách sử dụng kích thước đã tính toán được trong lượt đo lường.

Hai lượt này của quy trình vẽ bố cục được mô tả chi tiết hơn trong các phần sau.

Bắt đầu một lượt đo lường

Khi phương thức measure() của đối tượng View được trả về, hãy đặt giá trị getMeasuredWidth()getMeasuredHeight() của đối tượng đó cùng với các giá trị cho mọi phần tử con cháu của đối tượng View. Các giá trị chiều rộng đo được và chiều cao đo được của đối tượng View phải tuân theo những ràng buộc do phần tử mẹ của đối tượng View áp đặt. Điều này giúp đảm bảo rằng khi kết thúc lượt đo lường, mọi phần tử mẹ sẽ chấp nhận tất cả các số đo của phần tử con tương ứng.

Phần tử mẹ View có thể gọi measure() nhiều lần ở phần tử con. Ví dụ: phần tử mẹ có thể đo lường các phần tử con một lần mà không chỉ định các chiều để xác định kích thước ưu tiên. Nếu tổng kích thước không bị ràng buộc của phần tử con quá lớn hoặc quá nhỏ, thì phần tử mẹ có thể gọi lại measure() bằng các giá trị ràng buộc kích thước của phần tử con.

Lượt đo lường dùng 2 lớp để truyền đạt các chiều. Lớp ViewGroup.LayoutParams là cách các đối tượng View truyền đạt vị trí và kích thước ưu tiên. Lớp ViewGroup.LayoutParams cơ sở mô tả chiều rộng và chiều cao ưu tiên của View. Đối với mỗi chiều, lớp này có thể chỉ định một trong các tham số sau:

  • Một chiều chính xác.
  • MATCH_PARENT, nghĩa là kích thước ưu tiên cho View là kích thước của phần tử mẹ, trừ đi khoảng đệm.
  • WRAP_CONTENT, nghĩa là kích thước ưu tiên cho View chỉ đủ lớn để chứa nội dung, cộng với khoảng đệm.

ViewGroup.LayoutParams có các lớp con tương ứng với nhiều lớp con của ViewGroup. Ví dụ: RelativeLayout có lớp con riêng là ViewGroup.LayoutParams có khả năng căn giữa các đối tượng con View theo chiều ngang và chiều dọc.

Các đối tượng MeasureSpec dùng để đẩy yêu cầu xuống cây theo chiều từ phần tử mẹ xuống phần tử con. MeasureSpec có thể ở một trong ba chế độ sau:

  • UNSPECIFIED: phần tử mẹ dùng chế độ này để xác định chiều mục tiêu của phần tử con View. Ví dụ: LinearLayout có thể gọi measure() trên phần tử con của nó với chiều cao được đặt thành UNSPECIFIED và chiều rộng là EXACTLY 240 để tìm hiểu xem phần tử con View muốn có chiều cao là bao nhiêu, với giả sử chiều rộng là 240 pixel.
  • EXACTLY: phần tử mẹ dùng chế độ này để áp đặt kích thước chính xác cho phần tử con. Phần tử con phải dùng kích thước này và đảm bảo rằng tất cả các phần tử con cháu của mình sẽ nằm vừa khít bên trong kích thước này.
  • AT MOST: phần tử mẹ dùng chế độ này để áp đặt kích thước tối đa cho phần tử con. Phần tử con phải đảm bảo rằng bản thân và tất cả các phần tử con cháu của mình sẽ nằm vừa khít bên trong kích thước này.

Bắt đầu một lượt bố cục

Để bắt đầu một bố cục, hãy gọi requestLayout(). Phương thức này thường là do View tự gọi chính nó khi khung hiển thị này cho rằng nó không thể nằm trong giới hạn nữa.

Triển khai một phép đo tuỳ chỉnh và logic bố cục

Nếu bạn muốn triển khai một phép đo tuỳ chỉnh hoặc logic bố cục, hãy ghi đè các phương thức mà theo đó logic được triển khai: onMeasure(int, int)onLayout(boolean, int, int, int, int). Các phương thức này được gọi lần lượt bằng measure(int, int)layout(int, int, int, int). Đừng cố ghi đè phương thức measure(int, int) hoặc layout(int, int) – cả hai phương thức này đều là final nên không thể bị ghi đè.

Ví dụ sau cho biết cách thực hiện việc này trong lớp "SplitLayout" từ ứng dụng mẫu WindowManager. Nếu SplitLayout có từ 2 khung hiển thị con trở lên và màn hình có đường ranh giới phần hiển thị, thì 2 khung hiển thị con sẽ nằm ở 1 trong 2 phía của đường ranh giới đó. Ví dụ sau đây minh hoạ một trường hợp sử dụng hành vi ghi đè phép đo và bố cục, nhưng đối với phiên bản chính thức, hãy dùng SlidingPaneLayout nếu bạn muốn hành vi này xảy ra.

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;
      }
   }
}