블로그 글 읽으면서 유의점
이 글에 있는 내용중에서 오류가 있을수도 있습니다. 그점을 감안해서 봐주시면 감사하겠습니다.
오류가 있다면 댓글로 남겨주세요! 감사합니다.
소개
현재 개발 팀원은 세명으로 이루어져 있는데, 요즈음 안드로이드네이티브로 이루어진 쇼핑몰을 만들고 있습니다.
평화롭게 코딩을 하고 있는 오후 신입개발자가 헬프를 불렀습니다. '스크롤이 안되는 것에 대해서 아무리 고민을 해봐도 해결이 안되서 같이 고민을 해달라.' 간략하게 영상으로 보여주자면,
기능은 이렇다. 상세정보, 리뷰, 상품문의 버튼을 클릭하게 되면 해당 칸에 맞는곳으로 자동으로 스크롤을 해주는 기능이였다. 그런데! 팀원이 AAC 네비게이션 프래그먼트를 사용하는데, 첫 시작은 잘 되나, 다른 프래그먼트로 갔다가 다시 돌아오면 스크롤이 안된다는 제보를 받게 되었다.
스크롤 담당 코드
private class TabSelectedListener(val v: View) : TabLayout.OnTabSelectedListener {
val tabLayoutHeight by lazy { v.tabl__mpd__mainContent.height }
override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabReselected(tab: TabLayout.Tab?) {
initTabSelectedEvent(tab)
}
override fun onTabSelected(tab: TabLayout.Tab?) {
initTabSelectedEvent(tab)
}
fun initTabSelectedEvent(tab: TabLayout.Tab?) {
tab?.run {
when (position) {
0 -> {
v.stickyScrollView__mpd.smoothScrollTo(0, v.tabl__mpd__mainContent.top)
Log.d("ttt", "onTabSelected: 상세정보")
}
1 -> {
Log.d("ttt", "onTabSelected: 리뷰")
}
2 -> {
v.stickyScrollView__mpd.smoothScrollTo(0, v.tv__mpd__rowTab__productInquiry.top - tabLayoutHeight)
Log.d("ttt", "onTabSelected: 상품문의")
}
else -> {
Log.d("ttt", "onTabSelected: ???")
}
}
}
}
}
탭 레이아웃이 선택되었을때 동작하는 리스너를 클래스로 등록을 해서 프래그먼트가 생성되는 시점에서 lazy로 사용하는 변수에 넣어준다.
탭 레이아웃 리스너 변수
private val tabSelectedListener by lazy {
TabSelectedListener(v) }
또한 프래그먼트가 생성되고 탭 레이아웃 리스너를 사용하는 시점에서 lazy 초기화를 해주게 된다.
해당 코드
package com.app.abee.mvvm.abee_mall.child.product_detail.fragments
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.NavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.MergeAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.amar.library.ui.interfaces.IScrollViewListener
import com.app.abee.R
import com.app.abee.mvvm.abee_mall.child.product_detail.adapter.MallProductDetailMainThumbnailVp2Adapter
import com.app.abee.mvvm.abee_mall.child.product_detail.adapter.MallProductDetailUpPanelOption1RcvAdapter
import com.app.abee.mvvm.abee_mall.child.product_detail.adapter.MallProductDetailUpPanelOption2RcvAdapter
import com.app.abee.mvvm.abee_mall.child.product_detail.adapter.MallProductInfoRcvAdapter
import com.app.abee.mvvm.abee_mall.child.product_detail.interactor.MallProductDetail_Interactor
import com.app.abee.mvvm.abee_mall.child.product_detail.view_model.MallProductDetailViewModel
import com.app.abee.mvvm.abee_mall.interactor.Mall_Interactor
import com.app.abee.mvvm.base.extension.getNavController
import com.app.abee.mvvm.base.extension.toHtml
import com.app.abee.mvvm.utils.extension.getActivityViewModel
import com.app.abee.mvvm.utils.extension.makeGoneView
import com.app.abee.mvvm.utils.extension.showToast
import com.app.abee.mvvm.utils.extension.showView
import com.google.android.material.tabs.TabLayout
import com.sothree.slidinguppanel.SlidingUpPanelLayout
import kotlinx.android.synthetic.main.fragment_mall_product_detail.view.*
import kotlinx.android.synthetic.main.item_mall_product_summary.view.*
import kotlinx.android.synthetic.main.layout_mall_product_detail_panel.view.*
class MallProductDetailFragment : Fragment() {
private lateinit var v: View
private val interactor by lazy { getActivityViewModel<Mall_Interactor>() }
private val fragmentInteractor by lazy { getActivityViewModel<MallProductDetail_Interactor>() }
private val nc: NavController by lazy { getNavController() }
private val vm by lazy { getActivityViewModel<MallProductDetailViewModel>() }
private val tabSelectedListener by lazy {
Log.d("ttt", "v 주소: ${v.toString()}")
TabSelectedListener(v) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
v = inflater.inflate(R.layout.fragment_mall_product_detail, container, false)
Log.d("ttt", "onCreateView: ")
// 루트 레이아웃.
init(v, vm)
// 업 패널 초기화.
initPanelLayout(v, vm)
return v
}
override fun onDestroyView() {
super.onDestroyView()
vm.invalidate()
}
// 기본 레이아웃
private fun init(v: View, vm: MallProductDetailViewModel) {
v.apply {
// summary UI Data 연결.
init_SummaryView(v, vm)
// 탭레이아웃.
init_TabLayout(v)
// 상단 메인 썸네일 뷰페이저2
init_MainThumbNail(v)
// 아이템 상세 이미지 리싸이클러 뷰
init_ItemImageRecyclerView(v)
// 스크롤뷰 이벤트 초기화.
init_StickyScrollView_Event(v)
// 상품문의로 보내기.
tv__mpd__rowTab__productInquiry.setOnClickListener { nc.navigate(R.id.action_mallProductDetailFragment_to_mallProductInquiryListFragment) }
// 이용약관으로 보내기.
tv__mpd__rowTab__usingTerms2.setOnClickListener { nc.navigate(R.id.action_global_mallUsingTermsAndPrivacyPolicyFragment) }
// 배송/교환/반품으로 보내기.
tv__mpd__rowTab__usingTerms1.setOnClickListener { nc.navigate(R.id.action_global_mallDeliveryAndExchangeAndReturnTermsFragment) }
// 업 패널 백그라운드 터치로 못닫게 하기.
slidingUpPanelLayout__mpd.isTouchEnabled = false
// 업 패널 상단 아이콘 터치시 업 패널 비노출.
iv__mpdp__panelToggle.setOnClickListener {
slidingUpPanelLayout__mpd.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED
include__mpd__panel.makeGoneView()
}
// 구매하기 버튼 터치시 업 패널 노출.
btn__mpd__buy.setOnClickListener {
include__mpd__panel.showView()
slidingUpPanelLayout__mpd.panelState = SlidingUpPanelLayout.PanelState.EXPANDED
}
}
}
private fun init_StickyScrollView_Event(v: View) {
// 스크롤뷰 위치감지.
v.stickyScrollView__mpd.scrollViewListener = object : IScrollViewListener {
override fun onScrollStopped(isStoped: Boolean) {}
override fun onScrollChanged(x: Int, y: Int, oldX: Int, oldY: Int) {
Log.d("ttt", "onScrollChanged: $x $y $oldX $oldY")
val tabLayout = v.tabl__mpd__mainContent
val itemImageRecyclerView = v.nsv__mpd__contentDetail
when (y + tabLayout.height) {
in itemImageRecyclerView.top..itemImageRecyclerView.bottom -> {
tabLayout.apply {
removeOnTabSelectedListener(tabSelectedListener)
tabLayout.getTabAt(0)?.select()
}
}
in v.tv__mpd__rowTab__productInquiry.top..v.tv__mpd__rowTab__productInquiry.bottom -> {
tabLayout.apply {
removeOnTabSelectedListener(tabSelectedListener)
tabLayout.getTabAt(2)?.select()
}
}
} // when
init_TabLayout(v)
Log.d("ttt", "onScrollChanged: removeOnTabSelectedListener")
}
}
}
private fun init_SummaryView(v: View, vm: MallProductDetailViewModel) =
v.in__mpd__summary.apply {
with(vm.testDataList.first()) {
tv__mps__title.text = pName
tv__mps__price.setText(getString(R.string.mall_price_cancelLine, price).toHtml())
tv__mps__discountPriceData.text = getString(R.string.koreanCurrency_suffix_string, discountPrice)
tv__mps__savePointData.text = savePoint.toString()
}
}
private fun init_ItemImageRecyclerView(v: View) =
v.rcv__mpd__contentDetail.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = MallProductInfoRcvAdapter()
setOnTouchListener { _, _ -> true }
}
private fun init_MainThumbNail(v: View) =
v.vp2__mpd__mainThumbnail.apply {
orientation = ViewPager2.ORIENTATION_HORIZONTAL
adapter = MallProductDetailMainThumbnailVp2Adapter()
}
private fun init_TabLayout(v: View) =
v.tabl__mpd__mainContent.addOnTabSelectedListener(tabSelectedListener)
private class TabSelectedListener(val v: View) : TabLayout.OnTabSelectedListener {
val tabLayoutHeight by lazy { v.tabl__mpd__mainContent.height }
override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabReselected(tab: TabLayout.Tab?) {
initTabSelectedEvent(tab)
}
override fun onTabSelected(tab: TabLayout.Tab?) {
initTabSelectedEvent(tab)
}
fun initTabSelectedEvent(tab: TabLayout.Tab?) {
tab?.run {
when (position) {
0 -> {
v.stickyScrollView__mpd.smoothScrollTo(0, v.tabl__mpd__mainContent.top)
Log.d("ttt", "onTabSelected: 상세정보")
}
1 -> {
Log.d("ttt", "onTabSelected: 리뷰")
}
2 -> {
v.stickyScrollView__mpd.smoothScrollTo(0, v.tv__mpd__rowTab__productInquiry.top - tabLayoutHeight)
Log.d("ttt", "onTabSelected: 상품문의")
}
else -> {
Log.d("ttt", "onTabSelected: ???")
}
}
}
}
}
// 업 패널 레이아웃
private fun initPanelLayout(v: View, vm: MallProductDetailViewModel) {
v.include__mpd__panel.apply {
// 총 상품가격 갱신요청 옵저버.
fragmentInteractor.invalidateTotalPrice.observe(viewLifecycleOwner, Observer {
invalidateTotalPrice(v, vm)
})
val selectedFirstOptionList = vm.selectedFirstOptionsList
val selectedSecondOptionList = vm.selectedSecondOptionsList
var firstOptionAdapter: MallProductDetailUpPanelOption1RcvAdapter? = null
var secondOptionAdapter: MallProductDetailUpPanelOption2RcvAdapter? = null
val pdAdapterDataObserver = ProductDetailAdapterDataObserver { invalidateTotalPrice(v, vm) } // 선택된 상품옵션 삭제시 작동.
// 선택옵션 표시 리사이클러 뷰.
rcv__mpdp__selectedOption.apply {
layoutManager = LinearLayoutManager(requireContext())
}
// 기본옵션 유형선택 스피너.
run {
val items = vm.testDataList.first().options.map { it.optionName }
spinner__mpdp__basicOptions.apply {
setHint("기본옵션 유형선택")
setAdapter(ArrayAdapter(requireContext(), R.layout.custom_dropdown_item, items))
setOnItemClickListener(AdapterView.OnItemClickListener { parent, view, position, id ->
val selectedOption = vm.testDataList.first().options[position]
if (selectedOption.selectedCount == 0) selectedFirstOptionList.add(selectedOption)
selectedOption.selectedCount++
firstOptionAdapter = MallProductDetailUpPanelOption1RcvAdapter(selectedFirstOptionList, fragmentInteractor)
.also { adapter -> adapter.registerAdapterDataObserver(pdAdapterDataObserver) }
v.rcv__mpdp__selectedOption.refreshRcv(firstOptionAdapter, secondOptionAdapter)
invalidateTotalPrice(v, vm)
})
}
}
// 추가옵션 유형선택 스피너.
run {
val items = vm.testDataList.first().options2.map { it.optionName }
spinner__mpdp__additionalOptions.apply {
setHint("추가옵션 유형선택")
setAdapter(ArrayAdapter(requireContext(), R.layout.custom_dropdown_item, items))
setOnItemClickListener(AdapterView.OnItemClickListener { parent, view, position, id ->
val selectedOption = vm.testDataList.first().options2[position]
if (selectedOption.selectedCount == 0) selectedSecondOptionList.add(selectedOption)
selectedOption.selectedCount++
secondOptionAdapter = MallProductDetailUpPanelOption2RcvAdapter(selectedSecondOptionList, fragmentInteractor)
.also { adapter -> adapter.registerAdapterDataObserver(pdAdapterDataObserver) }
v.rcv__mpdp__selectedOption.refreshRcv(firstOptionAdapter, secondOptionAdapter)
invalidateTotalPrice(v, vm)
})
}
}
// 바로구매 버튼.
btn__mpdp__buy.setOnClickListener {
Log.d("ttt", "initPanelLayout: 구매하기 버튼 터치됨")
showToast("(예정) 결제창으로 넘어가기")
}
}
}
class ProductDetailAdapterDataObserver(val callback: () -> Unit) : RecyclerView.AdapterDataObserver() {
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
super.onItemRangeChanged(positionStart, itemCount)
callback.invoke()
}
}
// 업 패널) 선택옵션 총합 가격 표시.
private fun invalidateTotalPrice(v: View, vm: MallProductDetailViewModel) = v.apply {
tv__mpdp__totalCost__data.text = getString(R.string.koreanCurrency_suffix_string, vm.selectedOptionsTotalPrice)
}
// 업 패널) 리사이클러 뷰 새로고침.
private fun RecyclerView.refreshRcv(firstAdapter: MallProductDetailUpPanelOption1RcvAdapter?, secondAdapter: MallProductDetailUpPanelOption2RcvAdapter?) {
val rcv = this
firstAdapter?.run {
secondAdapter?.run {
rcv.adapter = MergeAdapter(firstAdapter, secondAdapter)
} ?: run {
rcv.adapter = firstAdapter
}
}
}
}
몇가지 알아야 할점
우리는 이 문제를 해결하기 위해서 몇가지를 이해하고 넘어가야한다.
1. lazy 키워드는 해당 변수가 사용될때 변수가 초기화 된다.
2. 프래그먼트는 다른 프래그먼트로 넘어갈때 destroy시키고 다른 프래그먼트로 간다.
3. 프래그먼트 생명 주기는
onAttach -> onCreate -> onCreateView -> onActivityCreated -> onStart -> onResume -> 프래그먼트 실행 -> onPause ->onStop -> onDestroyView -> onDestroy -> onDetach -> 프래그먼트 종료
우리가 이번에 주로 봐야할 곳은 onCreateView , OnDestroy이다.
4. AAC네비게이션 프래그먼트 클래스는 상위 액티비티에서 종료하지 않은 이상 계속해서 살아있다.
문제 찾기
우리는 스크롤이 되는가 안되는가를 찾아보았다.
해당 클릭 리스너가 불렸을때 ScrollTo 메소드가 동작하는지를 찾아보았다.
해당 메소드는 언제든지 잘 되는것을 확인할수가 있었다.
하지만 특이한 점을 발견할 수가 있었는데,
해당 프래그먼트에서 다른 프래그먼트로 들어가고 다시 나와서 스크롤을 해도 같은 전에 썻던 뷰를 스크롤해서
Position값은 잘 찍히는것이였다.
우리가 계속 찾아본 결과! 프래그먼트가 생성되고 다른 프래그먼트로 넘어갈때, 클래스는 계속 살아서 돌고 있음을 확인했다. 우리가 몰랐던 점은 클래스는 살아있음에도 불구하고 생명주기는 해당 메소드들이 관리하고 있는점이다.
그래서 우리가 변수 생성하고 사용하고 있는 변수들은 다시 초기화가 되지 않는것 같다.
그래서 우리는 헛발질을 한게 onCreateView에서 뷰를 다시 초기화 시키는 점을 볼 수가있다.
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
v = inflater.inflate(R.layout.fragment_mall_product_detail, container, false)
Log.d("ttt", "onCreateView: ")
// 루트 레이아웃.
init(v, vm)
// 업 패널 초기화.
initPanelLayout(v, vm)
return v
}
override fun onDestroyView() {
super.onDestroyView()
vm.invalidate()
}
// 기본 레이아웃
private fun init(v: View, vm: MallProductDetailViewModel) {
v.apply {
// summary UI Data 연결.
init_SummaryView(v, vm)
// 탭레이아웃.
init_TabLayout(v)
// 상단 메인 썸네일 뷰페이저2
init_MainThumbNail(v)
// 아이템 상세 이미지 리싸이클러 뷰
init_ItemImageRecyclerView(v)
// 스크롤뷰 이벤트 초기화.
init_StickyScrollView_Event(v)
// 상품문의로 보내기.
tv__mpd__rowTab__productInquiry.setOnClickListener { nc.navigate(R.id.action_mallProductDetailFragment_to_mallProductInquiryListFragment) }
// 이용약관으로 보내기.
tv__mpd__rowTab__usingTerms2.setOnClickListener { nc.navigate(R.id.action_global_mallUsingTermsAndPrivacyPolicyFragment) }
// 배송/교환/반품으로 보내기.
tv__mpd__rowTab__usingTerms1.setOnClickListener { nc.navigate(R.id.action_global_mallDeliveryAndExchangeAndReturnTermsFragment) }
// 업 패널 백그라운드 터치로 못닫게 하기.
slidingUpPanelLayout__mpd.isTouchEnabled = false
// 업 패널 상단 아이콘 터치시 업 패널 비노출.
iv__mpdp__panelToggle.setOnClickListener {
slidingUpPanelLayout__mpd.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED
include__mpd__panel.makeGoneView()
}
// 구매하기 버튼 터치시 업 패널 노출.
btn__mpd__buy.setOnClickListener {
include__mpd__panel.showView()
slidingUpPanelLayout__mpd.panelState = SlidingUpPanelLayout.PanelState.EXPANDED
}
}
}
그러니까 onCreateView는 프래그먼트끼리 넘어갈때 또다시 생성해서 다른 뷰를 사용하는데
우리는 전에 lazy초기화 시킨 변수는 다시 뷰를 안바꿔준 것이다.
private val tabSelectedListener by lazy {
TabSelectedListener(v) }
그래서 다른 프래그먼트로 갔다 오면 전에 있던 뷰를 계속 스크롤링 하는것이다.
문제 해결
정상적으로 탭 레이아웃의 버튼들을 누르면 스크롤이 작동하는 모습을 볼수가 있습니다.
우리가 간과하는 점이 클래스,뷰들은 언제 사라지는지, 재사용이 되는지 잘 알아보고 코딩을 해야할것 같습니다.
마치면서
세시간동안 같이 삽질하면서 프래그먼트가 동작하는 방식을 더 배운것 같습니다. 가끔 우리가 오랫동안 고민 한 버그들을 계속 포스팅 해보겠습니다.
'버그리포트' 카테고리의 다른 글
SSH 접속시 RSA 공유키 충돌 문제 (0) | 2021.04.30 |
---|---|
스프링 부트를 자바 9+ 업데이트 한 후에 발생하는 ClassNotFoundException: JAXBException 오류 (0) | 2021.04.29 |