728x90
  1. 사전앱 - 네이버사전 API 활용
  2. 메모앱 - 특별한 기능이 담긴 차별화된 앱 (로그인 기능?)
  3. 대중교통 혼잡도 알림 앱 - 전철, 버스
  4. 영화오피스 목록을 보여주는 앱
  5. 온라인 그룹 스터디 모임 앱
  6. 자취생의 반려동물 커뮤니티 앱
  7. 약 정보 앱
  8. 요리 앱 (만들기, 나만의 레시피)
  9. 실시간 주차 정보 앱
  10. 여행지 추천앱

선정 결과 : 주차장 앱

 

프로젝트명 : 주차마침

목적 : 전국 주차장 현황 정보 제공

소개 : 전국의 주차장의 운영 시간, 주차 가능 여부, 요금 정보, 휴일 운영 여부, 주차장 명 등을 조회하고, 해당 주차장에 대한 리뷰를 달 수 있는 앱

주 타겟층 : 자동차를 운전하는 20~50대 (남녀 누구나)

 

필수핵심 기능

  • 위치기반을 통해 주차장 위치를 보여주는 지도기능 (카카오) - 위치정보 동의 받기
  • 주차장에 필요한 정보를 API로 받아서 클릭했을 때 상세정보 화면이동 후 보여주기
    • 주차장 기본금액, 추가금액 , 1일요금
    • 시설 운영시간( 요일별/ 휴일 )
    • 주차장 면수 및 가능대수 여부 (혼잡도 보여주기) - 색깔 (0 25 50 75 100)

추가 기능

  • 주차장명, 지역명 실시간 검색기능 (키워드)
  • 로그인 (소셜로그인(카카오, 구글 네이버 등) , 일반 회원가입) - 데이터베이스 구축필요
  • 주차장 별 리뷰점수 달기 , 1줄 코멘트 남기기, 사진(이용자 사진을 통해서 주차장 모습 파악)
  • 전기차 충전소 포함 여부 제공 (주차장 내에 충전소가 있는지 여부정도) - ui로 보여주기
  • 즐겨찾기 (지역별)
  • 공항 별 주차장 현황 (기본핵심기능과 비슷)
728x90

가장 인기 비디오를 보여주는 화면

 

  • 가장 인기 있는 영상 리스트를 보여주는 화면 입니다. 스크롤을 밑으로 내리면, 다음 컨텐츠를 계속 불러오도록 되어있고, 플로팅 버튼을 누르면 최상단으로 이동하는 화면으로 구성되어 있습니다.

 

 

카테고리별 영상 / 해당 카테고리 채널 썸네일 보여주는 화면

  • 최초 진입시, 가장 인기 있는 영상들를 보여주고, 칩을 선택하면 해당 카테고리의 영상들을 보여주고 해당 카테고리에 속해있는 채널들을 보여줍니다.

 

구독 화면

  • 구독 채널 정보를 가로 리스트로 보여줍니다구독 채널의 영상들을 세로리스트에 보여줍니다스크롤이 내려가면 상단 툴바가 사라져 더 많은 영상 정보를 한 눈에 확인할 수 있습니다. 구독영상 상세영상 상세 정보를 보여줍니다
  • 영상을 재생합니다
  • 로딩화면을 구현
  • 스프이와이프 새로고침과 무한 스크롤 기능이 구현되어있습니다.
  • 구독 중인 채널이 10일 내 영상을 업로드 한 이력이 있다면 활성화 버튼을 부착합니다.

 

나의 정보 페이지 / 채널 상세 페이지

  • 화면은 프로필, 채널, 그리고 영상 목록으로 구성되어 있습니다.채널과 영상 목록은 Room을 이용하여 데이터베이스에 저장됩니다. 영상 목록에서 아이템을 클릭하면 해당 영상의 제목, 작성자, 조회수, 그리고 설명이 표시되며, 삭제 버튼을 클릭하면 해당 아이템을 목록에서 삭제할 수 있습니다.
  • 또한, 검색 부분에서는 검색 결과의 영상을 클릭하면 해당 영상의 정보가 표시되고, 즐겨찾기 버튼을 클릭하면 그 영상이 라이브러리의 영상 목록에 추가됩니다.
  • 프로필 이미지를 클릭하면 프로필 정보를 수정하는 화면으로 이동할 수 있습니다. 여기서 프로필 이미지, 이름, 그리고 설명을 수정할 수 있습니다. 이 정보들은 Sharedpreferences를 사용하여 저장됩니다.

 

