728x90

프로젝트 개선사항 

  • MVVM 패턴 구조로 변경
  • API 통신에 대한 예외 처리
  • 다른 화면으로 전환 후 다시 카테고리 화면으로 돌아 갔을 때 선택된 칩에 대한 카테고리를 기억하는 기능 추가 
  • 트렌드 화면에서 스크롤을 끝까지 내리면 컨텐츠가 계속 나오도록 무한 스크롤 구현
  • 상단으로 이동 할 수있는 Scroll To Top 기능 추가 

무한 스크롤/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
    }
}

 

+ Recent posts