|

Android ConstraintLayout 완전 정리 (18) – ConstraintLayout 상태별 레이아웃 설계: loading, empty, error 화면 만들기

Android ConstraintLayout 완전 정리 (18) – ConstraintLayout 상태별 레이아웃 설계: loading, empty, error 화면 만들기
상태별 레이아웃은 loading, empty, content, error를 같은 기준선 위에서 안정적으로 바꾸는 설계입니다.

ConstraintLayout 상태별 레이아웃은 loading, empty, content, error 화면을 각각 따로 예쁘게 만드는 문제가 아닙니다. 상태가 바뀔 때 View 위치와 기준선이 흔들리지 않게 만드는 배치 설계 문제입니다.

이번 글에서는 상태 관리를 ViewModel이나 아키텍처 관점으로 넓히지 않고, ConstraintLayout 안에서 무엇을 숨기고, 무엇을 고정하고, 언제 제약을 바꿔야 하는지만 집중해서 정리하겠습니다.


이번 글에서 다루는 범위

여기서 말하는 상태별 레이아웃은 데이터 상태를 어떻게 저장할지에 대한 이야기가 아닙니다. loading, empty, content, error 같은 상태가 이미 정해졌다고 보고, 그 상태가 화면에 표시될 때 ConstraintLayout 배치가 무너지지 않게 만드는 방법을 다룹니다.

  • 다루는 것: View의 표시 여부, 고정 기준선, 0dp 영역, Group, Barrier, ConstraintSet 사용 기준
  • 다루지 않는 것: ViewModel 설계, 네트워크 상태 관리, Compose 상태 처리, MotionLayout 전환 애니메이션

이 범위를 먼저 나누면 글의 핵심이 분명해집니다. 상태 객체를 어떻게 만들든, 마지막에는 View가 어디에 붙고 어떤 기준을 유지하는지가 중요합니다.


ConstraintLayout 상태별 레이아웃 핵심 정리

  • loading, empty, content, error는 같은 화면 안에서 바뀌는 상태로 본다.
  • 상태마다 새 기준을 만들기보다 toolbar, contentArea, messageArea 같은 고정 기준을 먼저 잡는다.
  • 같은 자리에서 View만 바뀌면 visibilityGroup으로 충분할 수 있다.
  • content 기준이 panel 아래로 이동하는 것처럼 연결 자체가 바뀌면 ConstraintSet을 검토한다.
  • 0dp 영역은 상태 전환 중에도 수평/수직 제약이 남아 있어야 한다.
  • empty와 error의 메시지 길이가 달라질 때는 Barrier가 기준선 안정화에 도움을 줄 수 있다.
ConstraintLayout 상태별 레이아웃 loading empty content error 비교 이미지
상태가 달라져도 같은 parent 안에서 기준선과 주요 영역이 흔들리지 않아야 합니다.

상태별 레이아웃에서 가장 흔한 실수는 각 상태를 독립 화면처럼 보는 것입니다. 실제로는 같은 ConstraintLayout 안에서 어떤 View가 보이고, 어떤 View가 숨고, 어떤 기준선이 계속 유지되는지를 먼저 정해야 합니다.


상태별 화면을 네 개로 나눠 본다

먼저 loading, empty, content, error를 네 개의 상태로 나눠 봅니다. 여기서 중요한 것은 네 화면이 완전히 다른 구조가 아니라는 점입니다. 대부분 toolbar나 상단 제목은 그대로 있고, 가운데 상태 영역만 달라집니다.

그래서 XML을 작성할 때도 상태별 View를 무작정 흩어 놓기보다, 같은 parent 안에서 고정 영역과 교체 영역을 나누는 편이 관리하기 쉽습니다.

ConstraintLayout 상태별 레이아웃 loading empty content error 비교 이미지
상태가 달라져도 같은 parent 안에서 기준선과 주요 영역이 흔들리지 않아야 합니다.

기준선을 먼저 고정한다

ConstraintLayout은 View의 위치를 제약 관계로 계산합니다. 공식 가이드에서도 각 View에는 최소 하나의 수평 제약과 하나의 수직 제약이 필요하다고 설명합니다. 상태별 레이아웃에서도 이 원칙은 그대로 유지됩니다.

