728x90

다음 작업으로, 카테고리 별 비디오 리스트를 분류, 해당 카테고리의 채널 썸네일을 보여주는 작업을 했다.

Category 화면

  • 카테고리 화면에서는 상단 카테고리 칩을 누르면 해당 카테고리의 썸네일을 가져오고, 관련 채널들을 보여주는 화면
  • 카테고리 화면을 작업하면서, 트렌드 화면과 같이 api 통신 중 로딩 ui가 추가 됨 썸네일을 불러오는 중 간혹 ... 으로 표시가 되어 api 수신 문제인줄 알았으나, 썸네일을 불러오는 Glide 이미지 로드 문제인 것 같음
  • 카테고리 화면에 초기에 진입 했을 경우 카테고리를 선택하지 않으면,  트렌드 화면에서 보여주는 실시간 가장 인기있는 영상 썸네일을 보여줌  
  • 불러올 수 없는 카테고리를 예외처리로 다른 화면을 보여줌 

작업 요구사항 

  • Category Videos 목록
    1. 비디오 카테고리 조회
      • YouTube에서 제공하는 다양한 비디오 카테고리를 조회
      • API 연동: videoCategories 엔드 포인트를 사용하여 원하는 국가의 비디오 카테고리 목록을 가져와 참조
    2. 카테고리 별 비디오 목록 조회
      • 특정 카테고리에 속하는 인기 비디오 목록을 조회하세요.
      • 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
}

+ Recent posts