[Android / Kotlin] shimmer library를 사용하여 스켈레톤 로딩 화면 만들기

Subeen·2024년 2월 17일
1

Android

목록 보기
58/71

Youtube Data Api를 사용하여 미디어 앱을 만드는 중 Api를 호출하고 데이터를 수신 하여 RecyclerView에 보여주기까지 몇 초의 시간이 걸렸다. 사용자의 입장에서 생각했을 때 아무런 반응이 없고 빈 화면만 보인다면 앱이 무슨 상태인지 알 수 없을거라 생각했다.
데이터를 불러오는 중에 로딩 화면을 만들어야겠다고 생각했고 Facebook에서 제공하는 shimmer library를 사용하여 스켈레톤 로딩 화면을 만들었다.
스켈레톤 로딩 화면은 표시될 정보의 대략적인 형태를 미리 보여줘서 다음 화면까지 부드럽게 연결해주는 역할을 한다.

결과 화면

dependencies

  • shimmer-android 라이브러리를 추가해준다.
dependencies {
	...
	// shimmer
	implementation("com.facebook.shimmer:shimmer:0.5.0")
}

item layout

RecyclerView에서 아이템을 그려줄 layout을 작성한다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="200dp"
    android:layout_height="wrap_content"
    android:layout_marginEnd="20dp">

    <ImageView
        android:id="@+id/iv_list_thumbnail"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:adjustViewBounds="true"
        android:background="@drawable/background_radius_round"
        android:scaleType="centerCrop"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_list_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:ellipsize="end"
        android:lineSpacingExtra="4dp"
        android:maxLines="2"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/iv_list_thumbnail" />

    <TextView
        android:id="@+id/tv_list_view_count"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:textSize="11sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_list_title" />

    <TextView
        android:id="@+id/tv_list_date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        android:textSize="11sp"
        app:layout_constraintBottom_toBottomOf="@id/tv_list_view_count"
        app:layout_constraintStart_toEndOf="@id/tv_list_view_count"
        app:layout_constraintTop_toTopOf="@id/tv_list_view_count" />

</androidx.constraintlayout.widget.ConstraintLayout>

item_shimmer_list.xml

스켈레톤 로딩 화면에서 보여줄 layout을 작성한다.
위에서 작성한 아이템의 기본 구조와 유사하게 작성하며 백그라운드 색상은 흰색이 아닌 유색으로 지정해줘야 반짝이는 효과를 나타낼 수 있다.

  • @color/background : #ddddd
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="200dp"
    android:layout_height="wrap_content"
    android:layout_margin="8dp">

    <ImageView
        android:id="@+id/iv_video_thumbnail"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:adjustViewBounds="true"
        android:background="@color/background"
        android:scaleType="centerCrop"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_channel_list_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:background="@color/background"
        android:ellipsize="end"
        android:maxLines="2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/iv_video_thumbnail" />

    <TextView
        android:id="@+id/tv_channel_view_count"
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:background="@color/background"
        android:textColor="@color/grey"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_channel_list_title" />

    <TextView
        android:id="@+id/tv_channel_list_date"
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:background="@color/background"
        android:textColor="@color/grey"
        app:layout_constraintBottom_toBottomOf="@id/tv_channel_view_count"
        app:layout_constraintStart_toEndOf="@id/tv_channel_view_count"
        app:layout_constraintTop_toTopOf="@id/tv_channel_view_count" />

</androidx.constraintlayout.widget.ConstraintLayout>

fragment layout