따라서 상태별 UI를 만들 때는 먼저 기준선을 정해야 합니다. 예를 들어 toolbar 아래에는 contentArea가 있고, contentArea 안에서 loading, empty, content, error 중 하나가 보인다고 생각하면 구조가 단순해집니다.

ConstraintLayout 상태별 레이아웃 기준선 유지 비교 이미지
상태별 UI를 만들 때는 View를 어디에 놓을지보다 어떤 기준을 유지할지 먼저 정합니다.
<androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/rootLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/toolbarTitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <FrameLayout
        android:id="@+id/stateContainer"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@id/toolbarTitle"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

예시에서는 상태 영역을 설명하기 위해 stateContainer라는 기준 영역을 따로 두었습니다. 꼭 FrameLayout을 써야 한다는 뜻은 아닙니다. 실제 프로젝트에서는 상태 View를 ConstraintLayout의 직접 child로 두고 같은 top/bottom 기준을 공유해도 됩니다. 중요한 것은 상태가 바뀌는 영역과 고정되는 기준을 분리해서 읽을 수 있게 만드는 것입니다.


visibility만 바꾸면 되는 경우

loading, empty, content, error가 모두 같은 영역 안에서 바뀐다면 제약 자체를 매번 바꿀 필요가 없습니다. 이 경우에는 각 상태 View의 visibility를 바꾸거나, 여러 View를 묶어서 숨겨야 할 때 Group을 사용할 수 있습니다.

다만 Group은 배치를 만드는 컨테이너가 아닙니다. 14편에서 정리한 것처럼 Group은 참조한 View들의 visibility를 함께 다루는 helper로 이해해야 합니다.

ConstraintLayout 상태별 레이아웃 visibility와 제약 변경 구분 이미지
상태 변경이 단순 표시 전환인지, 제약 기준 변경까지 필요한지 먼저 나눠야 합니다.
<ProgressBar
    android:id="@+id/loadingView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toTopOf="@id/stateContainer"
    app:layout_constraintBottom_toBottomOf="@id/stateContainer"
    app:layout_constraintStart_toStartOf="@id/stateContainer"
    app:layout_constraintEnd_toEndOf="@id/stateContainer" />

<TextView
    android:id="@+id/emptyText"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="gone"
    app:layout_constraintTop_toTopOf="@id/stateContainer"
    app:layout_constraintBottom_toBottomOf="@id/stateContainer"
    app:layout_constraintStart_toStartOf="@id/stateContainer"
    app:layout_constraintEnd_toEndOf="@id/stateContainer" />

empty 상태처럼 텍스트와 버튼을 함께 켜고 끄는 경우에는 Group을 붙일 수 있습니다. Group에 제약을 기대하는 것이 아니라, 이미 각자 제약을 가진 View들을 한 번에 보이거나 숨기기 위한 용도입니다.

<androidx.constraintlayout.widget.Group
    android:id="@+id/emptyGroup"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="gone"
    app:constraint_referenced_ids="emptyText,stateActionButton" />

<androidx.constraintlayout.widget.Group
    android:id="@+id/errorGroup"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="gone"
    app:constraint_referenced_ids="errorText,retryButton" />

위 예시에서 emptyGrouperrorGroup은 각 상태의 View들을 한 번에 숨기기 위한 묶음입니다. 각 TextView와 Button의 위치 제약은 여전히 개별 View에 있어야 하고, Group 자체가 배치를 대신하지는 않습니다.

두 View는 같은 stateContainer 중앙을 기준으로 배치됩니다. 상태가 loading이면 ProgressBar를 보이고, empty이면 emptyText를 보이면 됩니다. 기준선이 같기 때문에 화면이 갑자기 위아래로 흔들릴 가능성이 줄어듭니다.


Kotlin에서는 상태 전환 함수를 작게 나눈다

상태 전환 코드는 길어지기 쉽습니다. 그래서 한 함수 안에서 모든 View를 임의로 건드리기보다, 현재 상태에 맞는 View만 명확하게 보이도록 정리하는 편이 좋습니다.

