728x90

HomeFragment 상단 뷰 페이저

 

HomeFragment 

더보기
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayoutMediator
import com.nbc.shownect.R
import com.nbc.shownect.databinding.FragmentHomeBinding
import com.nbc.shownect.fetch.network.retrofit.RetrofitClient.fetch
import com.nbc.shownect.fetch.repository.impl.FetchRepositoryImpl
import com.nbc.shownect.presentation.home.adapter.GenreAdapter
import com.nbc.shownect.presentation.home.adapter.KidShowAdapter
import com.nbc.shownect.presentation.home.adapter.PosterClickListener
import com.nbc.shownect.presentation.home.adapter.TopRankAdapter
import com.nbc.shownect.presentation.home.adapter.UpcomingShowAdapter
import com.nbc.shownect.presentation.main.MainViewModel
import com.nbc.shownect.presentation.main.MainViewModelFactory
import com.nbc.shownect.presentation.ticket.TicketDialogFragment
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class HomeFragment : Fragment(), PosterClickListener {
    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!
    private val viewModel: HomeViewModel by viewModels {
        HomeViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(fetch),
        )
    }
    private val sharedViewModel: MainViewModel by activityViewModels<MainViewModel> {
        MainViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(
                fetch
            )
        )
    }
    private val upComingShowAdapter: UpcomingShowAdapter by lazy { UpcomingShowAdapter(this) }
    private val topRankAdapter: TopRankAdapter by lazy { TopRankAdapter(this) }
    private val genreAdapter: GenreAdapter by lazy { GenreAdapter(this) }
    private val kidShowAdapter: KidShowAdapter by lazy { KidShowAdapter(this) }
    private var isPaging = false
    private var pagingJob: Job? = null
    private val onPageChangeCallback: OnPageChangeCallback = object : OnPageChangeCallback() {
        //페이지 선택 될 때마다 호출
        override fun onPageSelected(position: Int) {
            super.onPageSelected(position)
            binding.tvPageIndicator.text = "${position + 1} / 10"
        }
    }
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        initViews()
        setUpObserve()
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        with(viewModel) {
            //공연 예정작
            fetchUpcoming()
            //TOP 10 공연
            fetchTopRank()
            //장르 스피너 선택
            binding.spinnerHomeGenre.setOnSpinnerItemSelectedListener<String> { _, _, newIndex, _ ->
                fetchGenre(newIndex)
            }
            //어린이 관람 가능 공연 목록
            fetchKidShow()
        }
    }

    //화면 초기 설정
    private fun initViews() {
        //어뎁터 초기화
        upComingShowAdapter
        topRankAdapter
        kidShowAdapter
        initRecyclerView()

        //viewPager 연결
        with(binding.viewPager) {
            adapter = upComingShowAdapter
            //viewPager PageTransformer 세팅
            offscreenPageLimit = 1
            setPageTransformer(SliderTransformer(requireContext()))
            val itemDecoration = HorizontalMarginItemDecoration(
                requireContext(),
                R.dimen.viewpager_current_item_horizontal_margin
            )
            addItemDecoration(itemDecoration)


            registerOnPageChangeCallback(onPageChangeCallback)

            //tab 연결
            TabLayoutMediator(binding.tabPosterIndicator, this) { tab, position ->
                currentItem = tab.position
            }.attach()
        }
        //장르 연극 초기화
        viewModel.fetchGenre(0)
    }

    //옵저브 세팅
    private fun setUpObserve() {
        with(viewModel) {
            showList.observe(viewLifecycleOwner) {
                upComingShowAdapter.submitList(it)
                if (!isPaging) startPaging()
            }
            topRank.observe(viewLifecycleOwner) {
                topRankAdapter.submitList(it?.take(10))
            }
            genre.observe(viewLifecycleOwner) {
                genreAdapter.submitList(it)
            }
            kidShow.observe(viewLifecycleOwner) {
                kidShowAdapter.submitList(it)
            }
            //로딩 화면 처리
            isLoadingGenre.observe(viewLifecycleOwner) {
                binding.skeletonGenreLoading.isVisible = !it
            }
            isLoadingRecommend.observe(viewLifecycleOwner) {
                binding.skeletonTopRankLoading.isVisible = !it
            }
            isLoadingKid.observe(viewLifecycleOwner) {
                binding.skeletonKidLoading.isVisible = !it
            }

        }
    }

    //리사이클러뷰 초기화
    private fun initRecyclerView() {
        with(binding) {
            //HOT 추천 리사이클러뷰
            rvHomeTopRank.apply {
                adapter = topRankAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //장르별 리사이클러뷰
            rvHomeGenre.apply {
                adapter = genreAdapter
                layoutManager =
                    LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //어린이 공연 리사이클러뷰
            rvHomeKidShow.apply {
                adapter = kidShowAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
        }
    }

    //3초 후 자동 페이징
    private fun nextPage() {
        runCatching {
            with(binding) {
                if (viewPager.currentItem == 9) {
                    lifecycleScope.launch {
                        delay(3000)
                    }
                    viewPager.currentItem = 0
                } else {
                    viewPager.currentItem++
                }
            }
        }.onFailure {
            Toast.makeText(context, "Exception nextPage()", Toast.LENGTH_SHORT).show()
        }
    }

    //페이징 스타트 함수
    private fun startPaging() {
        isPaging = true
        pagingJob = lifecycleScope.launch {
            while (true) {
                delay(3000)
                nextPage()
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
        isPaging = false
        pagingJob?.cancel()
    }

    //포스터 클릭 시 티켓
    override fun posterClicked(id: String) {
        val ticketDialog = TicketDialogFragment()
        sharedViewModel.sharedShowId(id) //해당 공연의 id를 MainViewModel로 보내줌
        ticketDialog.setStyle(
            DialogFragment.STYLE_NORMAL,
            R.style.RoundCornerBottomSheetDialogTheme
        )
        ticketDialog.show(childFragmentManager, ticketDialog.tag)
    }
}

offscreenPageLimit = 1 

  • 미리 로딩할 페이지 수

SliderTransformer - 페이지 변환 효과, 스크롤될 때 각 페이지에 적용되는 시각적 변환

  • transformPage 메소드는 페이지의 위치(position)에 따라 페이지의 변환을 결정
  • position 값은 현재 중앙에 위치한 페이지를 기준으로 왼쪽에 있는 페이지는 음수(-1, -0.5 등), 오른쪽에 있는 페이지는 양수(0.5, 1 등)으로 표현
  • page.translationX는 페이지의 가로 위치를 조정하여, 스크롤 시 페이지가 어떻게 이동할지 정의 (페이지 사이의 간격을 조정하는 데 사용)
  • page.scaleY는 페이지의 세로 축 스케일을 조정하여, 페이지가 어느 정도 떨어져 있을 때 크기가 줄어들게 함
  • page.alpha는 투명도 설정 (페이지가 중앙에 있을 때(position == 0)) , 최대 100% 불투명도(1.0f) 중앙에서 멀어질 수록 투명 (0.25f 는 최소 투명도)

리소스에서 다음 페이지가 보이는 부분(nextItemVisiblePx)과 현재 페이지의 수평 마진(currentItemHorizontalMarginPx)을 가져오고, 이 두 값을 합산하여 pageTranslationX를 계산 (스크롤 시 페이지의 위치 변환에 사용)

더보기
import android.content.Context
import android.view.View
import androidx.viewpager2.widget.ViewPager2
import com.nbc.shownect.R
import kotlin.math.abs

class SliderTransformer(context: Context) :
    ViewPager2.PageTransformer {
    override fun transformPage(page: View, position: Float) {
        page.translationX = -pageTranslationX * position
        page.scaleY = 1 - (0.25f * abs(position))
        page.alpha = 0.25f + (1 - abs(position))
    }

    val nextItemVisiblePx =
        context.resources.getDimension(R.dimen.viewpager_next_item_visible)
    val currentItemHorizontalMarginPx =
        context.resources.getDimension(R.dimen.viewpager_current_item_horizontal_margin)
    val pageTranslationX = nextItemVisiblePx + currentItemHorizontalMarginPx
}

 

HorizontalMarginItemDecoration - 리사이클러뷰 항목에 수평 마진을 추가하는 기능 (주로 리사이클러 뷰 내부의 아이템 간격을 조정하는 데 사용)

  • 생성자는 Context와 마진 값을 DP 단위로 받아 해당 값을 픽셀 단위로 변환
  • context.resources.getDimension() 리소스 ID를 통해 해당 마진 값의 실제 픽셀 크기를 가져옴
  • getItemOffsets() 메소드는 리사이클러뷰 내 각 항목에 대한 오프셋(여기서는 마진)을 설정, outRect.left와 outRect.right를 설정하여 각 항목의 왼쪽과 오른쪽에 마진을 적용
더보기
import android.content.Context
import android.graphics.Rect
import android.view.View
import androidx.annotation.DimenRes
import androidx.recyclerview.widget.RecyclerView

class HorizontalMarginItemDecoration(context: Context, @DimenRes horizontalMarginInDp: Int) :
    RecyclerView.ItemDecoration() {
    private val horizontalMarginInPx: Int =
        context.resources.getDimension(horizontalMarginInDp).toInt()

    override fun getItemOffsets(
        outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State
    ) {
        outRect.right = horizontalMarginInPx
        outRect.left = horizontalMarginInPx
    }
}

res/values/dimens/dimens.xml

더보기
<resources>
    <!-- Default screen margins, per the Android Design guidelines. -->
    <dimen name="activity_horizontal_margin">16dp</dimen>
    <dimen name="activity_vertical_margin">16dp</dimen>
    //추가된 부분 
    <dimen name="viewpager_next_item_visible">26dp</dimen>
    <dimen name="viewpager_current_item_horizontal_margin">42dp</dimen>
</resources>

viewpager_next_item_visible

  • ViewPager2를 사용할 때, 슬라이드되고 있는 다음 항목이 얼마나 보여질지를 정의하는 값
  • SliderTransformer Class에서 사용 

viewpager_current_item_horizontal_margin

  • 현재 활성화된 ViewPager2 항목의 수평 마진 크기를 정의
  • 현재 보여지는 항목과 양 옆의 항목 사이의 간격을 결정
  • HorizontalMarginItemDecoration Class 에서 사용 

 

참고 

https://github.com/unaisulhadi/ViewPager2-Carousel

 

GitHub - unaisulhadi/ViewPager2-Carousel: A simple Carousel for ViewPager2

A simple Carousel for ViewPager2. Contribute to unaisulhadi/ViewPager2-Carousel development by creating an account on GitHub.

github.com

https://stackoverflow.com/questions/71108306/viewpager2-left-and-right-preview-item-scrolling-not-working

 

Viewpager2 left and right preview item scrolling not working

I am trying to create an image slider using ViewPager2 with left and right previews. Everything is working perfectly, but scrolling on left and right preview items is not working. There is a similar

stackoverflow.com

 

728x90

블러처리된 이미지 백그라운드

 

프로젝트에서 이미지를 블러 처리할 일 이 생겨서 방법을 찾아 보았다. 

 

처음에는 Glide에서 BlurTransformation 을 지원해주는 줄 알았지만 wasabeef 의 라이브러리를 추가해 주어야 했다.

여기서 BlurTransformation()이 사용이안되어서 오랜시간 삽질을 했다. 

 

 

이미지를 처리하기 위한 라이브러리 추가 

 

사용방법  

			Glide.with(requireContext())
                            .load(showDetail.poster)
                            .apply(RequestOptions.bitmapTransform(BlurTransformation(15, 1)))
                            .into(ivSimplePosterBlur)

 

이미지를 블러 처리하는 부분  

.apply(RequestOptions.bitmapTransform(BlurTransformation(15, 1)))

BlurTransformation 은 radius (15) , sampling (1) 를 매개변수로 받는다.

 

Radius

  • 수치가 높을 수록 더 강한 블러처리 (이 값이 클수록 이미지가 더 흐릿해짐)
  • radius가 클수록 더 많은 픽셀이 블러 처리
  • 보통 1부터 25까지 처리 가능   

sampling 

  • 이미지를 샘플링하는 데 사용
  • 블러 효과의 품질을 결정
  • 높은 샘플링 값은 더 고해상도의 이미지를 사용하여 블러를 생성하므로 더 세밀한 블러를 얻을 수 있지만, 그만큼 처리 시간이 더 오래 걸릴 수 있음 (sampling 값을 높이면 성능은 향상되지만, 이미지의 디테일이 손실될 수 있음)
  • 최소값은 1 (1을 설정하면 이미지를 축소하지 않고 원본 해상도 그대로 블러 처리)

 

 

 

 

 

 

라이브러리 깃 허브 

 

BlurTransformation 

https://github.com/wasabeef/glide-transformations

 

GitHub - wasabeef/glide-transformations: An Android transformation library providing a variety of image transformations for Glid

An Android transformation library providing a variety of image transformations for Glide. - wasabeef/glide-transformations

github.com

 

Glide 

https://github.com/bumptech/glide

 

GitHub - bumptech/glide: An image loading and caching library for Android focused on smooth scrolling

An image loading and caching library for Android focused on smooth scrolling - bumptech/glide

github.com

 

728x90

프래그먼트간 데이터를 공유하려면 여러 가지 방법이있다.

  • ViewModel 사용하기 
    • 여러 프래그먼트가 같은 액티비티에 속해 있으면, 이 프래그먼트들은 같은 ViewModel을 공유해서 데이터를 주고받을 수 있어서 프래그먼트들 사이의 결합도가 낮아지고, 앱을 더 쉽게 관리할 수 있다.
  • 액티비티를 통해 데이터 공유하기
    • 프래그먼트는 액티비티에 붙어 있어서 액티비티를 매개체로 삼아 데이터를 주고받을 수 있다.
    • 한 프래그먼트에서 액티비티의 메소드를 호출해 데이터를 전달하고, 다른 프래그먼트에서는 액티비티를 통해 그 데이터를 가져올 수 있다.
  • 인터페이스 사용하기
    • 데이터를 공유할 때 인터페이스를 정의해서 사용하는 방법.
    • 액티비티에서 인터페이스를 구현하고, 데이터를 보내고 싶은 프래그먼트에서는 인터페이스 메소드를 호출해 데이터를 전달하면 다른 프래그먼트에서 이 인터페이스를 통해 데이터를 받을 수 있다.
    • 복잡하다.
  • Bundle 사용해서 데이터 전달하기
    • 프래그먼트를 전환할 때 Bundle에 데이터를 담아서 보낼 수 있음.
    • 주로 새 프래그먼트를 만들 때 시작 데이터를 전달하는 데 사용됨.
  • EventBus 같은 외부 라이브러리 사용하기
    • EventBus 같은 라이브러리를 사용해서 앱 전체에서 이벤트를 발행하고 구독함으로써 프래그먼트 간에 데이터를 공유할 수 있다.
    • 코드를 더 간결하게 만들어 주고, 결합도를 낮출 수 있지만, 외부 라이브러리에 의존하게 됨.
  • Shared Preferences로 데이터 공유하기
    • 간단한 데이터는 Shared Preferences를 통해 저장하고 공유할 수 있음. 앱의 설정 값이나 작은 데이터 조각을 저장하는 데 주로 사용
    • 매번 sharedPreference에서 값을 읽어와야하는 번거로움.

 

이번 프로젝트에서는 ViewModel을 사용하는 방법을 적용해 보았다.  

 

Activity에 포함된 둘 이상의 프래그먼트는 ViewModel을 통해서 데이터 공유가 가능하다.

 

Fragment들은 각자의 ViewModel (by viewModels()) 를 가질 수도 있고,

Activity의 ViewModel (by activityViewModels)  을 공유해서 사용할 수도 있는 것

 

프로젝트에 적용해 보기

해당 공연의 id를 받아와서 데이터를 뿌려주는 화면

HomeFragment ( TicketDialogFragment 으로 데이터를 보낼 Fragment

더보기
package com.nbc.curtaincall.ui.home

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.tabs.TabLayoutMediator
import com.nbc.curtaincall.R
import com.nbc.curtaincall.databinding.FragmentHomeBinding
import com.nbc.curtaincall.fetch.network.retrofit.RetrofitClient.fetch
import com.nbc.curtaincall.fetch.repository.impl.FetchRepositoryImpl
import com.nbc.curtaincall.ui.home.adapter.GenreAdapter
import com.nbc.curtaincall.ui.home.adapter.KidShowAdapter
import com.nbc.curtaincall.ui.home.adapter.PosterClickListener
import com.nbc.curtaincall.ui.home.adapter.TopRankAdapter
import com.nbc.curtaincall.ui.home.adapter.UpcomingShowAdapter
import com.nbc.curtaincall.ui.main.MainViewModel
import com.nbc.curtaincall.ui.main.MainViewModelFactory
import com.nbc.curtaincall.ui.ticket.TicketDialogFragment
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class HomeFragment : Fragment(), PosterClickListener {
    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!
    //해당 프레그먼트의 ViewModel
    private val viewModel: HomeViewModel by viewModels {
        HomeViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(fetch),
        )
    }
    //해당 액티비티의 ViewModel -> sharedViewModel을 통해서 데이터 공유
    private val sharedViewModel: MainViewModel by activityViewModels<MainViewModel> {
        MainViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(
                fetch
            )
        )
    }
    private val upComingShowAdapter: UpcomingShowAdapter by lazy { UpcomingShowAdapter() }
    private val topRankAdapter: TopRankAdapter by lazy { TopRankAdapter(this) }
    private val genreAdapter: GenreAdapter by lazy { GenreAdapter(this) }
    private val kidShowAdapter: KidShowAdapter by lazy { KidShowAdapter(this) }
    private var isPaging = false
    private var pagingJob: Job? = null

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        initViews()
        setUpObserve()
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        with(viewModel) {
            //공연 예정작
            fetchUpcoming()
            //TOP 10 공연
            fetchTopRank()
            //장르 스피너 선택
            binding.spinnerHomeGenre.setOnSpinnerItemSelectedListener<String> { _, _, newIndex, _ ->
                fetchGenre(newIndex)
            }
            //어린이 관람 가능 공연 목록
            fetchKidShow()
        }
    }

    //화면 초기 설정
    private fun initViews() {
        //어뎁터 초기화
        upComingShowAdapter
        topRankAdapter
        kidShowAdapter
        initRecyclerView()
        with(viewModel) {
            showList.observe(viewLifecycleOwner) {
                upComingShowAdapter.submitList(it)
                with(binding) {
                    //viewpager 연결
                    viewPager.adapter = upComingShowAdapter
                    //tab 연결
                    TabLayoutMediator(tabPosterIndicator, viewPager) { tab, position ->
                        viewPager.currentItem = tab.position
                    }.attach()
                }
                if (!isPaging) startPaging()
            }
            //장르 연극 초기화
            fetchGenre(0)
        }
    }

    //옵저브 세팅
    private fun setUpObserve() {
        with(viewModel) {
            topRank.observe(viewLifecycleOwner) {
                topRankAdapter.submitList(it.take(10))
            }
            genre.observe(viewLifecycleOwner) {
                genreAdapter.submitList(it)
            }
            kidShow.observe(viewLifecycleOwner) {
                kidShowAdapter.submitList(it)
            }
        }
    }

    //리사이클러뷰 초기화
    private fun initRecyclerView() {
        with(binding) {
            //HOT 추천 리사이클러뷰
            rvHomeTopRank.apply {
                adapter = topRankAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //장르별 리사이클러뷰
            rvHomeGenre.apply {
                adapter = genreAdapter
                layoutManager =
                    LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //어린이 공연 리사이클러뷰
            rvHomeKidShow.apply {
                adapter = kidShowAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
        }
    }

    //3초 후 자동 페이징
    private fun nextPage() {
        runCatching {
            with(binding) {
                if (viewPager.currentItem == 9) {
                    lifecycleScope.launch {
                        delay(3000)
                    }
                    viewPager.currentItem = 0
                } else {
                    viewPager.currentItem++
                }
            }
        }.onFailure {
            Toast.makeText(context, "Exception nextPage()", Toast.LENGTH_SHORT).show()
        }
    }

    //페이징 스타트 함수
    private fun startPaging() {
        isPaging = true
        pagingJob = lifecycleScope.launch {
            while (true) {
                delay(3000)
                nextPage()
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
        isPaging = false
        pagingJob?.cancel()
    }

    //포스터 클릭 시 티켓
    override fun posterClicked(id: String) {
        val ticketDialog = TicketDialogFragment()
        sharedViewModel.sharedShowId(id) //해당 공연의 id를 MainViewModel로 보내줌
        ticketDialog.setStyle(
            DialogFragment.STYLE_NORMAL,
            R.style.RoundCornerBottomSheetDialogTheme
        )
        ticketDialog.show(childFragmentManager, TicketDialogFragment().tag)
    }
}

 

TicketDialogFragment (HomeFragment 에서 데이터를 받아올 Fragment) 

더보기
package com.nbc.curtaincall.ui.ticket

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import coil.load
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.nbc.curtaincall.databinding.SimpleInfoBottomsheetDialogBinding
import com.nbc.curtaincall.fetch.network.retrofit.RetrofitClient.fetch
import com.nbc.curtaincall.fetch.repository.impl.FetchRepositoryImpl
import com.nbc.curtaincall.ui.main.MainViewModel
import com.nbc.curtaincall.ui.main.MainViewModelFactory

class TicketDialogFragment : BottomSheetDialogFragment() {
    private var _binding: SimpleInfoBottomsheetDialogBinding? = null
    private val binding get() = _binding!!
    //해당 액티비티의 ViewModel -> sharedViewModel을 통해서 데이터 공유
    private val sharedViewModel: MainViewModel by activityViewModels<MainViewModel> {
        MainViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(fetch = fetch)
        )
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = SimpleInfoBottomsheetDialogBinding.inflate(inflater, container, false)

        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        //TODO Swipe To Action
        with(sharedViewModel) {
            //HomeFragment에서 id를 보내주면 fetchShowDetail() 호출
            showId.observe(viewLifecycleOwner) { id ->
                sharedViewModel.fetchShowDetail(id)
            }
            showDetailInfo.observe(viewLifecycleOwner) { showDetailInfoList ->
                with(binding) {
                    ivDetailPosterImage.load(showDetailInfoList[0].poster)
                    tvDetailAge.text = showDetailInfoList[0].prfage
                    tvDetailRuntime.text = showDetailInfoList[0].prfruntime
                    tvDetailFacilityName.text = showDetailInfoList[0].dtguidance
                    tvDetailPrice.text= showDetailInfoList[0].pcseguidance
                    tvDetailFacilityName.text = showDetailInfoList[0].fcltynm
                    tvDetailGenre.text = showDetailInfoList[0].genrenm
                }
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        _binding = null
    }
}

HomeViewModel (액티비티의 ViewModel로 MainViewModel 에서 데이터 공유)

더보기
package com.nbc.curtaincall.ui.main

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.nbc.curtaincall.fetch.model.DbResponse
import com.nbc.curtaincall.fetch.repository.impl.FetchRepositoryImpl
import kotlinx.coroutines.launch

class MainViewModel(private val fetchRemoteRepository: FetchRepositoryImpl) : ViewModel() {
    //공연 상세 정보 리스트
    private val _showDetailInfo = MutableLiveData<List<DbResponse>>()
    val showDetailInfo: LiveData<List<DbResponse>> get() = _showDetailInfo
    //공연 id
    private val _showId = MutableLiveData<String>()
    val showId: LiveData<String> get() = _showId

    //id를 공유 하기 위한 함수
    fun sharedShowId(id: String) {
        _showId.value = id
    }

    //공연 상세 정보를 받아옴
    fun fetchShowDetail(id: String) {
        viewModelScope.launch {
            _showDetailInfo.value = fetchRemoteRepository.fetchShowDetail(path = id).showList
        }
    }
}

class MainViewModelFactory(private val fetchRemoteRepository: FetchRepositoryImpl):ViewModelProvider.Factory{
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return MainViewModel(fetchRemoteRepository) as T
    }
}

 

 

HomeFragment 에서 MainViewModel 로 id를 보내주고 TicketDialogFragment 에서 저장된 id 를 꺼내쓰는 구조 

Fragment가 여러개라도 속해있는 Activity에서 파생되었기 때문에 Activity를 통해서 데이터 공유가 가능하다. 

728x90

 

@GET("pblprfr")
    suspend fun fetchShowList(
        @Query("stdate") stdate: String = Constants.START_DATE,
        @Query("eddate") eddate: String = Constants.END_DATE,
        @Query("cpage") cpage: String = Constants.CURRENT_PAGE,
        @Query("rows") rows: String = Constants.PAGE_INDEX,
        @Query("openrun") openrun: String? = null,
        @Query("newsql") newsql: String? = "Y",
        @Query("shcate") shcate: String? = null,
        @Query("kidstate") kidstate: String? = null,
        @Query("prfstate") prfstate: String? = null,
    ): DbsResponse

그 동안 위의 방식으로 정해진 endPoint에 쿼리를 보내고 응답을 받는 방식만 사용해 보다가 이번에 프로젝트를 하면서 다른 쿼리에서 받은 id를 endPoint에 넣어야 할 일이 생겼다. 

 

방법은 동적으로 URI를 가능하게 해주는 Annotation인 @Path 를 사용하는 방법이다. 

 

 @GET(EndPoint), EndPoint에서 중괄호'{ }'로 감싸진 변수에 매핑되도록 알려주는 역할 

@GET("pblprfr/{id}")
    suspend fun fetchShowDetail(
        @Path("id") path: String,
    ): DbsResponse

 

728x90

공공데이터 포털에서 Json이 아닌 xml 을 응답으로 받아야 하는 상황이라서 xml 을 파싱하는 방법을 찾다가 처음에는 TikXml 을 사용 했으며, TikXml 사용시 gradle 충돌 문제가 있어서 다른 방법을 찾아봤다.

 

//빌드에러

Caused by: org.gradle.api.internal.artifacts.ivyservice.DefaultLenientConfiguration$ArtifactResolveException: Could not resolve all files for configuration ':app:debugCompileClasspath'.

 

jaxb 로 대체하려 했으나 몇 시간의 삽질 끝에 android 지원하지 않는 것 알게됨

 

시도 해본 것

  1. annotaion 버전 down grade
  2. 캐시 삭제
  3. 리빌드 프로젝트 -> Could not find com.tickaroo.tikxml:annotation:0.8.15.
    Required by:
    project :app
    project :app > com.tickaroo.tikxml:retrofit-converter:0.8.15 에러
  4. kapt 제거
  5. jaxb 라이브러리 사용 -> 안드로이드 지원x 삽질o

결국 버전을 내려서 다시 시도 

implementation("com.tickaroo.tikxml:annotation:0.8.13")
implementation("com.tickaroo.tikxml:core:0.8.13")
kapt("com.tickaroo.tikxml:processor:0.8.13")
implementation("com.tickaroo.tikxml:retrofit-converter:0.8.13")
kapt("com.tickaroo.tikxml:auto-value-tikxml:0.8.13")

 

버전을 0.8.15 에서 0.8.13으로 내려서 그래들 문제는 해결이 되었는데 데이터가 들어온 이후에 타임아웃 에러가 뜸

 

-> 데이터 클래스 구조와 어노테이션을 잘 못 사용한 것 때문에 파싱이 제대로 안된 것 데이터 클래스 구조와 어노테이션을 제대로 사용해서 파싱 성공

 

KopisApi

package com.nbc.curtaincall.data.model

import retrofit2.http.GET
import retrofit2.http.Query

interface KopisApi {
    @GET("pblprfr")
    suspend fun fetchShowList(
        @Query("stdate") stdate: String = "20240101",
        @Query("eddate") eddate: String = "20240630",
        @Query("cpage") cpage: String = "1",
        @Query("rows") rows: String = "10",
    ): ShowListResponse
}

 

RetrofitClient

package com.nbc.curtaincall.data.api

import com.nbc.curtaincall.BuildConfig
import com.nbc.curtaincall.data.model.KopisApi
import com.tickaroo.tikxml.TikXml
import com.tickaroo.tikxml.retrofit.TikXmlConverterFactory
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit

private const val BASE_URL = "http://kopis.or.kr/openApi/restful/"
private const val KOPIS_API_KEY = BuildConfig.KOPIS_API_KEY

object RetrofitClient {
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY // 로깅 레벨 설정 (BASIC, HEADERS, BODY)
    }

    // API Key 삽입을 위한 인터셉터
    private val apiKeyInterceptor = Interceptor { chain ->
        val original = chain.request()
        val originalHttpUrl = original.url
        val url = originalHttpUrl.newBuilder()
            .addQueryParameter("service", KOPIS_API_KEY)  // Kopis api key 기본 추가
            .build()
        val requestBuilder = original.newBuilder().url(url)
        val request = requestBuilder.build()
        chain.proceed(request)
    }

    // OkHttpClient 설정
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(apiKeyInterceptor)
        .addInterceptor(loggingInterceptor)
        .readTimeout(15, TimeUnit.SECONDS)
        .writeTimeout(15, TimeUnit.SECONDS)
        .connectTimeout(15, TimeUnit.SECONDS)
        .build()


    // Retrofit 설정
    private val retrofit by lazy {
    	//exceptionOnUnreadXml(false).build() 는 원하지 않는 데이터는 제외하기 위해서입니다. 내려받은 데이터를 모두 사용하신다면 추가하지 않아도 됩니다.
        val parser = TikXml.Builder().exceptionOnUnreadXml(false).build()
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(TikXmlConverterFactory.create(parser))
            .build()
    }

    val kopisApi: KopisApi by lazy { retrofit.create(KopisApi::class.java) }
}

 

ShowListResponse 

package com.nbc.curtaincall.data.model

import com.tickaroo.tikxml.annotation.Element
import com.tickaroo.tikxml.annotation.PropertyElement
import com.tickaroo.tikxml.annotation.Xml

@Xml(name = "dbs")
data class ShowListResponse(
    @Element(name = "db")
    val showList: List<ShowList>
)

@Xml(name = "db")
data class ShowList(
    @PropertyElement(name = "area") val area: String?,
    @PropertyElement(name = "fcltynm") val fcltynm: String?,
    @PropertyElement(name = "genrenm") val genrenm: String?,
    @PropertyElement(name = "mt20id") val mt20id: String?,
    @PropertyElement(name = "openrun") val openrun: String?,
    @PropertyElement(name = "poster") val poster: String?,
    @PropertyElement(name = "prfnm") val prfnm: String?,
    @PropertyElement(name = "prfpdfrom") val prfpdfrom: String?,
    @PropertyElement(name = "prfpdto") val prfpdto: String?,
    @PropertyElement(name = "prfstate") val prfstate: String?
)

 

@PropertyElement는 nest child element가 없는 경우 @Element는 nest child element를 가진 경우

어노테이션 부분은 Json을 파싱할 때랑 비슷하다. 

 

공식문서 참고

https://github.com/Tickaroo/tikxml/blob/master/docs/AnnotatingModelClasses.md

 

 

받아오는 XML 형태 

<?xml version="1.0" encoding="UTF-8"?>
<dbs>
    <db>
        <mt20id>PF236104</mt20id>
        <prfnm>싱어게인3 TOP10 전국투어 [울산]</prfnm>
        <prfpdfrom>2024.06.08</prfpdfrom>
        <prfpdto>2024.06.08</prfpdto>
        <fcltynm>KBS홀 [울산]</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236104_240223_151315.gif</poster>
        <genrenm>대중음악</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236099</mt20id>
        <prfnm>화통콘서트 [거창]</prfnm>
        <prfpdfrom>2024.03.09</prfpdfrom>
        <prfpdto>2024.03.09</prfpdto>
        <fcltynm>거창문화센터</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236099_240223_144636.jpg</poster>
        <genrenm>복합</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236098</mt20id>
        <prfnm>한경arte필하모닉 더클래식 시리즈3, 지휘 이병욱 &amp; 바이올린 윤소영</prfnm>
        <prfpdfrom>2024.03.28</prfpdfrom>
        <prfpdto>2024.03.28</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236098_240223_143315.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236097</mt20id>
        <prfnm>퍼커셔니스트 고길영 &amp; 홍진영 세 번째 듀오 리사이틀</prfnm>
        <prfpdfrom>2024.03.31</prfpdfrom>
        <prfpdto>2024.03.31</prfpdto>
        <fcltynm>대전예술의전당</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236097_240223_142644.gif</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236096</mt20id>
        <prfnm>코리아아르츠그룹 베스트 아티스트 시리즈, 1. The Best Sopranos</prfnm>
        <prfpdfrom>2024.03.11</prfpdfrom>
        <prfpdto>2024.03.11</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236096_240223_141522.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236095</mt20id>
        <prfnm>지브리 페스티벌</prfnm>
        <prfpdfrom>2024.05.12</prfpdfrom>
        <prfpdto>2024.05.12</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236095_240223_140619.gif</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236094</mt20id>
        <prfnm>지브리 봄 음악 대축전</prfnm>
        <prfpdfrom>2024.04.18</prfpdfrom>
        <prfpdto>2024.04.18</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236094_240223_140210.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236093</mt20id>
        <prfnm>오페라속 여인들1 [제주 서귀포]</prfnm>
        <prfpdfrom>2024.03.30</prfpdfrom>
        <prfpdto>2024.03.30</prfpdto>
        <fcltynm>서귀포예술의전당</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236093_240223_135545.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236090</mt20id>
        <prfnm>프린세스 공주뮤지컬쇼 [청주]</prfnm>
        <prfpdfrom>2024.04.14</prfpdfrom>
        <prfpdto>2024.04.14</prfpdto>
        <fcltynm>청주예술의전당</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236090_240223_133927.jpg</poster>
        <genrenm>뮤지컬</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236088</mt20id>
        <prfnm>기형도 35주기 추모 행사, 기형도 플레이</prfnm>
        <prfpdfrom>2024.03.22</prfpdfrom>
        <prfpdto>2024.03.22</prfpdto>
        <fcltynm>광명시민회관</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236088_240223_132953.jpg</poster>
        <genrenm>연극</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
</dbs>

 

API 통신응답을 JSON으로 밖에 받아 본 경험 밖에 없어서 XML 을 파싱하는데 많은 시행착오와 씨뻘건 오류 메세지만 이틀간 보면서 씨름 했다. 결국 Json이랑 다를 건 별로 없는데, Xml을 파싱하는 과정이 순조롭지 못 했고 TikXml이 2020년에 commit 된게 마지막이라고 한다. 

 

안드로이드에서는 XmlPullParser 사용을 추천하는 것 같은데 Retrofit을 사용하지 않는 것 같아서 다음에 한 번 알아봐야 겠다.

 

 

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
    }
}

 

728x90

loading 화면을 보여주기 위해 progressBar를 사용하려는데

 

사용법은 이렇게 적혀있었다. 

https://github.com/emreesen27/Android-Nested-Progress

 

settings.gradle.kts(root-Gradle)

?????

 

?????

출처 - https://jitpack.io/#emreesen27/Android-Nested-Progress

 

뭐가 문제인지 빨간줄이 떠서 여러가지 시도를 해보다가 안되서 결국 검색을 해봤다. 

 

해결 방법은 

 

이렇게 바꿔주고 Sync Project를 해주면 잘 된다.

 

 

 

사용 라이브러리 github 

https://github.com/emreesen27/Android-Nested-Progress

 

GitHub - emreesen27/Android-Nested-Progress: Nested Circular Progress Library For Android

Nested Circular Progress Library For Android. Contribute to emreesen27/Android-Nested-Progress development by creating an account on GitHub.

github.com

 

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
}
728x90

스코프 종류

LifecycleScope

GlobalScope은 topLevel 코루틴 이고 앱 라이프사이클중 항상 살아있음. LifecycleScope은 현재 라이프 사이클에서만 살아있는 코루틴 

ViewModelScope

Viewmodel이 살아있는 동안 해당 뷰모델 객체의 생명주기를 따름

 

CoroutineScope(Dispachers)

작업이 끝나면 생명주기가 끝남

LifecycleOwner.lifecycleScope

해당 라이프사이클오너 객체의 생명주기를 따름

GlobalScope

앱의 생명주기를 따름

Dispachers

코루틴이 할당될 스레드풀 종류. 프레임워크 종속적임

Default

안드로이드 기본 스레드풀. CPU를 많이 쓰는 작업에 최적화 (데이터 정렬, 복잡한 연산 등...)

IO

데이터 입출력 작업에 최적화 (네트워킹, 파일 입출력 등...)

Main

안드로이드 메인 스레드. UI작업만 하는 게 좋음
안드로이드의 Looper.getMainLooper() 메서드를 사용 → 프레임워크 종속

Unconfined

자신을 호출한 context에서 돌아가다가 한번 정지가 되고 나면 해당 정지를 수행한 스레드로 옮겨가서 재동작한다.

사용 형태

launch { }

리턴값을 받을 필요 없는 작업시 사용 (==블로킹 불가)
Job이라는 리턴값이 있는데 이걸로 cancel()과 join() 가능

CoroutineScope(Dispachers.IO).launch { 
    // do something
}

async { }

리턴값을 받아 후속 작업을 해야 하는 작업시 사용 (==블로킹 가능)

private suspend fun getTrendingVideos(): List<Item> =
        withContext(Dispatchers.IO) {
            val responseData = async { RetrofitInstance.api.getTrendingVideos().items }
            responseData.await()
        }

 

Async를 사용했기때문에 deferred값을 리턴한다. deferred값을 리턴할 때는 await을 사용해줘야 한다. await은 스레드를 방해하지 않고 deferred값이 계산될 때까지, 기다리게 하는 함수.

 

  • async 새로운 coroutine을 시작하고 GlobalScope.launch과 비슷하지만 ,  GlobalScope.launch처럼 job(백그라운드 작업)을 리턴하지않고 Deferred를 리턴함 
  • await() 비동기 함수의 실행을 일시 중지하고, 호출된 비동기 작업의 완료를 기다린다. 완료되면, 결과를 가져오고 코루틴의 실행을 재개한다.
728x90

이번 심화주차 팀 프로젝트로 YouTube API를 이용한 앱을 만들기로 했다. 

 

내가 맡은 부분은 가장 인기 있는 비디오 리스트를 api로 받아오고, 카테고리 별 비디오 리스트를 분류, 해당 카테고리의 채널 썸네일을 보여주는 작업을 맡았다.

 

우선 TrendFramgnet 에서 한국에서 가장 인기있는 영상들을 불러오는 작업을 했다.  

TrendFragment에서 영상 썸네일 받아오는 부분 / 화면 스크롤 

 

 

RetrofitInstance

더보기
package com.brandon.playvideo_app.data.api

import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

private const val BASE_URL = "https://www.googleapis.com/youtube/v3/"

object RetrofitInstance {
    private val retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            }).build())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    val api: YouTubeApi by lazy { retrofit.create(YouTubeApi::class.java) }
}

 

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
}