검색 화면 / 검색 결과 화면 / 검색 결과 상세 화면

  • 키워드로 영상들을 검색할 수 있습니다. 리스트 내 채널 클릭 시 채널 상세 페이지로 이동합니다.

 

심화주차 과제를 완성 했다. 이번 심화 팀 과제를 하면서 느낀점은 프로젝트가 커질수록 일정 관리의 어려움이나 작업을 진행하는 동안 협의 되지 않은 세세한 부분들이 있어서 중복되는 부분이나 코드를 수정하는 경우가 있었는데, 협업이 참 어려운 것 같다. 충분히 협의를 했다고 생각 했는데, 예기치 못한 곳에서 충돌이 난다거나 버그가 생기거나 하는 일이 있었다.

다음 협업에서는 더 세심하게 신경써서 일정을 잘 마무리 하고 싶다.  

 

 

 

 

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

 

 

728x90

xml에서 리사이클러뷰를 배치하면

이런식으로 보이는데, 배치할 item의 리스트를 보고싶을 때,

        tools:itemCount="20" //보여줄 item 개수 
        tools:listitem="@layout/item_category" //item Layout 
        
        //이 부분은 리사이클러뷰를 가로 스크롤일 때 주는 옵션 
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:orientation="horizontal"

 

속성을 지정해주면 아래 사진처럼 아이템 목록을 볼 수 있다. 

 

728x90

https://console.cloud.google.com/welcome/new?pli=1&project=youtube-project-46435

 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

1. 클라우드 플랫폼에서 API 키 받기 ---->

 

2. 라이브러리 ---> YouTube Data API v3 ---> API 사용하기 

 

https://console.cloud.google.com/welcome/new?pli=1&project=youtube-project-46435

 

API 사용 문서 

https://developers.google.com/youtube/v3/docs/videos?hl=ko

 

Videos  |  YouTube Data API  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English Videos 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 2020년 7월 28일 이후에 생성된 확인

developers.google.com

 

 

728x90

Model을 제외하고, 

LiveData와 MVVM 패턴의 간단한 예제로 EditText로 text를 받아서 버튼을 누르면 가운데 Change Word!!를 변경하는 코드

 

예제 화면

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplicationmvvm.ViewModel.MyViewModel
import com.example.myapplicationmvvm.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    private val binding : ActivityMainBinding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    private val viewModel : MyViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
        setUpObserve()
        setUpClickListener()
    }
   //옵저빙 셋팅
   private fun setUpObserve(){
       viewModel.writeText.observe(this){
           binding.tvText.text = it
       }
   }
   //클릭 리스너 셋팅
    private fun setUpClickListener(){
        binding.btnButton.setOnClickListener {
            viewModel.getWriteText(binding.EditText.text.toString())
        }
    }
}

 

MyViewModel

class MyViewModel(private val repository: Repository = Repository()) : ViewModel() {
    //방식2
    //private val repository = Repository()
    
    private val _writeText = MutableLiveData<String>()
    val writeText: LiveData<String> get() = _writeText
    
    fun getWriteText(text: String) {
        _writeText.value = repository.getText(text)
    }
}

 

 

Repository

class Repository {
    // 일반적으로 local db나 api 데이터를 호출해온다.
    // 예제에서는 간단히 입력한Text를 호출하는 방식으로 작성함
    fun getText(text: String) = text
}

 

dependencies

    //by viewModels() 를 사용하려면 추가 
    implementation("androidx.activity:activity-ktx:1.8.2")
    implementation("androidx.fragment:fragment-ktx:1.6.2")
    
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")

 

 

 

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=".MainActivity">

    <EditText
        android:id="@+id/EditText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btn_Button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="버튼"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/EditText" />

    <TextView
        android:id="@+id/tv_Text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Change Word!!"
        android:textSize="24sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

+ Recent posts