import androidx.core.view.isVisible

private fun renderLoading() {
    binding.loadingView.isVisible = true
    binding.emptyGroup.isVisible = false
    binding.contentList.isVisible = false
    binding.errorGroup.isVisible = false
}

private fun renderContent() {
    binding.loadingView.isVisible = false
    binding.emptyGroup.isVisible = false
    binding.contentList.isVisible = true
    binding.errorGroup.isVisible = false
}

이 예시는 상태 관리 구조를 설명하려는 코드가 아닙니다. 레이아웃 관점에서 어떤 View가 보이고 숨는지 한눈에 확인하기 위한 코드입니다. 실제 프로젝트에서는 sealed class나 UI state를 쓰더라도, 마지막에는 이런 표시 규칙으로 연결됩니다.


loading은 기준 영역 안에서 보여준다

loading을 만들 때 content 아래에 progress를 새로 밀어 넣으면 상태 전환 때 전체 화면 높이가 흔들릴 수 있습니다. 보통은 contentArea 또는 stateContainer 안에서 progress를 중앙에 두고, content가 준비되면 같은 영역 안에서 교체하는 편이 안정적입니다.

ConstraintLayout 상태별 레이아웃 loading 중앙 배치 이미지
loading은 전체 레이아웃 기준을 밀어내지 않고 상태 영역 안에서 표현하는 편이 안정적입니다.

이 방식은 특히 0dp 영역과 잘 맞습니다. stateContainer의 width와 height가 0dp라면 start/end, top/bottom 제약이 모두 있어야 하고, 그 안에 놓인 loading View도 명확한 기준을 가져야 합니다.


empty와 error는 같은 영역을 공유할 수 있다

empty와 error는 겉으로 비슷합니다. 둘 다 메시지와 버튼을 보여주는 경우가 많습니다. 하지만 의미는 다릅니다. empty는 데이터가 없는 상태이고, error는 데이터를 가져오지 못한 상태입니다.

레이아웃은 비슷하게 만들 수 있지만, 사용자에게 보여줄 문장과 버튼의 의미는 다르게 잡아야 합니다. empty는 검색 조건 변경이나 새 항목 만들기, error는 다시 시도 같은 행동으로 이어지는 경우가 많습니다.

ConstraintLayout 상태별 레이아웃 empty error 메시지 영역 비교 이미지
empty와 error는 같은 영역을 공유할 수 있지만, 사용자가 해야 할 다음 행동은 다릅니다.
<TextView
    android:id="@+id/stateMessage"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toTopOf="@id/stateContainer"
    app:layout_constraintBottom_toTopOf="@id/stateActionButton"
    app:layout_constraintStart_toStartOf="@id/stateContainer"
    app:layout_constraintEnd_toEndOf="@id/stateContainer" />

<Button
    android:id="@+id/stateActionButton"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toBottomOf="@id/stateMessage"
    app:layout_constraintStart_toStartOf="@id/stateContainer"
    app:layout_constraintEnd_toEndOf="@id/stateContainer" />

message와 button의 기준이 서로 이어져 있으면, empty 문장이든 error 문장이든 기본 구조를 유지할 수 있습니다. 다만 메시지가 길어질 가능성이 높다면 다음 섹션처럼 Barrier를 검토할 수 있습니다.


메시지 길이가 달라지면 Barrier를 검토한다

한국어와 영어 문장 길이는 다르고, 오류 메시지는 empty 문장보다 길어질 수 있습니다. 이때 다음 View를 특정 TextView의 고정 높이에 붙이면 언어나 상태에 따라 겹침이 생길 수 있습니다.

Barrier는 참조한 View들의 가장 바깥 위치를 기준으로 다른 View를 제약할 수 있는 helper입니다. 상태 메시지 길이가 달라질 때 버튼을 메시지 아래에 안정적으로 붙이는 데 도움이 됩니다.

