[Android] 스켈레톤 로딩 효과 적용해보기(feat. xml 과 Compose)

앱을 사용하다 보면 서버에서 데이터를 받아오는 과정에서 로딩 프로세스바나 리스트 모양이 반짝거리는 등의 효과를 볼 수 있습니다. 

이러한 효과들은 사용자에게 데이터를 받아오는 중이라는 것을 명시할 수 있고,

로딩 애니메이션에는 스켈레톤 UI, 루프 애니메이션, 프로그래스 바 등 다양한 방법이 있습니다.

그중에서 오늘은 스켈레톤 로딩을 구현하는 방법에 대해 공부해보려고 합니다.!

(Compose 또한 어떻게 적용하는지 궁금해서 xml과 Compose 모두 다뤄보려고 합니다.)

 


XML

스켈레톤 UI를 구현하는 방법은 다양하지만, 가장 많이 사용되는 facebook에서 제공하는 라이브러리인 shimmer-android를 사용해보도록 하겠습니다.

 

우선 build.gradle에 라이브러리를 추가합니다.

// Gradle dependency on Shimmer for Android
dependencies {
  implementation 'com.facebook.shimmer:shimmer:0.5.0'
}

 

이제 사용할 준비가 되었으니 Recyclerview를 활용하여 구현해보도록 하겠습니다.

먼저, Recyclerview에 각 item으로 들어갈 layout을 작성합니다. 저는 아래 사진처럼 이미지, 타이틀, 내용이 들어가는 item을 구성하였습니다.

이렇게 Recyclerview에 들어갈 item_layout을 만들어 준 다음에는 같은 구성의 item_shimmer_layout를 만들어 줍니다.

즉, 로딩이 되는 동안 나타날 레이아웃을 구성해주는 것입니다.

다음은 item_shimmer_layout 입니다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="80dp"
            android:layout_height="80dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="16dp"
            android:background="#B3B3B3"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:srcCompat="@tools:sample/avatars" />

        <TextView
            android:id="@+id/title"
            android:layout_width="100dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:textColor="#212121"
            android:background="#B3B3B3"
            android:textSize="14sp"
            android:textStyle="bold"
            app:layout_constraintBottom_toTopOf="@id/content"
            app:layout_constraintStart_toEndOf="@id/imageView"
            app:layout_constraintTop_toTopOf="@id/imageView" />

        <TextView
            android:id="@+id/content"
            android:layout_width="150dp"
            android:layout_height="wrap_content"
            android:textColor="#212121"
            android:textSize="12sp"
            android:background="#B3B3B3"
            app:layout_constraintBottom_toBottomOf="@id/imageView"
            app:layout_constraintStart_toStartOf="@id/title"
            app:layout_constraintTop_toBottomOf="@id/title" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

Recyclerview에 들어갈 item_layout과 동일한 구성을 가지고 있습니다. 다만, shimmer을 사용할 때 각 view에 배경색을 무색이 아닌 유색으로 지정해주어야 보입니다. 저는 #B3B3B3 색을 넣어서 연한 그레이를 표현해주었습니다.

 

 

그럼 이제 activity_main.xml에 Shimmer.FrameLayout과 Recyclerview를 사용하여 xml를 작성해보도록 하겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".MainActivity">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/textView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:text="Shimmer-Android"
            android:textColor="#212121"
            android:textSize="20sp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <com.facebook.shimmer.ShimmerFrameLayout
            android:id="@+id/shimmer_layout"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="@+id/textView"
            app:layout_constraintTop_toBottomOf="@+id/textView">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <include layout="@layout/item_model_shimmer" />

                <include layout="@layout/item_model_shimmer" />

                <include layout="@layout/item_model_shimmer" />

                <include layout="@layout/item_model_shimmer" />

            </LinearLayout>


        </com.facebook.shimmer.ShimmerFrameLayout>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerview_layout"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            tools:listitem="@layout/item_model"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="@+id/textView"
            app:layout_constraintTop_toBottomOf="@+id/textView"/>

    </androidx.constraintlayout.widget.ConstraintLayout>


</layout>

activity_main.xml은 위와 같이 작성합니다. ShimmerFrameLayout을 사용하고 그 안에는 앞에서 만들어주었던 item_shimmer_layout인 즉, 스켈레톤 효과를 주기 위해 만들었던 레이아웃을 여러 개 넣어줍니다. 그 후, ShimmerFrameLayout 위에 실제로 데이터를 표시하고자 하는 Recyclerview를 올려주면 됩니다.  이렇게 ShimmerFrameLayout위에 Recyclerview를 올리는 이유는 데이터가 로딩되는 동안 visible과 gone을 이용하여 두 개의 view를 조절할 예정이기 때문입니다. 

 

저는 ShimmerFrameLayout에 대략적으로 4개 정도의 레이아웃을 넣어주었는데요, API로부터 데이터를 뿌려주는 경우 개수를 예측할 수 없다면 shimmer 레이아웃의 갯수를 어떻게 적절하게 넣어야 할지는 조금 더 고민이 필요할 것 같습니다!

 

 

그럼 이제 MainActivity와 Recyclerview의 Adpater를 작성하여 완성해보도록 하겠습니다. 

 

다음은 작성한 MainActivity.kt 입니다.

class MainActivity : AppCompatActivity() {
    lateinit var  binding: ActivityMainBinding
    private val travelAdapter : TravelAdapter by lazy {
        TravelAdapter()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setAdapter()
        loadTravelData()
    }

    private fun setAdapter() {

        binding.recyclerviewLayout.apply {
            layoutManager =
                LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
            adapter = travelAdapter
        }
    }

