Android에서 뷰를 그리는 방법

kosdjs·2025년 10월 14일

Android

목록 보기
25/29
  • 안드로이드 프레임워크는 Activity 가 포커스를 가질 때 레이아웃을 그리도록 요청함, 안드로이드 프레임워크는 Activity 가 레이아웃 계층 구조의 루트 노드를 제공해야 그리는 과정을 처리할 수 있음

  • 안드로이드 프레임워크는 레이아웃의 루트 노드를 그리고 레이아웃 트리를 측정하고 그림, 트리를 따라 그리며 유효하지 않은 영역(invalid region)과 교차하는 뷰를 렌더링함, 각 뷰 그룹은 draw() 메소드를 사용해 각 하위 요소들을 그리도록 요청해야 하며 각 뷰는 자신을 그려야 함, 트리는 전위 순회(pre-order traversal)되기 때문에 프레임워크는 부모 노드를 먼저 그리고 자식 노드를 그림(부모가 뒤에 그려짐), 그리고 형제 뷰는 트리에 등장하는 순서대로 그림

노트: 프레임워크는 유효한 영역(valid region이라 써있지만 여기서는 그려야 하는 영역을 뜻함)에 있지 않은 뷰 객체를 그리지 않습니다. 또한 뷰의 배경을 그리는 것도 프레임워크가 합니다. invalidate() 를 호출해 뷰를 강제로 그릴 수 있습니다.

  • 안드로이드 프레임워크는 측정 단계와 레이아웃 단계 두 과정을 거쳐 레이아웃을 그림, 프레임워크는 측정 단계에서 measure(int, int) 를 수행하고 뷰 트리를 하향식 순회(top-down traversal)함, 각 뷰는 트리를 순회하는 동안 필요한 공간을 하위로 보냄, 측정 단계의 끝에는 모든 뷰가 자신의 측정치를 가지고 있게 됨, 그러면 프레임워크는 layout(int, int, int, int) 를 수행해 두 번째 단계인 레이아웃 단계를 하향식 순회로 진행함, 이 단계에서 각 부모는 모든 자식들을 측정 단계에서 측정된 크기를 사용해 위치를 잡아야 함

측정 단계 시작하기

  • 뷰 객체의 measure() 메소드가 반환될 때 뷰 객체의 모든 자손들의 것을 포함하는 getMeasuredWidth()getMeasuredHeight() 의 값이 설정됨, 뷰 객체의 측정된 너비와 측정된 높이 값은 반드시 뷰 객체의 부모에 의해 설정된 제약을 준수해야 함, 이는 측정 단계가 끝날 때 모든 부모가 모든 자식들의 측정치를 수용할 수 있도록 보장함

  • 부모 뷰는 measure() 를 자식에 대해 한번 이상 호출할 수 있음, 예를 들어 부모가 자식을 한번 지정되지 않은 크기로 측정해 자식이 선호하는 크기를 결정할 수 있음, 그 후에 자식의 제약되지 않은 크기의 합이 너무 크거나 너무 작으면 부모는 measure() 를 다시 호출해 제약된 자식의 크기를 가져옴

  • 측정 단계에서 크기 정보를 교환하기 위해 두 클래스를 사용함, ViewGroup.LayoutParams 클래스는 View 객체가 선호하는 크기와 위치를 전달하는 데 사용하는 클래스임, 기본 ViewGroup.LayoutParams 클래스는 View 의 선호하는 너비와 높이를 설명함, 각 크기를 다음 중 하나로 설명함

    • 정확한 크기
    • MATCH_PARENT : View 가 선호하는 크기가 부모의 크기에서 패딩을 뺀 크기라는 뜻
    • WRAP_CONTENT : View 가 선호하는 크기가 내용의 크기를 감쌀만큼의 크기에 패딩을 더한 크기라는 뜻
  • ViewGroup 의 다양한 서브클래스를 위한 ViewGroup.LayoutParams 의 서브클래스가 있음, 예시로 RelativeLayout 은 자식 View 객체를 수직과 수평으로 가운데 정렬을 하는 능력을 가지는 ViewGroup.LayoutParams 의 서브클래스를 가지고 있음

  • MeasureSpec 객체는 부모에서 자식으로 요구사항을 전달하기 위해 사용됨, MeasureSpec 은 다음 세 가지 모드 중 하나를 가짐

    • UNSPECIFIED : 부모가 자식 View 의 목표 크기를 결정하기 위해 사용함, 예시로 LinearLayout 은 자식 View 의 높이를 UNSPECIFIED 로 설정하고 너비를 EXACTLY 240으로 설정해 measure() 를 호출해 자식 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, int, int) 메소드는 final 이기 때문에 오버라이드될 수 없음

  • 다음 예제는 WindowManager 샘플 앱에서 SplitLayout 클래스가 이를 어떻게 하는지 보여주는 예제임, SplitLayout 이 두 개 이상의 자식 뷰를 가지고 있고 디스플레이가 접힌다면 두 자식 뷰를 접히는 양쪽 면에 위치하게 함, 다음 예제는 측정과 레이아웃을 오버라이딩하는 것을 보여주지만 이 동작을 하는 것은 생산성을 위해 SlidingPaneLayout 을 사용해야 함

/**
 * 윈도우 전체를 가로지르는 디스플레이 특징에 따라 분할된 두 개의 뷰를 위한
 * 분할 레이아웃 예시입니다. 시작 뷰와 종료 뷰가 모두 추가되면,
 * 접힘(fold) 또는 경첩(hinge) 같은 디스플레이 특징이 영역을 둘로 나누는지 확인하고,
 * 두 뷰를 나란히 혹은 위아래로 배치합니다.
 */
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)
      }
   }

   /**
   * 이 뷰에 대한 분할 위치를 가져옵니다.
   * 분할을 정의하는 사각형(Rect)을 반환하거나 또는 분할이 없으면 null을 반환합니다.
   */
   private fun splitViewPositions(startView: View?, endView: View?): Array? {
      if (windowLayoutInfo == null || startView == null || endView == null) {
            return null
      }

      // 패딩을 포함하는 뷰의 영역을 계산합니다.
      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) { // 수평 레이아웃.
                        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) { // 수직 레이아웃.
                        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)
                        }
                  }
               }
            }

      // 이전에 이미 자식을 측정하고 맞추려고 시도했습니다.
      // 이 값이 맞지 않다면 다시 측정하고 값을 저장합니다.
      measure(lastWidthMeasureSpec, lastHeightMeasureSpec)
      return null
   }

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

   /**
   * 자식 뷰를 측정하고 이 값이 제공된 사각형에 맞는지 확인합니다.
   * 이 메소드는 자식 뷰의 측정된 너비와 높이를 위해 저장된 값을 업데이트 하는
   * [View.measure]를 호출합니다. 만약 뷰가 다른 크기 값을 가지면
   * 다시 측정합니다.
   */
   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
}

원문: https://developer.android.com/guide/topics/ui/how-android-draws

0개의 댓글