ShimmerFrameLayout을 사용하여 스켈레톤 로딩 화면을 만들어 준다.
ShimmerFrameLayout으로 View를 감싸면 감싸진 View에 Shimmer Effect를 적용할 수 있다.
RecyclerView의 아이템와 유사한 빈 레이아웃을 표현하기 위해 item_shimmer_list를 여러 개 추가해준다.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    tools:context=".ui.datail.VideoDetailFragment">


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

        <com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView
            android:id="@+id/youtube_player_view"
            android:layout_width="match_parent"
            android:layout_height="230dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ScrollView
            android:id="@+id/scroll_view"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/youtube_player_view">

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

                <TextView
                    android:id="@+id/tv_video_title"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:lineSpacingExtra="4dp"
                    android:textSize="18sp"
                    android:textStyle="bold"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

                <ImageView
                    android:id="@+id/iv_channel_thumbnail"
                    android:layout_width="40dp"
                    android:layout_height="40dp"
                    android:layout_marginTop="8dp"
                    android:adjustViewBounds="true"
                    android:background="@drawable/background_shape_circle"
                    android:scaleType="centerCrop"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/tv_video_title" />

                <TextView
                    android:id="@+id/tv_channel_title"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="8dp"
                    android:textSize="14sp"
                    android:textStyle="bold"
                    app:layout_constraintBottom_toTopOf="@id/tv_channel_subscription_count"
                    app:layout_constraintStart_toEndOf="@id/iv_channel_thumbnail"
                    app:layout_constraintTop_toTopOf="@id/iv_channel_thumbnail" />

                <TextView
                    android:id="@+id/tv_channel_subscription_count"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="8dp"
                    android:textColor="@color/grey"
                    android:textSize="13sp"
                    app:layout_constraintBottom_toBottomOf="@id/iv_channel_thumbnail"
                    app:layout_constraintStart_toEndOf="@id/iv_channel_thumbnail"
                    app:layout_constraintTop_toBottomOf="@id/tv_channel_title" />

                <ImageView
                    android:id="@+id/iv_share"
                    android:layout_width="28dp"
                    android:layout_height="28dp"
                    android:layout_marginEnd="16dp"
                    android:src="@drawable/ic_share_24"
                    app:layout_constraintBottom_toBottomOf="@id/iv_channel_thumbnail"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintTop_toTopOf="@id/iv_channel_thumbnail" />

                <ImageView
                    android:id="@+id/iv_like"
                    android:layout_width="28dp"
                    android:layout_height="28dp"
                    android:layout_marginEnd="20dp"
                    android:src="@drawable/ic_like_empty_24"
                    app:layout_constraintBottom_toBottomOf="@id/iv_channel_thumbnail"
                    app:layout_constraintEnd_toStartOf="@id/iv_share"
                    app:layout_constraintTop_toTopOf="@id/iv_channel_thumbnail" />

                <kr.co.prnd.readmore.ReadMoreTextView
                    android:id="@+id/tv_video_description"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="8dp"
                    android:background="@drawable/background_radius_round"
                    android:lineSpacingExtra="4dp"
                    android:padding="8dp"
                    android:textSize="13sp"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/iv_channel_thumbnail"
                    app:readMoreColor="@android:color/black"
                    app:readMoreMaxLine="3"
                    app:readMoreText=" ...더보기" />

                <TextView
                    android:id="@+id/tv_other_videos_title"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="16dp"
                    android:layout_marginBottom="20dp"
                    android:textSize="18sp"
                    android:textStyle="bold"
                    app:layout_constraintBottom_toTopOf="@+id/constraint_layout"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/tv_video_description" />

                <androidx.constraintlayout.widget.ConstraintLayout
                    android:id="@+id/constraint_layout"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/tv_other_videos_title">

                    <androidx.recyclerview.widget.RecyclerView
                        android:id="@+id/channel_recycler_view"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:orientation="horizontal"
                        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent"
                        tools:listitem="@layout/item_channel_other_video_list" />

                    <com.facebook.shimmer.ShimmerFrameLayout
                        android:id="@+id/shimmer_frame_layout"
                        android:layout_width="match_parent"
                        android:layout_height="0dp"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent">

                        <LinearLayout
                            android:layout_width="match_parent"
                            android:layout_height="match_parent"
                            android:orientation="horizontal">

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

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

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

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

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

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

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

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

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

                            <include layout="@layout/item_shimmer_list" />
                        </LinearLayout>
                    </com.facebook.shimmer.ShimmerFrameLayout>

                </androidx.constraintlayout.widget.ConstraintLayout>

            </androidx.constraintlayout.widget.ConstraintLayout>


        </ScrollView>

    </androidx.constraintlayout.widget.ConstraintLayout>

</FrameLayout>

Fragment

class VideoDetailFragment : Fragment() {
    private var _binding: FragmentVideoDetailBinding? = null
    private val binding: FragmentVideoDetailBinding get() = _binding!!
    private val args by navArgs<VideoDetailFragmentArgs>()
    private val sharedViewModel by activityViewModels<SharedViewModel> {
        SharedViewModelFactory(YoutubeRepositoryImpl(VideoSearchDatabase.getInstance(requireContext())))
    }

    private val viewModel: DetailViewModel by viewModels {
        DetailViewModelFactory(
            YoutubeRepositoryImpl(VideoSearchDatabase.getInstance(requireContext())),
            args.homeToDetailEntity
        )
    }

