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