[vue2] 리스트 스켈레톤 UI 적용 과정

빛트·2022년 11월 26일
0
post-thumbnail

불편함

최근에 아래와 같은 컴포넌트를 수정할 일이 있었습니다

하나의 리스트는 탭을 선택할 때마다 목록을 비운 뒤에 api 호출을 통해 새로운 목록을 가져옵니다

이 때 리스트의 크기가 변화하면서 부자연스럽게 동작했습니다

부자연스러움을 해결하는 것이 일의 목적은 아니었지만,

그냥 보기 싫어서 어떻게 해결하면 좋을지 생각해 보게 됐습니다


생각의 흐름

고려사항

가장 쉽게 생각할 수 있는 것은 Api 호출 전 목록을 비우지 않는 것입니다

현재는 리스트의 높이가 x -> 0 -> y 로 변하지만,

목록을 비우지 않은 상태에서 다음 목록을 바로 로드한다면 높이의 변화는 x -> y 로 한단계 줄어듭니다

하지만 그렇게 되면 사용자는 해당 리스트가 로딩중인지 아닌지 알 수 없게 됩니다

그래서 처음 생각한 것은 목록을 비우지 않은 상태로 로딩 상태를 전달할 수 있도록 하는 것이었습니다

첫번째 아이디어

Progress Layer로 전체 화면을 덮는 것이었습니다

서비스 내부에서 대부분의 Data Fetch를 그렇게 처리하기도 했고, 가장 간편했습니다

그러나 목록을 빠르게 가져왔을 때 Progress Layer가 순식간에 사라지며 화면이 깜빡이는 것처럼 보였습니다

일정 시간동안은 Layer가 유지되도록 할 수도 있겠지만, 이 외에도 문제가 더 있었습니다

Layer에 접근하는 코드가 전체 소스 여기저기 흩어져 있어서 정확한 동작을 보장할 수도 없으며

탭을 변경하지 않은 다른 컴포넌트에 영향이 있다는 점에서 고려사항에서 제외했습니다

두번째 아이디어

각 컴포넌트 내부에서 Progress Bar를 표현하는 것이었습니다.

그러나 서비스 내에 이렇게 표현하는 화면이 전혀 없어서 이질적인 느낌이 들었습니다

디자인 협의 없이 독단적으로 결정할 수도 없었기에 다른 방안이 필요했습니다

세번째 아이디어

로딩중인 화면의 크기를 0이 아닌 값으로 고정하고자 했습니다

no-data 화면과 로딩중인 화면의 높이가 같아진다면

no-data 에서 출발하거나 no-data 가 되는 경우에는 훨씬 자연스럽게 보일 것 같았습니다

데이터가 있는 경우에도 30 -> 0 -> 80 의 변화보다는 30 -> 50 -> 80 의 변화가 더 자연스러울거라고 생각했습니다

( 지금 보면 터무니없는 생각입니다

첫번째 탭의 리스트 높이도 10이고 두번째 탭의 리스트 높이도 10이라고 했을 때

10 -> 50 -> 10 보다는 10 -> 0 -> 10의 변화가 더 자연스러울 것 같습니다 )

네번째 아이디어

이왕이면 스켈레톤 아이템을 이용해서 로딩중이라는 티도 조금 더 내고

화면이 심심하지 않게 보이도록 하고 싶었습니다


구현

1. 스켈레톤 아이템 생성

다음과 같이 실제 아이템과 같은 모양의 스켈레톤 아이템을 만들었습니다

각각의 리스트 컴포넌트는 다음과 같이 구성했습니다

<template>
  <div>
    <h2>{{ title }}</h2>
    <tag-menu
      :menus="menus"
      @onChangeMenu="handleChageMenu"
    ></tag-menu>

    <ul v-show="list.length === 0" class="list-container">
      <skeleton-item v-for="idx in 5" :key="idx"></skeleton-item>
    </ul>
    <ul v-show="list.length > 0" class="list-container">
      <list-item
        v-for="(item, idx) in list"
        :key="idx"
        :item="item"
      ></list-item>
    </ul>
  </div>