ConstraintLayout 상태별 레이아웃 Barrier 메시지 길이 대응 이미지
상태 메시지 길이가 달라지면 Barrier가 다음 View의 기준선을 안정적으로 잡아줄 수 있습니다.
<androidx.constraintlayout.widget.Barrier
    android:id="@+id/messageBottomBarrier"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:barrierDirection="bottom"
    app:constraint_referenced_ids="stateMessage,stateSubMessage" />

<Button
    android:id="@+id/retryButton"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toBottomOf="@id/messageBottomBarrier"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent" />

Barrier 자체가 상태를 바꾸는 것은 아닙니다. 상태별 메시지 높이가 달라질 때, 그 아래 View가 어느 기준 아래에 붙어야 하는지 안정적으로 정해주는 역할입니다.


기준이 바뀌면 ConstraintSet을 쓴다

모든 상태가 visibility만으로 해결되지는 않습니다. 예를 들어 필터 패널이 열리면 contentList의 top 기준이 toolbar 아래가 아니라 filterPanel 아래로 바뀌어야 할 수 있습니다. 이때는 17편에서 다룬 ConstraintSet을 쓰면 의도가 분명해집니다.

import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isVisible

private fun renderFilteredContent() {
    binding.filterPanel.isVisible = true
    binding.contentList.isVisible = true

    val set = ConstraintSet()
    set.clone(binding.rootLayout)

    set.clear(R.id.contentList, ConstraintSet.TOP)
    set.connect(
        R.id.contentList,
        ConstraintSet.TOP,
        R.id.filterPanel,
        ConstraintSet.BOTTOM,
        dp(16)
    )

    set.applyTo(binding.rootLayout)
}

private fun dp(value: Int): Int {
    return (value * resources.displayMetrics.density).toInt()
}

기준이 이동하는 상태에서는 visibility와 constraint 변경을 분리해서 생각해야 합니다. panel을 보이게 하는 일과 contentList의 top 기준을 바꾸는 일은 서로 다른 책임입니다. 그리고 connect에 넘기는 margin은 px 값이므로, 예시처럼 dp(16)으로 변환해서 넘겨야 합니다.


나쁜 예: 상태마다 기준을 새로 만든다

나쁜 상태별 레이아웃은 loading, empty, error View가 모두 다른 기준에 붙어 있는 구조입니다. 처음에는 각 상태가 따로 잘 보일 수 있지만, 실제 데이터가 바뀌면 전환 시 위치가 튀거나 버튼이 겹치기 쉽습니다.

특히 content 영역이 0dp인데 상태 전환 과정에서 top이나 bottom 제약을 하나 지우면, 크기 계산이 의도와 달라질 수 있습니다. 4편과 5편에서 다룬 0dp 규칙은 상태별 레이아웃에서도 그대로 중요합니다.


좋은 예: 고정 영역과 상태 영역을 분리한다

좋은 구조는 고정되는 영역과 바뀌는 영역을 먼저 나눕니다. toolbar나 상단 안내는 고정하고, 그 아래 stateContainer 안에서 loading, empty, content, error를 교체합니다.

이렇게 하면 상태가 늘어나도 기준선이 크게 바뀌지 않습니다. 추가 상태가 생기면 새 View를 어느 영역에 넣을지만 정하면 되고, 전체 parent 제약을 다시 이해할 필요가 줄어듭니다.


상태별 레이아웃 디버깅 체크리스트

ConstraintLayout 상태별 레이아웃 디버깅 체크리스트 이미지
상태별 화면 문제는 표시 여부보다 기준선과 0dp 제약부터 확인하는 편이 빠릅니다.
  • 각 상태에서 보여야 할 View와 숨겨야 할 View가 명확한가.
  • loading, empty, content, error가 같은 기준 영역을 공유하는가.
  • 0dp View의 start/end 또는 top/bottom 제약이 상태 전환 후에도 남아 있는가.
  • Group을 컨테이너처럼 착각하지 않았는가.
  • Barrier가 필요한 곳과 단순 visibility로 충분한 곳을 구분했는가.
  • ConstraintSet을 쓴다면 clear/connect/applyTo 흐름이 명확한가.
  • empty와 error 버튼의 다음 행동이 서로 구분되는가.

