Skip to content

✅ [기술 결정] 스켈레톤 UI로 사용성 개선

HahyunKang edited this page Dec 5, 2024 · 2 revisions

포링 앱은 사진을 메인으로 다루는 앱이기 때문에, 거의 모든 화면에서 coil 라이브러리를 사용해서 이미지를 띄운다. coil은 기본적으로 캐시 기능을 가지고 있기는 하지만, 처음 로드하는 이미지는 사진이 보여지는데 시간이 2~3초 정도 소요된다. 이로 인해 사용자 경험 향상을 위해 로딩 화면을 보여주고자 했다. 로딩 상태임을 보여주기 위해서 2가지 방법을 보통 사용한다.

  1. Circular Indicator

    • 단순한 그래픽 요소가 무한 반복되는 애니메이션이다.
    • 동적인 요소로 사용자가 지루함을 느끼지 않게 한다.
    • 스켈레톤 UI 보다 체감 로딩 시간이 길며, 로딩 시간이 길어지면 금방 지루해질 수 있다는 단점을 가진다.
  2. Skeleton UI

    • 스켈레톤 ui는 실제 데이터가 렌더링 되기 전, 보일 화면의 윤곽을 먼저 그려주는 로딩 애니메이션이다
    • 로딩이 완료되면 윤곽에 데이터가 대체되어 화면이 부드럽게 전환되기 때문에 체감 로딩 시간이 짧다는 장점이 있다.
    • 하지만 화면마다 새로운 스켈레톤 UI를 적용해야 하기 때문에 제작 비용이 많이 든다는 단점을 가진다.

이 두 가지의 선택지를 어떤 기준으로 적용할지 고려해보았다. 사용자의 입장에서 생각해보면, 보여지는 아이템의 개수가 명확하지 않은 화면에서 Skeleton UI를 적용한다면 오히려 사용자 경험을 해칠 수 있다고 느꼈다. 예를 들어 Skeleton UI를 5개 정도의 아이템 리스트를 예상하고 적용했는데 실제로 보여질 아이템이 없다면 skeleton UI가 갑자기 사라지는 것 처럼 보여지기 때문에 사용성이 떨어진다는 것을 느꼈다.

따라서 Skeleton UI의 기준은 다음과 같이 정했다.

  • 화면의 변동이 잦지 않고 예측 가능한가
  • 로딩이 짧게 느껴지는 중요한 화면인가

화면을 무한 스크롤해서 사진을 보는 기능은 앱의 핵심 기능이고, 하나의 화면에 하나의 게시물만이 보여지기 때문에 예측가능하기 때문에 Skeleton UI를 띄우기로 했다

팔로우한 사람들과 그들의 대표 사진 3개를 보여주는 화면과, 내가 작성한 게시물을 보여주는 화면은 아이템 개수가 예측 가능하지 않기 때문에 로딩 인디케이터를 사용하고자 했다.

Shimmer Effect 적용

Skeleton UI를 구현하기 위해 modifier의 확장함수를 만들었다.

무한 애니메이션 효과를 적용하기 위해 Transition 객체를 만들어 애니메이션 상태를 관리했다.

그라데이션의 시작과 끝 위치를 startOffsetX를 기준으로 설정하여 애니메이션 효과를 주었다. 다음과 같이 코드를 작성했다.

fun Modifier.shimmerEffect(): Modifier = composed {
    var size by remember {
        mutableStateOf(IntSize.Zero)
    }
    val transition = rememberInfiniteTransition()
    val startOffsetX by transition.animateFloat(
        initialValue = -2 * size.width.toFloat(),
        targetValue = 2 * size.width.toFloat(),
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000
            )
        ), label = ""
    )
    background(
        brush = Brush.linearGradient(
            colors = listOf(
                ShimmerLightGray,
                ShimmerDarkGray,
                ShimmerLightGray
            ),
            start = Offset(startOffsetX, 0f),
            end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
        )
    )
        .onGloballyPositioned {
            size = it.size
        }
}

