728x90
다음 작업으로, 카테고리 별 비디오 리스트를 분류, 해당 카테고리의 채널 썸네일을 보여주는 작업을 했다.
- 카테고리 화면에서는 상단 카테고리 칩을 누르면 해당 카테고리의 썸네일을 가져오고, 관련 채널들을 보여주는 화면
- 카테고리 화면을 작업하면서, 트렌드 화면과 같이 api 통신 중 로딩 ui가 추가 됨 썸네일을 불러오는 중 간혹 ... 으로 표시가 되어 api 수신 문제인줄 알았으나, 썸네일을 불러오는 Glide 이미지 로드 문제인 것 같음
- 카테고리 화면에 초기에 진입 했을 경우 카테고리를 선택하지 않으면, 트렌드 화면에서 보여주는 실시간 가장 인기있는 영상 썸네일을 보여줌
- 불러올 수 없는 카테고리를 예외처리로 다른 화면을 보여줌
작업 요구사항
- Category Videos 목록
- 비디오 카테고리 조회
- YouTube에서 제공하는 다양한 비디오 카테고리를 조회
- API 연동: videoCategories 엔드 포인트를 사용하여 원하는 국가의 비디오 카테고리 목록을 가져와 참조
- 카테고리 별 비디오 목록 조회
- 특정 카테고리에 속하는 인기 비디오 목록을 조회하세요.
- API 연동: videos 엔드 포인트에 videoCategoryId 파라미터를 활용하여 해당 카테고리의 가장 인기 있는(chart=mostPopular) 비디오 목록을 가져오기
- 비디오 카테고리 조회
- Category Channels 목록
- 특정 카테고리에 속하는 비디오의 각 채널들의 정보를 조회
- API 연동: channels 엔드 포인트를 활용하고, id 파라미터에 채널 ID 값을 넣어 해당 채널의 정보를 가져오기
- 가져온 채널 정보 중 snippet 부분을 활용하여, 채널의 기본 정보와 대표 이미지를 정확히 표시하십시오. API를 사용할 때 part 파라미터에 snippet 값을 넣어 주어야 함.
category_fragment.xml
더보기
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.category.CategoryFragment">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/tb_category_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/collapse_container"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include
android:id="@+id/included_tool_bar"
layout="@layout/toolbar_common" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout>
<HorizontalScrollView
android:id="@+id/horizontal_scrollview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tb_category_toolbar">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chip_group_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:selectionRequired="true"
app:singleLine="true"
app:singleSelection="true" />
</HorizontalScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview_category"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/horizontal_scrollview" />
<TextView
android:id="@+id/tv_channel_by_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="12dp"
android:text="@string/category_fragment_title"
android:textColor="@color/black"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerview_category" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview_channels_by_category"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_channel_by_category" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraint_layout_category_fragment"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="10dp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tb_category_toolbar">
<ImageView
android:id="@+id/iv_category_empty"
android:layout_width="100dp"
android:layout_height="100dp"
android:maxHeight="220dp"
android:minHeight="220dp"
android:src="@drawable/img_category_empty"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.4" />
<TextView
android:id="@+id/tv_category_notice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="해당 카테고리의 영상을 받아 올 수 없습니다."
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv_category_empty" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/pb_category_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
item_category.xml
더보기
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/cardview_category"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:cardCornerRadius="12dp"
android:layout_marginHorizontal="10dp"
>
<ImageView
android:padding="1dp"
android:src="@mipmap/ic_launcher_round"
android:id="@+id/iv_category_thumbnail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:maxWidth="280dp"
android:maxHeight="250dp"
android:scaleType="fitXY"
/>
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/tv_category_title"
style="@style/video_title_style"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintEnd_toEndOf="@id/cardview_category"
app:layout_constraintStart_toStartOf="@id/cardview_category"
app:layout_constraintTop_toBottomOf="@id/cardview_category"
tools:text="Love wins all (Love wins all) " />
</androidx.constraintlayout.widget.ConstraintLayout>
item_channel_by_category
더보기
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
android:id="@+id/cardview_channel_by_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
app:cardCornerRadius="12dp"
android:elevation="10dp"
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="wrap_content"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:maxWidth="200dp"
android:maxHeight="200dp"
android:scaleType="fitCenter" />
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/tv_channel_name"
style="@style/video_title_style"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintEnd_toEndOf="@id/cardview_channel_by_category"
app:layout_constraintStart_toStartOf="@id/cardview_channel_by_category"
app:layout_constraintTop_toBottomOf="@id/cardview_channel_by_category"
tools:text="IU - Topic" />
</androidx.constraintlayout.widget.ConstraintLayout>
CategoryVideoAdapter
더보기
package com.brandon.playvideo_app.ui.category
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.brandon.playvideo_app.R
import com.brandon.playvideo_app.data.api.RetrofitInstance
import com.brandon.playvideo_app.data.model.CategoryItem
import com.brandon.playvideo_app.data.model.ChannelItem
import com.brandon.playvideo_app.data.model.Item
import com.brandon.playvideo_app.databinding.CategoryFragmentBinding
import com.brandon.playvideo_app.databinding.ToolbarCommonBinding
import com.google.android.material.chip.Chip
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
class CategoryFragment : Fragment() {
private var _binding: CategoryFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var categoryVideoAdapter: CategoryVideoAdapter
private lateinit var channelAdapter: ChannelAdapter
private lateinit var channel: List<ChannelItem>
private lateinit var categoryVideos: List<Item>
private lateinit var categories: List<CategoryItem>
private lateinit var trendVideos: List<Item>
private var idList = mutableListOf<String>()
companion object {
@JvmStatic
fun newInstance() =
CategoryFragment().apply {
arguments = Bundle().apply {
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = CategoryFragmentBinding.inflate(inflater, container, false)
initViews()
initRecyclerView()
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbarBinding = ToolbarCommonBinding.bind(view.findViewById(R.id.included_tool_bar))
toolbarBinding.toolbarCommon.inflateMenu(R.menu.library_tool_bar_menu)
toolbarBinding.toolbarCommon.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.search -> {
true
}
R.id.setting -> {
Timber.d("Setting Item Clicked!")
true
}
else -> false
}
}
}
private fun initRecyclerView() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
trendVideos = RetrofitInstance.api.getTrendingVideos().items
}
categoryVideoAdapter = CategoryVideoAdapter(trendVideos)
channelAdapter = ChannelAdapter()
with(binding) {
recyclerviewCategory.apply {
adapter = categoryVideoAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
recyclerviewChannelsByCategory.apply {
adapter = channelAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
}
}
}
}
private fun initViews() {
binding.tvChannelByCategory.isVisible =
false //chip이 선택되지 않았을 경우 Channels by Category 안보이게처리
//카테고리 목록 받아오기
lifecycleScope.launch {
runCatching {
withContext(Dispatchers.IO) {
categories = RetrofitInstance.api.getCategoryIds().items
}
val category = mutableListOf<String>()
//assignable이 허용된 api Item만 추가
categories.filter { it.snippet.assignable }.forEach {
category.add(it.snippet.title)
idList.add(it.id)
}
//칩 셋팅
binding.chipGroupCategory.apply {
category.forEach { category ->
addView(createChip(category))
}
}
}.onFailure {
//api 수신 실패 시
receptionFailed()
}
}
}
//칩 생성
private fun createChip(category: String): Chip {
return Chip(context).apply {
setText(category)
isClickable = true
isCheckable = true
setOnClickListener {
runCatching {
//id의 인덱스 받아 오는 부분 chip 의 개수가 14개(api assignable), id가 1부터 시작
var idx = (id % 14) - 1
if (idx == -1) {
idx = 13
}
val videoCategoryId = idList[idx]
fetchCategory(videoCategoryId)
}.onFailure {
receptionFailed()
}
}
}
}
//화면 초기화시 카테고리 별 영상 불러오기
private fun fetchCategory(categoryId: String) {
lifecycleScope.launch {
runCatching {
binding.pbCategoryLoading.isVisible = true //로딩 처리
categoryVideos =
getCategoryVideos(categoryId) //api 통신
//channelId를 리스트에 추가
val channelIdList = mutableListOf<String>()
categoryVideos.forEach { channelIdList.add(it.snippet.channelId) }
channel =
getChannelInfo(channelIdList) //api 통신
binding.pbCategoryLoading.isVisible = false //로딩 처리
//CategoryVideos
categoryVideoAdapter.items = categoryVideos
categoryVideoAdapter.notifyDataSetChanged()
//Channel By Category
channelAdapter.channelItem = channel
channelAdapter.notifyDataSetChanged()
//포지션 위치 초기화
with(binding) {
recyclerviewCategory.scrollToPosition(0)
recyclerviewChannelsByCategory.scrollToPosition(0)
}
viewVisible(true, false)//Channels by Category Text-View
//404에러 API 불러올 수 없음
}.onFailure {
receptionFailed()
}
}
}
//Api 수신 결과에 따른 view 상태
private fun viewVisible(state: Boolean, loadingState: Boolean) {
binding.tvChannelByCategory.isVisible = state
binding.constraintLayoutCategoryFragment.isVisible = !state
binding.pbCategoryLoading.isVisible = loadingState
}
//api 수신 실패시 ui 변경
private fun receptionFailed() {
viewVisible(false, false)
categoryVideoAdapter.items = listOf()
categoryVideoAdapter.notifyDataSetChanged()
channelAdapter.channelItem = listOf()
channelAdapter.notifyDataSetChanged()
}
//api 통신 부분
private suspend fun getCategoryVideos(categoryId: String): List<Item> =
withContext(Dispatchers.IO) {
RetrofitInstance.api.getTrendingVideos(videoCategoryId = categoryId).items
}
private suspend fun getChannelInfo(channelIdList: MutableList<String>): List<ChannelItem> =
withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelInfo(channelId = channelIdList.joinToString(",")).items
}
}
CategoryVideoAdapter
더보기
package com.brandon.playvideo_app.ui.category
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.brandon.playvideo_app.data.model.Item
import com.brandon.playvideo_app.databinding.ItemCategoryBinding
import com.bumptech.glide.Glide
class CategoryVideoAdapter(var items: List<Item>) :
RecyclerView.Adapter<CategoryVideoAdapter.CategoryViewHolder>() {
inner class CategoryViewHolder(private val binding: ItemCategoryBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: Item) {
with(binding) {
tvCategoryTitle.text = item.snippet.title
Glide.with(itemView)
.load(item.snippet.thumbnails.maxres?.url ?: item.snippet.thumbnails.default)
.into(ivCategoryThumbnail)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryViewHolder {
val inflater =
parent.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val binding = ItemCategoryBinding.inflate(inflater, parent, false)
return CategoryViewHolder(binding)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) {
holder.bind(items[position])
}
}
ChannelAdapter
더보기
package com.brandon.playvideo_app.ui.category
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.brandon.playvideo_app.data.model.ChannelItem
import com.brandon.playvideo_app.databinding.ItemChannelByCategoryBinding
import com.bumptech.glide.Glide
class ChannelAdapter :
RecyclerView.Adapter<ChannelAdapter.ChannelViewHolder>() {
var channelItem: List<ChannelItem> = listOf()
inner class ChannelViewHolder(private val binding: ItemChannelByCategoryBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: ChannelItem) {
with(binding) {
tvChannelName.text = item.snippet.title
Glide.with(itemView)
.load(item.snippet.thumbnails.high?.url ?: item.snippet.thumbnails.default.url)
.into(ivChannelThumbnail)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChannelViewHolder {
val inflater =
parent.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val binding = ItemChannelByCategoryBinding.inflate(inflater, parent, false)
return ChannelViewHolder(binding)
}
override fun getItemCount() = channelItem.size
override fun onBindViewHolder(holder: ChannelViewHolder, position: Int) {
holder.bind(channelItem[position])
}
}
YouTubeApi
더보기
package com.brandon.playvideo_app.data.api
import com.brandon.playvideo_app.data.model.CategoryVideoModel
import com.brandon.playvideo_app.data.model.ChannelByCategoryModel
import com.brandon.playvideo_app.data.model.TrendVideoModel
import retrofit2.http.GET
import retrofit2.http.Query
private const val PART = "snippet"
private const val CHART = "mostPopular"
private const val MAX_RESULT = 20
private const val REGION = "KR"
private const val API_KEY = //API//
private const val HL = "ko_KR" //hl 매개변수는 API 응답의 텍스트 값에 사용할 언어를 지정합니다. 기본값은 en_US입니다.
private const val VIDEO_CATEGORY_ID =
"0" //videoCategoryId 매개변수는 차트를 검색해야 하는 동영상 카테고리를 식별합니다. 이 매개변수는 chart 매개변수와만 함께 사용할 수 있습니다. 기본적으로 차트는 특정 카테고리로 제한되지 않습니다. 기본값은 0입니다.
interface YouTubeApi {
@GET("videos")
suspend fun getTrendingVideos(
@Query("part") part: String = PART,
@Query("chart") chart: String = CHART,
@Query("maxResults") maxResults: Int = MAX_RESULT,
@Query("regionCode") regionCode: String = REGION,
@Query("videoCategoryId") videoCategoryId: String = VIDEO_CATEGORY_ID,
@Query("key") apiKey: String = API_KEY
): TrendVideoModel
/////////////////////////////////////추가된부분
@GET("videoCategories")
suspend fun getCategoryIds(
@Query("part") part: String = PART,
@Query("regionCode") regionCode: String = REGION,
@Query("hl") hl: String = HL,
@Query("key") apiKey: String = API_KEY
): CategoryVideoModel
@GET("channels")
suspend fun getChannelInfo(
@Query("part") part: String = PART,
@Query("id") channelId: String,
@Query("key") apiKey: String = API_KEY
): ChannelByCategoryModel
}