채널프래그먼트 안에서 활용하는 데이터들이 프래그먼트 간에 공유 될 수 있도록 ChannelViewModel을 만들었다.
class ChannelViewModel : ViewModel() {
//채널다이얼로그에서 사용하는 채널리스트. isSubscirbed의 변경여부를 관찰한다.
private val _channelList : MutableLiveData<List<ChannelItem>> = MutableLiveData()
val channelList: LiveData<List<ChannelItem>> get() = _channelList
//채널리스트중에서 구독중인 채널만 관리하여 채널프래그먼트에 보여주는 리스트
private val _subscriptionList : MutableLiveData<List<ChannelItem>> = MutableLiveData(mutableListOf())
val subscriptionList : LiveData<List<ChannelItem>> get() = _subscriptionList
//선택한 채널의 비디오만 담는 리스트
private val _channelVideo : MutableLiveData<List<SearchItem>> = MutableLiveData()
val channelVideo: LiveData<List<SearchItem>> = _channelVideo
fun addChannelList(dataList: List<ChannelItem>) {
_channelList.value = dataList
}
fun addChannelVideo(item: SearchItem) {
_channelVideo.value = _channelVideo.value?.toMutableList()?.apply {
add(item)
} ?: mutableListOf()
}
fun addSubscription(channelData: ChannelItem) {
val distinctList = _subscriptionList.value?.toMutableList()?.apply {
if (!contains(channelData)) {
add(0,channelData)
}
}
_subscriptionList.value = distinctList?.distinct() ?: emptyList()
}
fun removeSubscription(channelID : String) {
_subscriptionList.value = _subscriptionList.value?.toMutableList()?.apply {
val removeItem = find { it.channelId == channelID }
remove(removeItem)
} ?: mutableListOf()
}
//해당 채널의 비디오만 담을 수 있도록 기존의 비디오리스트들을 깨끗히 비워준다.
fun clearVideo() {
_channelVideo.value = emptyList()
}
}
addSubscription의 경우 채널이 중복 추가 되는 경우를 방지하고자 distinct를 활용했다. 원래는 개인과제 때 처럼 Set을 활용하려고 했는데, 이번에는 꼭 0번 인덱스 추가 기능을 사용하고 싶기도 했고, 개인적으로 덜 헷갈리려고..ㅎㅎ List로 최대한 통일 시켰기 때문에 이렇게 만들게 되었다. 개인적으로 아주 만족스럽다.기존에 하드코딩으로 박아놨던 채널 다이얼로그의 채널 리스트를 데이터화 시켜서 리사이클러뷰로 연결할 수 있도록 바꿨다.
하드코딩으로 바꾸면 각 채널의 구독 버튼 하나하나마다 똑같은 코드를 걸어줘야해서 중복이 너무 많아지지만, 리사이클러뷰로 만들면 버튼 하나에만 코드를 만들어 놓으면 각 데이터에 맞춰서 작동이 가능해진다.
다만, 유튜브API할당량과 이런 저런 조건의 문제로, 채널리스트 데이터는 가져오지 않고, 직접 만들어서 관리하기로 했다.
다이얼로그에도 리사이클러뷰를 걸어야하기 때문에, 기존에 ChannelFragment에 그냥 setDialog함수로 걸어놨던 다이얼로그를 아예 다이얼로그 프래그먼트로 분리하기로 했다.
이 다이얼로그프래그먼트 안에서 리스트데이터를 관리한다.
//ChannelAddDialogFragment.kt
private fun setChannelList() {
val pref = requireContext().getSharedPreferences(Constants.CHANNEL_PREFERENCE_KEY, 0)
val channelIds = pref.all.keys
//ChannelItem 데이터 클래스 형식에 맞도록 직접 데이터들을 작성하여 add해주었다.
channelList.add(ChannelItem(val logo: Int,val title: String,val channelId: String,var isSubscribed: Boolean))
for (channelItem in channelList) {
if (channelIds.contains(channelItem.channelId)) {
channelItem.isSubscribed = true
}
}
channelViewModel.addChannelList(channelList)
}
다이얼로그의 기본기능인 isSubscribed가 false일 때 구독버튼을 클릭하면 구독중으로 버튼상황이 변하고, 구독사항을 저장해주는 SharedPreference에 저장하고, 실제로 채널프래그먼트에서 관리하는 구독리스트에 값이 추가되고, 그 값에 해당되는 영상들이 같이 나올 수 있도록 만들었다. 물론 isSubscribed가 true일 때에는 반대로 작동될 수 있도록 else문을 사용하여 만들어 주었다.
//ChannelAddDialogFragment.kt
private fun clickSubscription() {
adapter.itemClick = object : ChannelDialogAdapter.ItemClick {
override fun onClick(view: View, position: Int) {
//isSubscribed가 false라면
if (!adapter.currentList[position].isSubscribed) {
//해당아이템의 isSubscribed를 true로 바꾸고 (화면에서버튼의 상태가 구독중으로 바뀐다.)
channelList[position].isSubscribed = true
//채널프래그먼트의 구독리스트에 추가하고
channelViewModel.addSubscription(adapter.currentList[position])
//SharedPreference에도 저장하고
saveChannel(channelList[position])
//해당하는 채널아이디의 영상이 채널프래그먼트의 영상리스트에 추가되도록 한다.
retrofitViewModel.videoItems.observe(viewLifecycleOwner, Observer {
channelViewModel.clearVideo()
it.forEach { index ->
index.value.forEach { item ->
if (item.snippet.channelId == adapter.currentList[position].channelId) {
channelViewModel.addChannelVideo(item)
}
}
}
})
} else {
channelList[position].isSubscribed = false
channelViewModel.removeSubscription(channelList[position].channelId)
removeChannel(channelList[position])
channelViewModel.clearVideo()
retrofitViewModel.videoItems.observe(viewLifecycleOwner, Observer {
channelViewModel.clearVideo()
val subscriptionList = channelViewModel.subscriptionList.value
if (!subscriptionList.isNullOrEmpty()) {
val firstKey = subscriptionList[0].channelId
it.forEach { index ->
index.value.forEach { item ->
if (item.snippet.channelId == firstKey) {
channelViewModel.addChannelVideo(item)
}
}
}
}
})
}
}
}
}
//SharedPreference에 저장
private fun saveChannel(item: ChannelItem) {
val pref = requireContext().getSharedPreferences(Constants.CHANNEL_PREFERENCE_KEY, 0)
val edit = pref?.edit()
val subscriptionChannel = Gson().toJson(item)
edit?.putString(item.channelId, subscriptionChannel)
edit?.apply()
}
//SharedPreference에서 지우기
private fun removeChannel(item: ChannelItem) {
val pref = requireContext().getSharedPreferences(Constants.CHANNEL_PREFERENCE_KEY, 0)
val edit = pref?.edit()
edit?.remove(item.channelId)
edit?.apply()
}
채널추가아이콘을 누르면 다이얼로그가 나타날 수 있도록 클릭이벤트를 만들어주었다.
private fun setDialog() {
binding.linearAddChannel.setOnClickListener {
channelAddDialog.show(requireActivity().supportFragmentManager, "ChannelAddDialog")
}
}
SharedPreference에서 이미 구독중인 채널이 있는 지 값을 가져와서, 있다면 _subscriptionList에 그 값을 추가해준다.
private fun loadSubscription() {
val pref = requireContext().getSharedPreferences(Constants.CHANNEL_PREFERENCE_KEY, 0)
val allItems: Map<String, *> = pref.all
for ((_, value) in allItems) {
if (value is String) {
val channelData = Gson().fromJson(value, ChannelItem::class.java)
channelViewModel.addSubscription(channelData)
}
}
}
채널프래그먼트에서는 구독 중인 채널 리스트의 리사이클러뷰, 구독 중인 채널 리스트의 비디오가 나오는 리사이클러뷰. 이렇게 2개의 리사이클러뷰가 있다.
private fun setChannelAdater() {
channelAdapter = ChannelAdapter()
binding.rvChannelTitle.adapter = channelAdapter
binding.rvChannelTitle.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
channelViewModel.subscriptionList.observe(viewLifecycleOwner, Observer {
channelAdapter.submitList(it.toList())
})
}
private fun setVideoAdater() {
channelViewModel.channelVideo.observe(viewLifecycleOwner, Observer {
videoAdapter = ChannelVideoAdapter(it)
binding.rvVideo.adapter = videoAdapter
binding.rvVideo.layoutManager = LinearLayoutManager(context)
})
}
ChannelAdapter와 ChannelVideoAdapter를 연결하고 있고, 이 중 ChannelVideoAdapter는 생성자로 List<SearchItem>을 직접 받아서 해당 SearchItem의 동영상썸네일, 동영상제목을 직접 연결해준다.LinearLayoutManager.HORIZONTAL을 추가해주었다. 그 뒤에 false는 아이템의 순서가 역순이 되지 않도록 해주는 것이라고 한다.초기화 화면에서 바로 영상이 나올 수 있도록 함수를 만들어줬다.
private fun getFirstVideo() {
retrofitViewModel.videoItems.observe(viewLifecycleOwner, Observer {
channelViewModel.clearVideo()
val subscriptionList = channelViewModel.subscriptionList.value
if (!subscriptionList.isNullOrEmpty()) {
val firstKey = subscriptionList[0].channelId
it.forEach { index ->
index.value.forEach { item ->
if (item.snippet.channelId == firstKey) {
channelViewModel.addChannelVideo(item)
}
}
}
}
})
}
_channelVideo에 담겨서 리사이클러뷰에서 보여질 수 있도록 만들었다.구독 중인 채널 리스트에서 해당 채널을 클릭하면 클릭한 채널의 영상이 나올 수 있도록 만들었다. 위의 getFirstVideo()는 이 코드를 바탕으로 만들어졌다.
private fun getChannelVideo() {
channelAdapter.itemClick = object : ChannelAdapter.ItemClick {
override fun onClick(view: View, position: Int) {
retrofitViewModel.videoItems.observe(viewLifecycleOwner, Observer {
channelViewModel.clearVideo()
it.forEach { index ->
index.value.forEach { item ->
if (item.snippet.channelId == channelAdapter.currentList[position].channelId) {
channelViewModel.addChannelVideo(item)
}
}
}
})
}
}
}
clearVideo()를 사용하여 _channelVideo를 비워준 후, retrofitViewModel에서 관리하는 전체 비디오 데이터리스트의 채널아이디와 클릭한 채널아이템의 채널아이디를 비교해서 일치하는 영상만 _channelVideo에 추가해준다.리스트의 길이가 길기 때문에, 스크롤을 최상단으로 올려주는 플로팅 버튼을 만들었다. 이 코드는 심화 과정 때 배웠던 코드를 바탕으로 만들었다.
private fun clickFAB() {
val fadeIn = AlphaAnimation(0f, 1f).apply { duration = 500 }
val fadeOut = AlphaAnimation(1f, 0f).apply { duration = 500 }
var isTop = true
binding.btnFloating.setOnClickListener {
binding.rvVideo.smoothScrollToPosition(0)
}
binding.rvVideo.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
if (!binding.rvVideo.canScrollVertically(-1)
&& newState == RecyclerView.SCROLL_STATE_IDLE
) {
binding.btnFloating.startAnimation(fadeOut)
binding.btnFloating.visibility = View.GONE
isTop = true
} else {
if (isTop) {
binding.btnFloating.visibility = View.VISIBLE
binding.btnFloating.startAnimation(fadeIn)
isTop = false
}
}
}
})
}
fadeIn fadeOut애니메이션을 추가했고, 최상단일 경우에는 나타나지 않았다가, 스크롤이 하단으로 움직였을 때만 플로팅 버튼이 등장하도록 했다.연휴가 끼어있는 팀플. 믿기지 않겠지만 나는 연휴 4일 전부 코드를 만졌지만, 중간에 프로젝트의 방향성 자체가 왔다갔다하기도 하고, 유튜브API 할당량 문제 등으로 여러가지 논의 끝에, 영화관련 영상에 한정되어있어야 하는 우리 어플의 특성 상 할당량 100인 Search API만 활용하되, 한번 불러온 값을 (SharedPreference에)저장해서 활용하는 방법을 사용하자고 하셨다.
오늘 만든 부분들은 전부가 트러블슈팅이라고 봐도 무방하다. 개인과제와 이전 과제들 때 만든 코드들을 최대한 활용하고, 팀원 분들이 만들어주신 베이스코드들을 바탕으로 구글링과 오픈AI들의 많은 도움으로 겨우 만들 수 있었다.........
다이얼로그프래그먼트에서 그냥 프래그먼트로 구독여부에 대한 정보를 넘기는 것에 대해서도 어떻게 해야하나 고민했었다. intent같은걸 사용해야 하나 했다가, 그냥 개인과제때의 기억을 되살려 뷰모델을 활용하는 방법을 사용했는데 오히려 직관적?이고 좋은 것 같다.
채널에 관련된 것들은 내가 개인적으로 필요한 정보만 뽑아서 ChannelItem으로 관리했기 때문에 기존의 리사이클러뷰들과 똑같은 방법으로 연결이 가능했는데, 비디오리스트를 가져오는 것은 SearchVideo의 SearchItem을 가져와서 사용해야하는 것이라 접근하는 것이 좀 까다로웠다. 심지어 RetrofitViewModel에서 관리하는 전체 비디오 데이터가 Map형태로 저장되어있었기 때문에, 그 값들을 불러와서 그 값 안에 있는 채널아이디나 동영상타이틀을 접근하는 것이 좀 쉽지 않았던 것 같다.
그리고 좀 바보같기도 했는데, channelViewModel.clearVideo()를 어느 위치에 두는 것도 아주 중요한 문제라는 것을 알게 되었다.ㅎㅎ 처음에는 channelViewModel.addChannelVideo(item) 코드 바로 위에 같이 붙여놔서 영상이 딱 한개만. 불러와지는 희한한 경험을 했다.ㅎㅎ..ㅎㅎ..ㅎㅎㅎㅎ 금새 문제점을 깨달아서 고쳐서 망정이지 영상이 뜨긴 뜨는데 하나만 뜬다? 그 당시의 황망한 감정이 생생하다.
xml에서 다이얼로그프래그먼트를 리사이클러뷰로 바꾸고 또 스크롤이 끝까지 안되는 문제가 발생헀다. 이건 어떻게 매번 만나게 되는지 모르겠다. 이번에는 marginBottom이 안먹혀서 paddingBottom을 주었는데 영향이 있어서 heigh=0dp를 주고 paddingBottom = 160dp로 많이.. 주어서 해결했다. 동영상이 커서 그런건지 아니면 뭐 때문인지 늘 100 이상은 주어야 그나마 티가 나는 것 같다.
그 채널프래그먼트가 열리자마자 처음 동영상을 가져오는 것도 SharedPreference에서 일치하는 값을 찾아오려고 했는데, SharedPreference는 Map으로 저장하기 때문에 딱히 순서가 있는 컬렉션이 아니라서 어느 값으로 가져와야 구독중인 채널리스트와 일치하나 고민했었는데, 고민한게 바보같이 그냥 말그대로 구독중인 채널리스트의 첫번째 값과 일치하는 채널아이디의 영상들을 가져오도록 만들면 되었다. distinct를 활용한 일반 List를 사용하기를 잘했다고 가장 크게 뿌듯했던 순간이었다.