    private fun loadTravelData() {
        lifecycleScope.launch {
            showTravelData(isLoading = true)
            delay(3000)

            val dataList = getTravelInfo()
            travelAdapter.submitList(dataList)

            showTravelData(isLoading = false)
        }
    }

    private fun showTravelData(isLoading: Boolean) {
        if (isLoading) {
            binding.shimmerLayout.startShimmer()
            binding.shimmerLayout.visibility = View.VISIBLE
            binding.recyclerviewLayout.visibility = View.GONE
        } else {
            binding.shimmerLayout.stopShimmer()
            binding.shimmerLayout.visibility = View.GONE
            binding.recyclerviewLayout.visibility = View.VISIBLE
        }
    }
}

해당 코드에서는 loadTravelData()와 showTravelData()만 참고하면 되는데요, 데이터가 불러지는 로딩 시간 동안 showTravelData() 함수를 호출하여 ShimmerLayout를 Visible 하게 해 주고 로딩이 완료된 후에 다시 함수를 호출하여 recyclerview를 Visible 해주면 됩니다. showTravelData()를 자세히 보면 startShimmer()과 stopShimmer() 메소드를 사용하고 이 두 개의 메소드는 애니메이션을 실행할 수 있도록 합니다. Recyclerview는 평소와 다를 게 없어서 코드는 생략하도록 하겠습니다.

 

완성된 스켈레톤 애니메이션!

 

실습해보니 Visible를 이용하여 아주 간단하게 구현할 수 있었고 스켈레톤을 이용하니 사용자 입장에서도 더 눈에 띄는 UI 효과를 주는 것 같다고 느꼈습니다. :) 

 

🧐 그렇다면  Compose에서는 스켈레톤 효과를 어떻게 구현할 수 있을까요?

 

Jetpack Compose

먼저 로딩 효과를 그려줄 컴포저블을 만듭니다. 해당 컴포즈에는 그라데이션, 애니메이션  효과 등을 지정해줍니다.

@Composable
fun LoadingShimmerEffect() {
    val shimmerColors = listOf(
        Color.LightGray.copy(alpha = 0.6f),
        Color.LightGray.copy(alpha = 0.2f),
        Color.LightGray.copy(alpha = 0.6f),
    )

    val transition = rememberInfiniteTransition()
    val translateAnim = transition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000,
                easing = FastOutSlowInEasing
            ),
            repeatMode = RepeatMode.Reverse
        )
    )

    val brush = Brush.linearGradient(
        colors = shimmerColors,
        start = Offset.Zero,
        end = Offset(x = translateAnim.value, y = translateAnim.value)
    )

    ShimmerGridItem(brush = brush)
}

먼저, 스켈레톤에 넣어 줄 그라데이션 색상들을 리스트로 지정합니다. 그다음은 애니메이션 효과를 줄 수 있는 rememberInfiniteTransition()을 지정합니다. 컴포즈는 Brush를 이용해 background에 그라데이션을 세팅할 수 있기 때문에 Brush를 이용하여 애니메이션과 설정해준 그라데이션을 지정합니다. 

 

그다음은, xml에서 Recyclerview였던 Row를 구성하는 ShimmerGrideItem을 작성해보도록 하겠습니다.

@Composable
fun ShimmerGridItem(brush: Brush) {


    Row(modifier = Modifier
        .fillMaxWidth()
        .padding(all = 10.dp), verticalAlignment = Alignment.CenterVertically) {

        Spacer(modifier = Modifier
            .size(80.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(brush)
        )
        Spacer(modifier = Modifier.width(10.dp))
        Column(verticalArrangement = Arrangement.Center) {
            Spacer(modifier = Modifier
                .height(20.dp)
                .clip(RoundedCornerShape(10.dp))
                .fillMaxWidth(fraction = 0.5f)
                .background(brush)
            )

            Spacer(modifier = Modifier.height(10.dp)) //creates an empty space between
            Spacer(modifier = Modifier
                .height(20.dp)
                .clip(RoundedCornerShape(10.dp))
                .fillMaxWidth(fraction = 0.7f)
                .background(brush)
            )
        }
    }
}

저는 위 예제와 같이 사진과 텍스트를 넣을 수 있도록 아이템을 구성하였습니다.

Preview로 보았을 때 위에서 만들어준 Brush가 적용된 Column들을 확인할 수 있습니다.

이렇게 스켈레톤 효과를 주기 위한 준비를 한 후에는 바로 뷰에 뿌려주면 됩니다. MainActivity에서 다수의 Column을 생성하고 애니메이션도 잘 작동하는지 확인해보도록 하겠습니다.

 

 setContent {
 	AnimatedShimmerEffectTheme {
       Column {
          repeat(7) {
           LoadingShimmerEffect()
           }
         }
  }

Theme.kt에서 다크 모드와 라이트 모드 테마를 지정해준 후 7개의 셀을 구성하는 ShimmerGrideItem을 호출해주었습니다.

그 후 동작하면, 다음과 같이 생성된 것을 확인할 수 있습니다.

 

앞서 xml에서 또한 ShimmerFrameLayout과 Recyclerview를 겹쳐둔 것처럼 Compose에서도 ShimmerEffect를 만든 후, LazyColumn과 스켈레톤을 상황에 맞게 뷰를 보여준다면 쉽게 구현할 수 있을 것 같습니다.

 

 

💨 마무리

생각보다 간단하지만 알아두면 유용하고 좋은 UI인 것 같습니다. Compose에서 효과를 구성하는 방법도 알았으니 컴포즈에서의 구현도 해보도록 하겠습니다~!

 

 

참고문서

https://velmurugan-murugesan.medium.com/shimmer-effect-for-android-recyclerview-example-a9315b46cdc0