TrendViewModel

더보기
package com.brandon.playvideo_app.data.model

import com.google.gson.annotations.SerializedName

data class TrendVideoModel(
    @SerializedName("etag")
    val etag: String,
    @SerializedName("items")
    val items: List<Item>,
    @SerializedName("kind")
    val kind: String,
    @SerializedName("nextPageToken")
    val nextPageToken: String,
    @SerializedName("pageInfo")
    val pageInfo: PageInfo
)

data class Item(
    @SerializedName("etag")
    val etag: String,
    @SerializedName("id")
    val id: String,
    @SerializedName("kind")
    val kind: String,
    @SerializedName("snippet")
    val snippet: Snippet
)

data class Snippet(
    @SerializedName("categoryId")
    val categoryId: String,
    @SerializedName("channelId")
    val channelId: String,
    @SerializedName("channelTitle")
    val channelTitle: String,
    @SerializedName("defaultAudioLanguage")
    val defaultAudioLanguage: String,
    @SerializedName("defaultLanguage")
    val defaultLanguage: String,
    @SerializedName("description")
    val description: String,
    @SerializedName("liveBroadcastContent")
    val liveBroadcastContent: String,
    @SerializedName("localized")
    val localized: Localized,
    @SerializedName("publishedAt")
    val publishedAt: String,
    @SerializedName("tags")
    val tags: List<String>,
    @SerializedName("thumbnails")
    val thumbnails: Thumbnails,
    @SerializedName("title")
    val title: String
)