</template>
<script>
export default {
  ..., // 생략
  props: {
    title: {
      type: String,
      default: "",
    },
    menus: {
      type: Array,
      default: () => [],
    },
    totalList: {
      type: Array,
      default: () => [],
    },
  },
  
  data(){
    return {
      list: [],
    }
  },
  
  methods: {
    handleChageMenu(idx) {
      this.list = [];

      setTimeout(() => { // 로딩 딜레이는 타임아웃으로으로 대체했습니다 
        this.list = this.totalList[idx];
      }, 500);
    },
  },
}
</script>

조금 덜 부자연스러운 것 같고, 로딩중이구나 하는 느낌도 드시나요?

하지만, 아이템의 갯수에 따라서 여전히 부자연스럽다고 느꼈습니다

2. 변경 전 리스트 갯수를 활용

vue의 watch속성을 이용하면 변경 전의 리스트 갯수를 알 수 있고,

이것을 이용해 높이의 변화를 줄일 수 있을 것 같았습니다

<template>
  <div>
    <h2>{{ title }}</h2>
    <tag-menu
      :menus="menus"
      @onChangeMenu="handleChageMenu"
    ></tag-menu>

    <ul v-show="list.length === 0" class="list-container">
      <skeleton-item v-for="idx in skeletonSize" :key="idx"></skeleton-item>
    </ul>
    <ul v-show="list.length > 0" class="list-container">
      <list-item
        v-for="(item, idx) in list"
        :key="idx"
        :item="item"
      ></list-item>
    </ul>
  </div>
</template>
<script>
export default {
  ..., // 생략
  data(){
    return {
      list: [],
      skeletonSize: 5
    }
  },
  watch: {
    list(curr, prev){
      this.skeletonSize = prev.length;
    }
  },
  methods: {
    handleChageMenu(idx) {
      this.list = [];

      setTimeout(() => {
        this.list = this.totalList[idx];
      }, 500);
    },
  },
}
</script>

조금 더 나아졌나요?

3. NoItem 처리

만약 서버에서 가져온 리스트가 비어있다면, 스켈레톤 아이템이 노출될겁니다.

로딩 상태와 데이터가 없는 상태의 구분이 필요하겠네요

<template>
  <div>
    <h2>{{ title }}</h2>
    <tag-menu
      :menus="menus"
      @onChangeMenu="handleChageMenu"
    ></tag-menu>

	<ul v-show="isShowSkeletonItems" class="list-container">
      <skeleton-item v-for="idx in skeletonSize" :key="idx"></skeleton-item>
    </ul>
    <ul v-show="isShowNoItem" class="list-container">
      <h1>NO-ITEM</h1>
    </ul>
    <ul v-show="isShowItems" class="list-container">
      <list-item
        v-for="(item, idx) in list"
        :key="idx"
        :item="item"
      ></list-item>
    </ul>
    
  </div>
</template>
<script>
export default {
  ..., // 생략
  data(){
    return {
      list: null,
      skeletonSize: 5
    }
  },
  
  computed: {
    isShowSkeletonItems() {
      return this.list === null;
    },
    isShowNoItem() {
      return Array.isArray(this.list) && this.list.length === 0;
    },
    isShowItems() {
      return !this.isShowSkeletonItems && !this.isShowNoItem;
    },
  },
  
  watch: {
    list(curr, prev){
      if (!Array.isArray(prev)) {
        this.skeletonSize = 5;
        return;
      }

      if (prev.length === 0) {
        this.skeletonSize = 1;
      } else {
        this.skeletonSize = prev.length;
      }
    }
  },
  methods: {
    handleChageMenu(idx) {
      this.list = null;

      setTimeout(() => {
        this.list = this.totalList[idx];
      }, 500);
    },
  },
}
</script>

대단한건 아니지만

대단한 기술도 아니고 대단한 발상도 아니지만

어떤 사고의 과정을 통해 결과물을 도출했는지 기록하고 공유하고 싶었습니다


전체 코드

0개의 댓글