실제 적용은 원하는 크기의 Box를 만들고 해당 Box의 modifier에 shimmerEffect를 적용하면 된다.

아래처럼 shimmerEffect를 적용해 로딩 중임을 나타낼 수 있었다.

   Box(modifier = if (!isLoading.value)
                Modifier
                    .fillMaxWidth()
                    .aspectRatio(4f / 5f)
            else Modifier
                .fillMaxWidth()
                .aspectRatio(4f / 5f)
                .shimmerEffect()
        ) {
            AsyncImage()
          }

사용성 개선

아래 gif 파일을 보면 로딩 시간이 짧지 않은 경우에는 Shimmering Effect가 잠깐 생겼다가 사라져서 깜빡이는 것처럼 보인다. 이는 사용자들에 피로감을 제공할 것이라 생각했고, 이를 위해 몇 가지 방법을 생각해보았다.

gif_1

  1. VerticalPager의 beyondViewportPageCount 속성을 크게 잡기
  • VerticalPager에는 미리 페이지를 불러올 수 있는데, 불러올 페이지 수를 조절할 수 있다. 이 count 속성을 크게 잡으면 로딩 중인 화면이 거의 보이지 않겠지만, 한 번에 많은 composition이 일어나는 것은 성능이 저하될 가능성이 있다.
  1. delay로 로딩 시간 조절
  • delay를 두어서 보여지는 로딩 시간을 늘리는 방법이다.
  • coil에서 캐시되지 않은 이미지라면 평균적으로 2초의 시간이 걸린다. 깜빡이는 현상이 발생하는 원인을 생각해 보면 Vertical Pager는 페이지를 미리 불러오는데 1번 페이지가 보여질 때 4번 페이지는 로드를 시작한다. 한 페이지 당 0.5초의 속도로 넘긴다고 생각하면 4번 페이지가 실제로 보여지는 순간에는 (2-0.5*3) = 0.5초 정도 보여지고 사라짐을 추측할 수 있다.
  • beyondViewportPageCount 를 default로 3으로 지정해두었는데, 엄청 빠르게 넘겼을 때 평균 0.6초 넘긴다고 했을 때, delay를 사용해 2초로 로딩 시간을 조절하면 (로딩되는 마지막 사진 기준) 약 1초(4-1*3) 정도의 shimmer effect를 보여줄 수 있도록 할 수 있기 때문에 사용성이 개선될 수 있다고 생각했다.

첫 번째 방법은 성능 상으로 크게 저하될 가능성이 있고 불필요한 리소스가 사용될 수 있기에, 두 번째 방법을 택하기로 했다.

이미지 로드가 성공했을 때(onSuccess), delay를 주어서 보여지는 로딩시간을 늘렸다.

AsyncImage에서 제공하는 onSuccess에서는 suspend 함수는 delay함수를 적용할 수 없기 때문에
rememberCoroutineScope 을 사용해서 coroutine을 통해 이미지 로딩을 지연시켰다.

    var isLoading by remember { mutableStateOf(true) }
    var isError by remember { mutableStateOf(false) }
    val coroutineScope = rememberCoroutineScope()
         
            AsyncImage(
                modifier = Modifier
                    .fillMaxSize()
                    .combinedClickable(
                        indication = null,
                        interactionSource = interactionSource,
                        onClick = onClick,
                        onLongClick = onLongClick,
                        onDoubleClick = onDoubleClick
                    ),
                model = imageUrl,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                onLoading = {
                    isLoading = true
                    isError = false
                },
                onSuccess = {
                    coroutineScope.launch {
                        delay(delay)
                        isLoading = false
                        isError = false
                    }
                },
                onError = {
                    isLoading = false
                    isError = true
                }
            )

아래 영상을 보면 깜빡이는 현상이 줄어든 결과를 볼 수 있다.

default.mp4
Clone this wiki locally