한 번에 정리

  • ConstraintLayout 상태별 레이아웃은 상태별 화면을 독립적으로 만드는 일이 아니다.
  • 먼저 고정 기준선과 교체 영역을 나눈다.
  • 같은 자리에서 View만 바뀌면 visibility나 Group으로 처리할 수 있다.
  • 제약 기준 자체가 바뀌면 ConstraintSet을 검토한다.
  • 긴 메시지 아래에 버튼을 붙여야 한다면 Barrier가 도움이 될 수 있다.
  • 0dp 영역은 상태가 바뀌어도 양쪽 제약이 유지되어야 한다.

상태별 레이아웃의 핵심은 상태마다 새 화면을 만드는 것이 아니라, 같은 ConstraintLayout 안에서 흔들리지 않는 기준선을 유지하는 것입니다.


마무리

ConstraintLayout 상태별 레이아웃은 앱 상태 관리보다 한 단계 아래에 있는 배치 설계 문제입니다. loading, empty, content, error를 어떤 데이터 구조로 표현하든, 최종 화면에서는 View의 표시 여부와 기준선이 안정적으로 맞아야 합니다.

공식 기준은 Android Developers ConstraintLayout 가이드, AndroidX ConstraintLayout API reference, AndroidX Group API reference, AndroidX Barrier API reference, AndroidX ConstraintSet API reference를 확인했습니다. 다음 19편에서는 ConstraintLayout에서 View가 안 보이거나 0dp가 깨지는 문제를 디버깅하는 방법을 정리하겠습니다.

Android ConstraintLayout 완전 정리 이전 글 모음

Android ConstraintLayout 완전 정리 (1) – ConstraintLayout 기본 사용법: LinearLayout과 다른 제약 기반 배치 이해하기

Android ConstraintLayout 완전 정리 (2) – ConstraintLayout start end top bottom 제약 정리: View를 원하는 위치에 붙이는 법

Android ConstraintLayout 완전 정리 (3) – ConstraintLayout goneMargin과 margin 차이: View.GONE일 때 간격이 바뀌는 이유

Android ConstraintLayout 완전 정리 (4) – ConstraintLayout width height 정리: wrap_content, 0dp, match_constraint 차이

Android ConstraintLayout 완전 정리 (5) – ConstraintLayout match_constraint 완전 정리: 0dp가 남은 공간을 채우는 원리

Android ConstraintLayout 완전 정리 (6) – ConstraintLayout bias 사용법: horizontalBias와 verticalBias로 위치 조절하기

Android ConstraintLayout 완전 정리 (7) – ConstraintLayout baseline 정렬: TextView 글자 기준선을 맞추는 방법

Android ConstraintLayout 완전 정리 (8) – ConstraintLayout dimensionRatio 사용법: 1:1, 16:9 이미지 비율 맞추기

Android ConstraintLayout 완전 정리 (9) – ConstraintLayout percent min max 크기 정리: 화면 크기에 맞게 View 조절하기

Android ConstraintLayout 완전 정리 (10) – ConstraintLayout chain 사용법: spread, spread_inside, packed 차이

Android ConstraintLayout 완전 정리 (11) – ConstraintLayout chain weight와 bias: 같은 폭, 다른 비율 배치 만들기

Android ConstraintLayout 완전 정리 (12) – ConstraintLayout Guideline 사용법: percent 기준선으로 화면 나누기

Android ConstraintLayout 완전 정리 (13) – ConstraintLayout Barrier 사용법: 텍스트 길이에 따라 동적 기준선 만들기

Android ConstraintLayout 완전 정리 (14) – ConstraintLayout Group과 Placeholder: 여러 View 숨기기와 위치 바꾸기

Android ConstraintLayout 완전 정리 (15) – ConstraintLayout Flow 사용법: Chip과 태그를 자동 줄바꿈 배치하기

Android ConstraintLayout 완전 정리 (16) – ConstraintLayout Layer 사용법: 여러 View를 함께 이동, 회전, 확대하기

Android ConstraintLayout 완전 정리 (17) – ConstraintSet 사용법: 코드로 ConstraintLayout 제약 바꾸기

함께보면 좋은 글