Android がビューを描画する方法

Compose を試す
Jetpack Compose は、Android で推奨される UI ツールキットです。Compose のフェーズについて学習します。

Android フレームワークは、Activity がフォーカスを取得すると、Activity にレイアウトを描画するよう求めます。描画の手順は Android フレームワークによって処理されますが、レイアウト階層のルートノードは Activity で指定する必要があります。

Android フレームワークでは、描画はレイアウトのルートノードから始まります。続いて、レイアウト ツリーの測定と描画が行われます。描画の処理では、ツリーをたどって無効な領域と交差する各 View がレンダリングされ、描画されます。各 ViewGroupdraw() メソッドでそれぞれの子に対して描画をリクエストする役割を担い、各 View は自身の描画を行う役割を担います。ツリーは前順で走査されるため、親は子より先(つまり背後)に描画され、兄弟はツリーに表示された順序で描画されます。

Android フレームワークは、測定パスとレイアウトパスの 2 つのパスでレイアウトを描画します。測定パスは measure(int, int) で実装され、走査は View ツリーの上から下に向かって行われます。この繰り返しの間に、各 View により寸法指定がツリーの下方にプッシュされます。測定パスの終了時には、すべての View に測定値が保存されます。レイアウトパスは layout(int, int, int, int) で実装されます。この走査も上から下に向かって行われます。このパスでは、各親は測定パスで計算されたサイズを使用して、すべての子を配置する役割を担います。

次のセクションでは、レイアウト プロセスの 2 つのパスについて詳しく説明します。

測定パスを開始する

View オブジェクトの measure() メソッドが返された時点で、その View オブジェクトのすべての子孫も含め、getMeasuredWidth()getMeasuredHeight() の値が設定されています。View オブジェクトの幅と高さの測定値は、その View オブジェクトの親から課された制約に従う必要があります。これにより、測定パスの最後には、すべての親がすべての子の測定値を受け入れることが保証されます。

View は、子に対して measure() を複数回呼び出すことがあります。たとえば、親はまず寸法を指定せずに子の測定を行い、必要なサイズを判断します。制約をかけないと子のサイズの合計が大きすぎる、または小さすぎる場合は、親は再度 measure() を呼び出して、子のサイズを制限する値を指定します

測定パスでは寸法の伝達に 2 つのクラスが使用されます。ViewGroup.LayoutParams クラスは、View オブジェクトが必要なサイズと位置を通知するために使用されます。ViewGroup.LayoutParams 基本クラスには、View が必要とする幅と高さが記述されています。各寸法に次のうちのいずれかを指定できます。

  • ぴったりの寸法
  • MATCH_PARENT: View の必要サイズは、親のサイズからパディング分を引いた値になる
  • WRAP_CONTENT: View の必要サイズは、コンテンツを囲めるサイズにパディング分を加えた値になる

ViewGroup のさまざまなサブクラスに、ViewGroup.LayoutParams サブクラスがあります。たとえば、RelativeLayout には固有の ViewGroup.LayoutParams サブクラスがあり、これには子 View オブジェクトを水平および垂直方向に中央揃えする機能が含まれています。

MeasureSpec オブジェクトは、要件を親から子に向けて、ツリー下方にプッシュするために使用されます。MeasureSpec のモードは、次の 3 つのうちのいずれかになります。

  • UNSPECIFIED: 親が子 View の適切な寸法を決定する際に指定します。たとえば、LinearLayout で高さを UNSPECIFIED、幅を EXACTLY 240 に設定して、子に対して measure() を呼び出し、幅が 240 ピクセルの場合に子 View の高さがいくつになるかを特定します。
  • 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 であるため、オーバーライドできません。

次の例は、WindowManager サンプルアプリの SplitLayout クラスで行う方法を示しています。SplitLayout に 2 つ以上の子ビューがあり、ディスプレイが折りたたみ式の場合、2 つの子ビューが折り目の両側に分かれて配置されます。次の例は、測定とレイアウトをオーバーライドするユースケースを示していますが、本番環境でこの動作を実現するには 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;
      }
   }
}