Android에서 뷰를 그리는 방법

Compose 사용해 보기
Jetpack Compose는 Android를 위한 권장 UI 도구 키트입니다. Compose 단계에 대해 알아봅니다.

Android 프레임워크는 Activity가 포커스를 받을 때 레이아웃을 그리도록 Activity에 요청합니다. Android 프레임워크는 그리기 절차를 처리하지만 Activity에서는 레이아웃 계층 구조의 루트 노드를 제공해야 합니다.

Android 프레임워크는 레이아웃의 루트 노드를 그리고 레이아웃 트리를 측정하고 그립니다. 트리를 이동하고, 잘못된 영역을 교차하는 각 View를 렌더링하여 그립니다. 각 ViewGroupdraw() 메서드를 사용하여 각 하위 요소를 그리도록 요청하고 각 View는 자체 그리기를 담당합니다. 트리는 사전 순서로 순회하므로 프레임워크는 하위 요소 전에(즉, 하위 요소 뒤에) 상위 요소를 그리고 트리에서 표시되는 순서로 동위 요소를 그립니다.

Android 프레임워크는 측정 패스와 레이아웃 패스라는 두 패스 프로세스로 레이아웃을 그립니다. 프레임워크는 measure(int, int)에서 측정 패스를 실행하고 View 트리의 하향식 순회를 실행합니다. 각 View는 반복 중에 트리 아래로 크기 사양을 푸시합니다. 측정 패스가 끝날 때 모든 View는 측정값을 저장합니다. 프레임워크는 layout(int, int, int, int)에서 두 번째 패스를 실행하며 또한 하향식입니다. 이 패스 동안 각 상위 요소는 측정 패스에서 계산된 크기를 사용하여 모든 하위 요소를 배치합니다.

레이아웃 프로세스의 두 패스는 다음 섹션에서 더 자세히 설명합니다.

측정 패스 시작

View 객체의 measure() 메서드가 반환되면 getMeasuredWidth()getMeasuredHeight() 값과 함께 모든 View 객체의 하위 요소 값을 설정합니다. View 객체의 측정된 너비 및 측정된 높이 값은 View 객체의 상위 요소가 부여한 제약 조건을 준수합니다. 그러면 측정 패스가 종료되고 모든 상위 요소에서 하위 요소의 측정값을 모두 수락합니다.

상위 요소 View에서는 하위 요소를 대상으로 두 번 이상 measure()를 호출할 수 있습니다. 예를 들어 상위 요소는 지정되지 않은 크기로 하위 요소를 한 번 측정하여 원하는 크기를 결정할 수 있습니다. 하위 요소의 제한되지 않은 크기의 합계가 너무 크거나 너무 작으면 상위 요소는 하위 요소의 크기를 제한하는 값으로 measure()를 다시 호출할 수 있습니다.

측정 패스에서는 두 개의 클래스를 사용하여 크기를 전달합니다. ViewGroup.LayoutParams 클래스는 View 객체가 선호하는 크기 및 위치를 전달하는 방법입니다. 기본 ViewGroup.LayoutParams 클래스는 View의 기본 너비 및 높이를 설명합니다. 각 크기의 경우 다음 중 하나를 지정할 수 있습니다.

  • 정확한 크기
  • MATCH_PARENT: View의 기본 크기가 상위 요소의 크기에서 패딩을 뺀 값입니다.
  • WRAP_CONTENT: View의 기본 크기가 콘텐츠와 패딩을 포함할 만큼만 큽니다.

ViewGroup의 여러 다른 서브클래스에 맞는 ViewGroup.LayoutParams의 서브클래스가 있습니다. 예를 들어 RelativeLayout에는 ViewGroup.LayoutParams의 서브클래스가 있습니다. 이 클래스에는 하위 요소 View 객체를 가로 및 세로 방향으로 중앙에 두는 기능이 포함됩니다.

MeasureSpec 객체는 상위 요소에서 하위 요소로 요구사항을 트리에서 아래로 푸시하는 데 사용합니다. MeasureSpec은 다음 세 가지 모드 중 하나일 수 있습니다.

  • 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에 두 개 이상의 하위 뷰가 있고 디스플레이에 접히는 부분이 있는 경우 두 개의 하위 뷰를 접히는 부분의 양쪽에 배치합니다. 다음 예는 측정 및 레이아웃을 재정의하는 사용 사례를 보여주지만 프로덕션의 경우 이 동작을 원한다면 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;
      }
   }
}