data class Thumbnails(
    @SerializedName("default")
    val default: Default,
    @SerializedName("high")
    val high: High?,
    @SerializedName("maxres")
    val maxres: Maxres?,
    @SerializedName("medium")
    val medium: Medium?,
    @SerializedName("standard")
    val standard: Standard?
)

data class Standard(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class PageInfo(
    @SerializedName("resultsPerPage")
    val resultsPerPage: Int,
    @SerializedName("totalResults")
    val totalResults: Int
)

data class Medium(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class Maxres(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class Localized(
    @SerializedName("description")
    val description: String,
    @SerializedName("title")
    val title: String
)

data class High(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class Default(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

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.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.databinding.ToolbarCommonBinding
import com.brandon.playvideo_app.databinding.TrendFragmentBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber

class TrendFragment : Fragment() {
    private var _binding: TrendFragmentBinding? = null
    private val binding get() = _binding!!
    private lateinit var videoAdapter: VideoAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = TrendFragmentBinding.inflate(inflater, container, false)
        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 -> {
                    true
                }
                // 다른 메뉴 아이템에 대해서도 필요한 경우 추가할 수 있음
                else -> false
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        _binding = null
    }

    private fun initRecyclerView() {
        lifecycleScope.launch {
            withContext(Dispatchers.IO) {
            //API 통신 부분 
                val responseData = RetrofitInstance.api.getTrendingVideos().items
                videoAdapter = VideoAdapter(responseData)
            }
            binding.recyclerView.apply {
                adapter = videoAdapter
                layoutManager =
                    LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
            }

        }
    }
}

VideoAdapter

더보기
package com.brandon.playvideo_app.ui.trend

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.ItemTrendBinding
import com.bumptech.glide.Glide

class VideoAdapter(private val items: List<Item>) :
    RecyclerView.Adapter<VideoAdapter.VideoHolder>() {
    inner class VideoHolder(private val binding: ItemTrendBinding) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(item: Item) {
            with(binding) {
                tvVideoTitle.text = item.snippet.title
                tvChannelTitle.text = item.snippet.channelTitle
                //Glide 라이브러리 사용 
                Glide.with(itemView).load(item.snippet.thumbnails.maxres?.url ?: item.snippet.thumbnails.default.url).into(ivThumbnail)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoHolder {
        val inflater =
            parent.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        val binding = ItemTrendBinding.inflate(inflater, parent, false)
        return VideoHolder(binding)
    }

    override fun getItemCount() = items.size

    override fun onBindViewHolder(holder: VideoHolder, position: Int) {
        holder.bind(items[position])
    }
}

item_trend.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="wrap_content">

    <ImageView
        android:id="@+id/iv_thumbnail"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="6dp"
        android:adjustViewBounds="true"
        android:minHeight="300dp"
        android:scaleType="fitXY"
        app:layout_constraintTop_toBottomOf="@id/tv_channel_title" />

    <TextView
        android:id="@+id/tv_video_title"
        style="@style/video_title_style"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="22dp"
        android:layout_marginTop="12dp"
        android:layout_marginBottom="12dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/iv_thumbnail"
        tools:text="The Beauty of Existence - Heart Touching Nasheed" />

    <TextView
        android:id="@+id/tv_channel_title"
        style="@style/channel_title_style"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="22dp"
        app:layout_constraintBottom_toTopOf="@id/iv_thumbnail"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="channelTitle" />


</androidx.constraintlayout.widget.ConstraintLayout>

종속성 추가

    // retrofit2
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation ("com.squareup.okhttp3:logging-interceptor:4.12.0")
    implementation ("com.google.code.gson:gson:2.10.1")

    // Glide
    implementation("com.github.bumptech.glide:glide:4.16.0")

    // 코루틴
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")

    // navigation
    implementation("androidx.navigation:navigation-fragment-ktx:2.7.6")
    implementation("androidx.navigation:navigation-ui-ktx:2.7.6")

 

 

youtube data v3 를 사용했고, API 사용 방법은 아래 링크를 참고 했다. 

https://developers.google.com/youtube/v3/docs?apix=true&hl=ko

 

API Reference  |  YouTube Data API  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. API Reference 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. YouTube Data API를 사용하면 YouTube 웹사이트에

developers.google.com

 

 

+ Recent posts