728x90
프로젝트 개선사항
- MVVM 패턴 구조로 변경
- API 통신에 대한 예외 처리
- 다른 화면으로 전환 후 다시 카테고리 화면으로 돌아 갔을 때 선택된 칩에 대한 카테고리를 기억하는 기능 추가
- 트렌드 화면에서 스크롤을 끝까지 내리면 컨텐츠가 계속 나오도록 무한 스크롤 구현
- 상단으로 이동 할 수있는 Scroll To Top 기능 추가
이전 코드와 변경 사항은 MVVM 패턴 을 적용해 보기 위해 Model-View-ViewModel 구조로 분리 해봤다.
Repository
더보기
package com.brandon.playvideo_app.repository
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.data.model.YoutubeVideoResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class PlayVideoRepository {
//api 통신 부분
suspend fun getTrendingVideos(): YoutubeVideoResponse =
withContext(Dispatchers.IO) {
RetrofitInstance.api.getTrendingVideos()
}
//칩 선택시 카테고리 영상
suspend fun fetchCategoryVideos(videoCategoryId: String): List<Item> =
withContext(Dispatchers.IO) {
RetrofitInstance.api.getTrendingVideos(videoCategoryId = videoCategoryId).items
}
//채널 정보
suspend fun getChannelInfo(channelIdList: MutableList<String>): List<ChannelItem> =
withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelInfo(channelId = channelIdList.joinToString(",")).items
}
//카테고리별 id를 받아오는 코드
suspend fun getCategoryIds(): List<CategoryItem> =
withContext(Dispatchers.IO) {
RetrofitInstance.api.getCategoryIds().items
}
//다음 페이지의 트렌딩 비디오를 받아오 옴
suspend fun getNextTrendingVideos(nextPageToken: String): YoutubeVideoResponse =
withContext(Dispatchers.IO) {
RetrofitInstance.api.getTrendingVideos(pageToken = nextPageToken)
}
}
TrendFragment
더보기
package com.brandon.playvideo_app.ui.trend
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.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.brandon.playvideo_app.R
import com.brandon.playvideo_app.databinding.ToolbarCommonBinding
import com.brandon.playvideo_app.databinding.TrendFragmentBinding
import com.brandon.playvideo_app.viewmodel.TrendViewModel
import timber.log.Timber
class TrendFragment : Fragment() {
private var _binding: TrendFragmentBinding? = null
private val binding get() = _binding!!
private val videoAdapter by lazy { VideoAdapter() }
private val viewModel by viewModels<TrendViewModel>()
companion object {
@JvmStatic
fun newInstance() =
TrendFragment().apply {
arguments = Bundle().apply {
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//Timber.d("Create")
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = TrendFragmentBinding.inflate(inflater, container, false)
initRecyclerView()
viewModelObserve()
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.common_tool_bar_menu)
toolbarBinding.toolbarCommon.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.search -> {
// 메뉴 아이템 1 클릭 시 동작할 코드 작성
Timber.d("Search Item Clicked!")
true
}
// 다른 메뉴 아이템에 대해서도 필요한 경우 추가할 수 있음
else -> false
}
}
//viewModel에 데이터 요청
viewModel.trendingVideos()
setUpClickListener()
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
//리사이클러뷰 초기화
private fun initRecyclerView() {
with(binding) {
recyclerView.apply {
adapter = videoAdapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addOnScrollListener(onScrollListener)
}
}
}
//viewModel 상태 관찰 //binding 으로 묶고 viewModel 상태를 observing 해도 되는지??
private fun viewModelObserve() {
with(viewModel) {
trendVideos.observe(viewLifecycleOwner) {
val videos = videoAdapter.currentList.toMutableList().apply {
addAll(it)
}
videoAdapter.submitList(videos)
}
isLoading.observe(viewLifecycleOwner) {
binding.pbTrendLoading.isVisible = it
}
receptionImage.observe(viewLifecycleOwner) {
binding.constraintLayoutTrendFragment.isVisible = it
}
}
}
private fun setUpClickListener() {
with(binding) {
fbTrendScrollToTop.setOnClickListener {
recyclerView.smoothScrollToPosition(0)
}
}
}
private var onScrollListener: RecyclerView.OnScrollListener =
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
//스크롤이 끝까지 닫아서 내릴 곳이 없으면 아이템을 추가
if (!recyclerView.canScrollVertically(1)) {
with(viewModel) {
loadingState(true)
getNextTrendingVideos()
}
}
//scrollToTop 버튼 visible
with(binding) {
if (dy < 0 && fbTrendScrollToTop.isVisible) fbTrendScrollToTop.hide()
else if (dy > 0 && !fbTrendScrollToTop.isVisible) fbTrendScrollToTop.show()
//맨위면 hide
if (!recyclerView.canScrollVertically(-1)) fbTrendScrollToTop.hide()
}
}
}
}
TrendViewModel
더보기
package com.brandon.playvideo_app.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.brandon.playvideo_app.data.model.Item
import com.brandon.playvideo_app.repository.PlayVideoRepository
import kotlinx.coroutines.launch
class TrendViewModel(val repository: PlayVideoRepository = PlayVideoRepository()) : ViewModel() {
private val _trendVideos = MutableLiveData<List<Item>>()
val trendVideos: LiveData<List<Item>> get() = _trendVideos
private val _isLoading = MutableLiveData<Boolean>(true)
val isLoading: LiveData<Boolean> get() = _isLoading
private val _receptionImage = MutableLiveData<Boolean>(false)
val receptionImage: LiveData<Boolean> get() = _receptionImage
//다음 페이지 정보 관련 변수
private val pageList: MutableList<String> = mutableListOf()
private var pageIdx = 0
//repository 데이터 요청 하고 통신 끝나면 isLoading false
//최초 실행시 nextPage를 받아 와서 List에 저장
fun trendingVideos() {
viewModelScope.launch {
runCatching {
val trendingVideos = repository.getTrendingVideos().items
_trendVideos.value = trendingVideos
loadingState(false)
val nextPageToken = repository.getTrendingVideos().nextPageToken
pageList.add(nextPageToken)
failedState(false)
}.onFailure {
loadingState(false)
failedState(true) //통신 실패
}
}
}
fun loadingState(state: Boolean) {
_isLoading.value = state
}
//다음 트렌딩 비디오를 받아 오는 함수
fun getNextTrendingVideos() {
viewModelScope.launch {
val videos = repository.getNextTrendingVideos(pageList[pageIdx]).items
_trendVideos.value = videos
loadingState(false)
//해당 페이지의 nextPageToken을 받아옴
val nextPageToken = repository.getNextTrendingVideos(pageList[pageIdx]).nextPageToken
pageList.add(nextPageToken)
//페이지 index 이동
pageIdx++
}
}
//통신 불가 예외 처리 state true 이면 실패
private fun failedState(state: Boolean) {
_receptionImage.value = state
}
}
CategoryFragment
더보기
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.children
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.brandon.playvideo_app.R
import com.brandon.playvideo_app.databinding.CategoryFragmentBinding
import com.brandon.playvideo_app.databinding.ToolbarCommonBinding
import com.brandon.playvideo_app.viewmodel.CategoryViewModel
import com.google.android.material.chip.Chip
class CategoryFragment : Fragment() {
private var _binding: CategoryFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel by viewModels<CategoryViewModel>()
private val channelAdapter by lazy { ChannelAdapter() }
private lateinit var categoryVideoAdapter: CategoryVideoAdapter
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()
viewModelObserve()
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.common_tool_bar_menu)
toolbarBinding.toolbarCommon.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.search -> {
true
}
else -> false
}
}
}
private fun initRecyclerView() {
categoryVideoAdapter = CategoryVideoAdapter(listOf())
with(binding) {
//카테고리 영상 recyclerView
recyclerviewCategory.apply {
adapter = categoryVideoAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
//채널 정보 recyclerView
recyclerviewChannelsByCategory.apply {
adapter = channelAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
}
}
//초기 화면 셋팅 칩 생성
private fun initViews() {
with(viewModel) {
loadingState(false)
getCategoryIds()
//assignable 허용된 id만 가져와서 칩그룹에 추가하는 코드
categoryIdList.observe(viewLifecycleOwner) { idList ->
idList.filter { it.snippet.assignable }.forEach { id ->
binding.chipGroupCategory.addView(createChip(id.snippet.title, id.id))
}
}
}
}
private fun createChip(category: String, videoCategoryId: String): Chip {
return Chip(context).apply {
setText(category)
isClickable = true
isCheckable = true
setOnClickListener {
with(viewModel) {
//로딩 ui 처리
loadingState(true)
//칩이 눌리면 카테고리별 영상과 채널 정보를 가져옴
fetchCategoryVideos(videoCategoryId)
//칩이 눌리면 카테고리명 저장
saveCategoryTitle(category)
}
}
}
}
private fun viewVisible(state: Boolean) {
with(binding) {
tvChannelByCategory.isVisible = state
constraintLayoutCategoryFragment.isVisible = !state
ivCategoryLogo.isVisible = state
}
}
private fun viewModelObserve() {
with(viewModel) {
//초기 화면 트렌딩 비디오
trendVideos.observe(viewLifecycleOwner) {
categoryVideoAdapter.items = it
categoryVideoAdapter.notifyDataSetChanged()
}
//카테고리 칩이 눌리면 카테고리별 영상과 채널의 썸네일을 보여줌
categoryVideos.observe(viewLifecycleOwner) {
categoryVideoAdapter.items = it
categoryVideoAdapter.notifyDataSetChanged()
}
//채널 썸네일
channel.observe(viewLifecycleOwner) {
channelAdapter.channelItem = it
channelAdapter.notifyDataSetChanged()
}
//로딩 상태 처리
isLoading.observe(viewLifecycleOwner) {
binding.pbCategoryLoading.isVisible = it
}
//api 통신 상태에 따른 ui 처리
receptionState.observe(viewLifecycleOwner) {
viewVisible(it)
}
//최초 CategoryFragment 진입 했을 때 처리
initState.observe(viewLifecycleOwner) { initState ->
if (initState) {
viewModel.getTrendingVideos() //초기 화면 트렌드 비디오 셋팅
binding.tvChannelByCategory.isVisible = false
}
}
//선택된 칩의 위치를 찾아서 isChecked 상태로 변경
saveCategoryTitle.observe(viewLifecycleOwner) { categoryTitle ->
val selectedChip =
binding.chipGroupCategory.children.firstOrNull { (it as Chip).text == categoryTitle } as? Chip
selectedChip?.isChecked = true
}
}
}
}
CategoryViewModel
더보기
package com.brandon.playvideo_app.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.repository.PlayVideoRepository
import kotlinx.coroutines.launch
class CategoryViewModel(val repository: PlayVideoRepository = PlayVideoRepository()) : ViewModel() {
private val _trendVideos = MutableLiveData<List<Item>>()
val trendVideos: LiveData<List<Item>> get() = _trendVideos
private val _categoryVideos = MutableLiveData<List<Item>>()
val categoryVideos: LiveData<List<Item>> get() = _categoryVideos
private val _channel = MutableLiveData<List<ChannelItem>>()
val channel: LiveData<List<ChannelItem>> get() = _channel
//카테고리의 ID
private val _categoryIdList = MutableLiveData<List<CategoryItem>>()
val categoryIdList: LiveData<List<CategoryItem>> get() = _categoryIdList
//채널 고유의 ID
private val channelIdList = mutableListOf<String>()
private val _isLoading = MutableLiveData<Boolean>(true)
val isLoading: LiveData<Boolean> get() = _isLoading
private val _receptionState = MutableLiveData<Boolean>(true)
val receptionState: LiveData<Boolean> get() = _receptionState
private val _initState = MutableLiveData<Boolean>(true)
val initState: LiveData<Boolean> get() = _initState
private val _saveCategoryTitle = MutableLiveData<String>()
val saveCategoryTitle: LiveData<String> get() = _saveCategoryTitle
//칩 눌렀을 때 카테고리 별 영상
fun fetchCategoryVideos(videoCategoryId: String) {
viewModelScope.launch {
runCatching {
//api 통신 videos endPoint
val videos = repository.fetchCategoryVideos(videoCategoryId = videoCategoryId)
_categoryVideos.value = videos
//channelIdList가 비어 있지 않으면 초기화 후 id추가
if (channelIdList.isNotEmpty()) channelIdList.clear()
videos.forEach { channelIdList.add(it.snippet.channelId) }
//통신이 끝나면 loading 끝
_isLoading.value = false
//api 통신 채널 정보를 가져오는 코드 channels endPoint
val channels = repository.getChannelInfo(channelIdList)
_channel.value = channels
//통신 상태 처리
receptionState(true)
}.onFailure {
//api 통신 예외 발생시
receptionFailed()
receptionState(false)
}
}
}
//트렌딩 비디오 영상 초기 화면 셋팅용
fun getTrendingVideos() {
viewModelScope.launch {
runCatching {
val videos = repository.getTrendingVideos().items
_trendVideos.value = videos
_initState.value = false
}.onFailure {
receptionFailed()
receptionState(false)
}
}
}
//카테고리의 id 받아오는 코드
fun getCategoryIds() {
viewModelScope.launch {
runCatching {
val ids = repository.getCategoryIds()
_categoryIdList.value = ids
}.onFailure {
receptionFailed()
receptionState(false)
}
}
}
//로딩 상태 처리
fun loadingState(state: Boolean) {
_isLoading.value = state
}
//api 통신 실패 처리
private fun receptionFailed() {
_categoryVideos.value = listOf()
_channel.value = listOf()
_isLoading.value = false
}
//통신 상태
fun receptionState(state: Boolean) {
_receptionState.value = state
}
//선택된 칩 위치를 기억 하기 위한 코드
fun saveCategoryTitle(category: String) {
_saveCategoryTitle.value = category
}
}