Youtube Data API에서 다른 EndPoint를 사용해서 각각의 API를 호출해보자.
- 이전 게시글에서 Videos EndPoint를 사용해서 Trending / Top 10 List를 받아오는 코드를 작성해 보았는데 Video EndPoint는 API 호출 비용1 인 대신 채널 정보를 가져올 수 없다. 이전에 작성한 코드에 이어서 Search EndPoint를 통해 Category 별 Video와 Channel을 Video EndPoint와 SearchEndPoint 각각의 EndPoint로 호출해보는 예제를 추가해보자.
- HomeFragment 의 경우 코드내 주석으로 자세한 설명 대체, 이전글에서 다룬 부분은 별도로 기록되지 않음
이전의 코드는 아래 게시글에서 작성.
Category별 Video와 Category에 해당하는 Channel Rcv 추가 / 구현 화면
VideoInterface.kt
interface VideoInterface {
@GET("videos")
suspend fun searchYoutube(
@Query("key") key: String = Constants.API_KEY,
@Query("part") part: String,
@Query("videoCategoryId") videoCategoryId: String,
@Query("chart") chart: String,
@Query("hl") hl: String,
@Query("maxResults") maxResults: Int,
@Query("regionCode") regionCode: String,
): Response<SearchResponse>
// 새로 추가된 코드
@GET("search")
suspend fun searchChannel(
@Query("key") key: String = Constants.API_KEY,
@Query("part") part: String,
@Query("maxResults") maxResults: Int,
@Query("q") q: String,
@Query("regionCode") regionCode: String,
@Query("type") type: String,
): Response<ChannelResponse>
}
- API Service Interface로 생성해둔 VideoInterface 내부에 채널 데이터를 받아올 수 있도록 search EndPoint를 호출하는 searchChannel 함수를 생성해준다.
RetrofitInstance.kt
object RetrofitInstance {
private val okHttpClient: OkHttpClient by lazy {
val interceptor = HttpLoggingInterceptor()
if (BuildConfig.DEBUG)
interceptor.level = HttpLoggingInterceptor.Level.BODY
else
interceptor.level = HttpLoggingInterceptor.Level.NONE
OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.addNetworkInterceptor(interceptor)
.build()
}
private val searchRetrofit: Retrofit by lazy {
val gson = GsonBuilder().setLenient().create()
Retrofit.Builder()
.baseUrl("https://youtube.googleapis.com/youtube/v3/")
.addConverterFactory(GsonConverterFactory.create(gson))
.client(okHttpClient)
.build()
}
val api: VideoInterface by lazy {
searchRetrofit.create(VideoInterface::class.java)
}
}
- Retrofit Instance는 baseUrl이 새로 추가된 search 메서드가 기존의 videos 메서드와 동일하기 때문에 변경사항이 없다.
VideoRepository.kt
interface VideoRepository {
suspend fun search(
key: String,
part: String,
videoCategoryId: String,
chart: String,
hl: String,
maxResults: Int,
regionCode: String,
): Response<SearchResponse>
suspend fun searchChannel(
key: String,
part: String,
maxResults: Int,
q: String,
regionCode: String,
type: String,
): Response<ChannelResponse>
}
VideoRepositoryImpl.kt
class VideoRepositoryImpl : VideoRepository {
override suspend fun search(
key: String,
part: String,
videoCategoryId: String,
chart: String,
hl: String,
maxResults: Int,
regionCode: String,
): Response<SearchResponse> {
return api.searchYoutube(key, part, videoCategoryId, chart, hl, maxResults, regionCode)
}
override suspend fun searchChannel(
key: String,
part: String,
maxResults: Int,
q: String,
regionCode: String,
type: String,
): Response<ChannelResponse> {
return api.searchChannel(key, part, maxResults, q, regionCode, type)
}
}
- Repository는 API Service Interface 의 데이터 액세스 방법을 지정하며 데이터 소스를 추상화해줘야 하므로 VideoInterface와 마찬가지로 searchChannel 함수를 작성해준다.
SearchViewModel.kt
fun searchYoutube(videoCategoryId: String) =
viewModelScope.launch(Dispatchers.IO) {
try {
val response: Response<SearchResponse> = searchRepository.search(
Constants.API_KEY,
"snippet",
videoCategoryId,
"mostPopular",
"ko-KR",
10,
"KR"
)
if (response.isSuccessful) {
response.body()?.let { body ->
_searchResult.postValue(body)
}
} else {
val videosErrorBody: ResponseBody? = response.errorBody()
}
} catch (e: Exception) {
}
}
- Fragment에서 Spinner를 통해 지정할 예정인 category ID 값을 파라미터로 받아 Category별 인기 동영상(Youtube API 파라미터 값 - mostPopular)로 지정 후 호출이 정상적으로 이루어질 경우 LiveData에 postValue를 통해 호출받은 데이터 저장하도록 한다.
fun searchChannels(CategoryId: String) = viewModelScope.launch(Dispatchers.IO) {
try {
val channelResponse: Response<ChannelResponse> = searchRepository.searchChannel(
Constants.API_KEY,
"snippet",
15,
CategoryId,
"KR",
"channel"
)
if (channelResponse.isSuccessful) {
channelResponse.body()?.let { body ->
_channelSearchResult.postValue(body)
}
} else {
val channelsErrorBody: ResponseBody? = channelResponse.errorBody()
}
} catch (e: Exception) {
}
}
- Channel List 또한 Fragment의 Spinner를 통해 CategoryId값을 지정하여서 해당 CategoryId 별로 채널 리스트가 다르게 나오도록 API 호출 매서드 작성
Category Video Rcv목록 Infinity Scroll 을 위한 다음 페이지 API 호출 함수 정의
fun searchYoutubeNextPage(videoCategoryId: String, pageToken: String) =
viewModelScope.launch(Dispatchers.IO) {
try {
val response: Response<SearchResponse> = searchRepository.search(
Constants.API_KEY,
"snippet",
videoCategoryId,
"mostPopular",
"ko-KR",
10,
"KR",
pageToken
)
if (response.isSuccessful) {
response.body()?.let { body ->
// 기존 아이템
val currentVideoList = _searchResult.value?.items
// 새로 추가되는 아이템
currentVideoList?.let {
currentVideoList.addAll(body.items)
_searchResult.postValue(
SearchResponse(
etag = body.etag,
items = currentVideoList,
kind = body.kind,
nextPageToken = body.nextPageToken,
pageInfo = body.pageInfo
)
)
}
}
} else {
val videosNextPageErrorBody: ResponseBody? = response.errorBody()
}
} catch (e: Exception) {
}
}
- Category Rcv는 _searchResult LiveData를 Observe 해서 해당 데이터에 변화가 생길 시 Rcv에 아이템을 표시해줘야 하는데 추가로 데이터를 받을 시 _searchResult에 데이터를 추가하려면 따로 List를 생성해서 해당 List에 기존 데이터를 담고 추가 된 데이터를 같은 형식으로 담아줘야 하기 때문에 items 는 기존에 데이터에 새로운 body에서 추가된 데이터를 addAll로 더해주고 나머지 etag, kind 값은 기존 데이터가 유지될 필요가 없어서 body 값으로 변경해주고 변경된 값으로 _searchResult에 postValue로 값을 저장한다.
Category, Channel 각각의 Rcv Adapter 생성해주기
class HomeCategoryRcvViewAdapter() :
ListAdapter<Item,HomeCategoryRcvHolder>(DifferCallback.differCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeCategoryRcvHolder {
return HomeCategoryRcvHolder(
HomeRcvItemCategoryBinding.inflate(LayoutInflater.from(parent.context),parent, false)
)
}
override fun onBindViewHolder(holder: HomeCategoryRcvHolder, position: Int) {
val category = currentList[position]
val snippet = category.snippet
val thumbnails = snippet.thumbnails
holder.apply {
holder.testThumbnails.load(thumbnails.medium.url)
holder.testItemID.text = snippet.title
}
}
}
class HomeCategoryRcvHolder(binding: HomeRcvItemCategoryBinding) :
RecyclerView.ViewHolder(binding.root) {
val testThumbnails : ImageView = binding.homeRcvImgCategoryVideoThumbnail
val testItemID:TextView = binding.homeRcvTvCategoryVideoTitle
}
class HomeChannelRcvAdapter() :
ListAdapter<ChItem, HomeChannelRcvHolder>(DifferCallback.channelDifferCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeChannelRcvHolder {
return HomeChannelRcvHolder(
HomeRcvItemChannelBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: HomeChannelRcvHolder, position: Int) {
val category = currentList[position]
val snippet = category.snippet
val thumbnails = snippet.thumbnails
holder.apply {
channelThumbnails.load(thumbnails.medium.url)
channelTitle.text = snippet.title
}
}
}
class HomeChannelRcvHolder(binding: HomeRcvItemChannelBinding) :
RecyclerView.ViewHolder(binding.root) {
val channelThumbnails: ImageView = binding.homeRcvImgChannelThumbnail
val channelTitle: TextView = binding.homeRcvTvChannelTitle
}
CategoryId.kt
object CategoryId {
val categoryMap = mapOf(
"영화" to "1",
"차량" to "2",
"음악" to "10",
"반려동물,동물" to "15",
"스포츠" to "17",
"Short Movies" to "18",
"여행" to "19",
"게임" to "20",
"Vlog" to "22",
"코미디" to "23",
"앤터테이먼트" to "24",
"뉴스" to "25",
"스타일" to "26",
)
}
- Category에 선택된 값을 Query 값으로 Channel 검색을 해야할 때 사용해야 하는데 음악 카테고리인 10을 선택했을 때 채널명 검색이 음악으로 검색이 될 수 있도록 하기 위해 Youtube API 호출 프로퍼티인 CategoryId의 값을 Map형태로 저장
HomeFragment.kt
class HomeFragment : Fragment() {
private var _binding: HomeFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var searchViewModel: SearchViewModel
private lateinit var homeCategoryRcvViewAdapter: HomeCategoryRcvViewAdapter
private lateinit var homeTrendingRcvViewAdapter: HomeTrendingRcvAdapter
private lateinit var homeChannelRcvAdapter: HomeChannelRcvAdapter
private var isUserScrolling = false
// Auto Scroll / Handler 객체 생성
private var scrollHandler = Handler(Looper.getMainLooper())
private val scrollRunnable = object : Runnable {
override fun run() {
autoScroll()
scrollHandler.postDelayed(this, 5000)
}
}
// CategoryQuery 를 Fragment 전역에서 사용할 수 있도록 지정
private var categoryQuery: String? = null
// PageToken 을 Fragment 전역에서 사용할 수 있도록 지정
private var _pageToken: String? = null
private val pageToken get() = _pageToken!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = HomeFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchViewModel = (activity as MainActivity).searchViewModel
setupRecyclerView()
searchCategory()
searchViewModel.searchYoutube("1", "")
searchViewModel.searchChannels("tv")
searchViewModel.searchTrending()
searchViewModel.trendingResult.observe(viewLifecycleOwner) { response ->
val result: List<Item> = response.items
homeTrendingRcvViewAdapter.submitList(result)
}
searchViewModel.searchResult.observe(viewLifecycleOwner) { response ->
val result: List<Item> = response.items
// Rcv 마지막 Position에 Scroll이 위치할 경우 다음 페이지의 Item 을 받아오기 위해 _pageToken에 nextPage 값 할당.
_pageToken = response.nextPageToken
homeCategoryRcvViewAdapter.submitList(result)
// 현재 화면에 그려지는 데이터셋에서는 Rcv가 자동으로 새로고침이 되지 않기 때문에 Data를 새로고침 해준다.
homeCategoryRcvViewAdapter.notifyDataSetChanged()
}
searchViewModel.channelResult.observe(viewLifecycleOwner) { response ->
val result: List<ChItem> = response.chItems
homeChannelRcvAdapter.submitList(result)
}
// Auto Scroll 적용
scrollHandler.postDelayed(scrollRunnable, 5000)
}
private fun searchCategory() {
// Spinner 를 통해 변경되는 CategoryQuery 값을 받아 API 호출
binding.homeSpnCategorySelect.setOnSpinnerItemSelectedListener<String> { _, _, _, query ->
searchViewModel.searchYoutube(CategoryId.categoryMap[query] ?: "1", "")
searchViewModel.searchChannels(query)
categoryQuery = query
}
}
private fun setupRecyclerView() {
// Trending Rcv가 스크롤 될 시 item 중앙에 Scroll이 위치하도록 SnapHelper 사용
val trendingSnapHelper = LinearSnapHelper()
trendingSnapHelper.attachToRecyclerView(binding.homeRcvTrendingList)
binding.homeRcvCategoryList.apply {
setHasFixedSize(true)
layoutManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = homeCategoryRcvViewAdapter
// Category Rcv Infinity Scroll 적용
addOnScrollListener(categoryRcvListener)
}
binding.homeRcvTrendingList.apply {
setHasFixedSize(true)
layoutManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = homeTrendingRcvViewAdapter
}
binding.homeRcvChannelList.apply {
setHasFixedSize(true)
layoutManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = homeChannelRcvAdapter
}
}
// Category Rcv Infinity Scroll 함수 정의
private val categoryRcvListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
isUserScrolling = true
}
RecyclerView.SCROLL_STATE_IDLE -> {
if (isUserScrolling) {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val itemCount = recyclerView.adapter?.itemCount ?: 0
if (lastVisibleItemPosition == itemCount - 1) {
searchViewModel.searchYoutubeNextPage(
CategoryId.categoryMap[categoryQuery] ?: "1", pageToken
)
}
}
isUserScrolling = false
}
}
}
}
// Trending Rcv의 Scroll이 자동으로 순환하는 함수 정의
private fun autoScroll() {
val layoutManager = binding.homeRcvTrendingList.layoutManager as LinearLayoutManager
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val itemCount = homeTrendingRcvViewAdapter.itemCount
if (lastVisibleItemPosition < itemCount - 1) {
binding.homeRcvTrendingList.smoothScrollToPosition(lastVisibleItemPosition + 1)
} else {
binding.homeRcvTrendingList.scrollToPosition(0)
}
}
override fun onPause() {
super.onPause()
// Spinner를 펼친 상태에서 다른 Fragment로 이동 시 Spinner가 닫히도록 함.
binding.homeSpnCategorySelect.dismiss()
}
override fun onDestroyView() {
super.onDestroyView()
// Auto Scroll Handler 제거
scrollHandler.removeCallbacksAndMessages(null)
_binding = null
}
}
[오늘 복습한 내용]
1. ??
[오류,에러 등등]
1. RecyclerView 아이템 추가를 했는데 View에 적용이 되지 않는 오류
문제 코드
searchViewModel.searchResult.observe(viewLifecycleOwner) { response ->
val result: List<Item> = response.items
_pageToken = response.nextPageToken
binding.testButton.setOnClickListener {
searchViewModel.searchYoutubeNextPage("17",pageToken)
Log.d("xxxx", "pageTokey: $pageToken")
homeCategoryRcvViewAdapter.submitList(result)
homeCategoryRcvViewAdapter.notifyDataSetChanged()
}
homeCategoryRcvViewAdapter.submitList(result)
Log.d("xxxx", "home frag submitList? : ${result.size}")
}
해결 방법 = notifyDataSetChanged()를 코드 맨 아래로 이동.
해결 코드
searchViewModel.searchResult.observe(viewLifecycleOwner) { response ->
val result: List<Item> = response.items
_pageToken = response.nextPageToken
binding.testButton.setOnClickListener {
searchViewModel.searchYoutubeNextPage("17",pageToken)
Log.d("xxxx", "pageTokey: $pageToken")
homeCategoryRcvViewAdapter.submitList(result)
}
homeCategoryRcvViewAdapter.submitList(result)
homeCategoryRcvViewAdapter.notifyDataSetChanged()
Log.d("xxxx", "home frag submitList? : ${result.size}")
}
[느낀 점]
1. 어렵다
2. MVVM이 되는건지... 안되는건지...
3. 여러 코드를 접하면 다양한 의문을 갖게되어서 좋은 것 같다.
[Reference]
// Infinity Scroll
'TIL' 카테고리의 다른 글
[TIL] Kotlin 테마 아이콘( Adaptive Icon ) - 테마에 따라 아이콘 색, 배경 색 자동 변경 (0) | 2023.10.08 |
---|---|
[TIL] Kotlin 화면 세로 고정 / App Icon, splash 변경 (0) | 2023.10.07 |
[TIL] Kotlin ViewPager2 [ 2 ] 순환 Scroll 배너 만들기 ( Fragment전환이 아닌 Rcv 형태) (0) | 2023.10.04 |
[TIL] Kotlin ViewPager2 [ 1 ] Fragment 전환 하기, 선택된 탭 아이콘 색상이 바뀌지 않는 오류 (0) | 2023.10.03 |
[TIL] kotlin Room Database 개념 (0) | 2023.10.01 |