    private val channelListAdapter by lazy {
        ChannelOtherVideoListAdapter(
            onItemClick = { item ->
                val action = VideoDetailFragmentDirections.actionVideoDetailToVideoDetail(item)
                findNavController().navigate(action)
            }
        )
    }
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentVideoDetailBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initView()
        initViewModel()
    }

    private fun initView() {
        binding.channelRecyclerView.adapter = channelListAdapter
    }

    private fun initViewModel() {
        with(viewModel) {
			...

            uiChannelVideoState.observe(viewLifecycleOwner) {
                channelListAdapter.submitList(it)
            }

            loadingState.observe(viewLifecycleOwner) { loadingState ->
               /*
                * loadingState.isLoading이 true라면 데이터 로딩을 시작했다는 것이고 startShimmer() 메서드를 호출하여 shimmer effect를 적용한다. 
                * 데이터 로딩이 완료되어 effect를 해제하려면 stopShimmer()를 호출한다. 
                */
                if (loadingState.isLoading) {
                    binding.shimmerFrameLayout.startShimmer()
                } else {
                    binding.shimmerFrameLayout.stopShimmer()
                }

				/* 
                 * shimmer effect가 적용 되어 있을 때는 shimmerFrameLayout을 visible, 로딩 된 아이템을 보여주기 위한 channelRecyclerView는 gone으로 변경한다. 
                 * 적용이 해제 되면 channelRecyclerView를 visible, shimmerFrameLayout을 gone으로 변경한다. 
                 */
                binding.shimmerFrameLayout.visibility = loadingState.shimmerVisibility
                binding.channelRecyclerView.visibility = loadingState.recyclerViewVisibility
            }
        }
    }


    override fun onDestroyView() {
        _binding = null
        super.onDestroyView()
    }

}

ViewModel

data class LoadingState(
    val isLoading: Boolean,
    val shimmerVisibility: Int,
    val recyclerViewVisibility: Int
) {
    companion object {
        fun loading(): LoadingState {
            return LoadingState(true, View.VISIBLE, View.GONE)
        }

        fun loaded(): LoadingState {
            return LoadingState(false, View.GONE, View.VISIBLE)
        }
    }
}

class DetailViewModel(
    private val youtubeRepositoryImpl: YoutubeRepositoryImpl,
    private val entity: VideoBasicModel
) : BasicDetailViewModel() {
    private val _uiChannelVideoState: MutableLiveData<List<VideoBasicModel>> = MutableLiveData()
    val uiChannelVideoState: LiveData<List<VideoBasicModel>> get() = _uiChannelVideoState

    private val _loadingState: MutableLiveData<LoadingState> = MutableLiveData(LoadingState.loaded())
    val loadingState: LiveData<LoadingState> get() = _loadingState

    init {
        searchChannelVideos()
    }

    private fun searchChannelVideos() = viewModelScope.launch {
        if (entity.channelId == null) {
            return@launch
        }

        kotlin.runCatching {
        	// 데이터 로딩이 시작 되므로 loadingState를 loading()으로 변경한다. 
            _loadingState.value = LoadingState.loading()
            val videos = youtubeRepositoryImpl.getChannelVideos(entity.channelId)
            val videoItemModels = videos.items
                .mapNotNull { item ->
                    val videoId = item.id.videoId ?: item.id.kind
                    if (!isPlaylistOrChannel(videoId)) {
                        val channelInfoList =
                            getChannelInfo(item.snippet.channelId, youtubeRepositoryImpl)
                        val videoViewCountList =
                            getVideoViewCount(videoId, youtubeRepositoryImpl)
                        val videoViewCountModel = videoViewCountList.firstOrNull()
                        val channelInfoModel = channelInfoList.firstOrNull()

                        VideoBasicModel(
                            id = videoId,
                            timestamp = System.currentTimeMillis(),
                            thumbNailUrl = item.snippet.thumbnails.medium.url,
                            channelId = item.snippet.channelId,
                            channelTitle = item.snippet.channelTitle,
                            title = item.snippet.title,
                            description = item.snippet.description,
                            publishTime = item.snippet.publishedAt,
                            channelInfoModel = channelInfoModel,
                            videoViewCountModel = videoViewCountModel,
                            modelType = ModelType.VIDEO_CHANNEL
                        )
                    } else {
                        null
                    }
                }
            _uiChannelVideoState.postValue(videoItemModels)
        }.onFailure { exception ->
            withContext(Dispatchers.Main) {
                Log.e("DetailViewModel", "$exception")
            }
        }.also {
        	// 데이터 로딩이 완료 되면 loadingState를 loaded()로 변경한다. 
            _loadingState.value = LoadingState.loaded()
        }
    }

    private fun isPlaylistOrChannel(videoId: String): Boolean {
        return videoId == "youtube#playlist" || videoId == "youtube#channel"
    }


}

class DetailViewModelFactory(
    private val youtubeRepositoryImpl: YoutubeRepositoryImpl,
    private val entity: VideoBasicModel
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(DetailViewModel::class.java)) {
            return DetailViewModel(youtubeRepositoryImpl, entity) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}
profile
개발 공부 기록 🌱

0개의 댓글