위 사진과 같은 화면을 개발하기 위해 Persistent Bottom Sheet 를 개발하게 되었다.
사용자의 액션에 따라 바텀시트를 펼치고 닫고 하는 동작이 중요한데 약간 까다로운? 다소 복잡한 요구사항을 만족시켜야 해서 구현하는데에 고민을 좀 했었다.
✔️ 기능 요구사항
- 바텀시트가 열린 상태에서 외부를 클릭하면 바텀시트가 Hidden
- 바텀시트가 열린 상태에서 외부를 드래그하면 바텀시트는 유지되고 드래그만 가능
바텀 시트 외부를 클릭하게 되면 바텀시트 자체가 숨겨져야 하는데 단순 클릭이 아닌 외부 화면을 드래그 하는 경우에는 바텀시트는 가만히 있고 그냥 맵 화면 탐색과 같은 기능을 제공해야 했다.
✔️ 문제상황
- 바텀시트 외부 즉, 네이버 지도가 표시될 map_fragment를 사용자가 단순 터치를 했는지, 드래그를 했는지 구분해야 함
- 뷰바인딩을 통해 map_fragment에 터치 리스너 달아주었지만 전혀 감지/ 동작 하지 않음 => 어떤 액션 이벤트가 들어왔는지를 감지조차 못함
첫번째, 사용자가 외부화면을 단순히 '클릭' 한것인지 혹은 '드래그' 한 것인지를 구분해야 한다. 어렵긴 하지만 사용자 이벤트에 따라 클릭인지 드래그인지를 검사하는 로직에 대해 찾아보면 될 것 같았다.
두번째, 첫번째 문제를 해결하기 위해서는 일단 사용자의 이벤트를 감지하는 것이 중요하다. 그래야 이게 클릭인지 드래그인지 구분을 하지..그래서 일반적으로 사용할 법한 터치 이벤트를 감지하는 터치리스너를 맵 화면에 달아주고 OnTouch 메서드를 오버라이딩 하며 MotionEvent를 받아오려고 했으나 전혀 해당 함수가 호출되지 않는 문제가 발생했다.
binding.mapFragment.setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(p0: View?, p1: MotionEvent?): Boolean {
Log.d("mapFragment","onTouch")
return true
}
})
<FramLayout
android:id="@+id/map_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable= "true"
android:focusable= "true"
android:onClick="@{() -> viewmodel.onClickContainer()}">
</FramLayout>
(데이터바인딩 방식을 사용해 map 화면에 직접 클릭 메서드를 작성해주었는데 이또한 아무 반응이 없었다....ㅜ)
💡 원인/해결 방법
이 문제를 해결하기 위해서는 Touch Event의 내부적인 호출 경로를 알고 있어야 했다.
내가 어떤 뷰 컴포넌트를 터치했을 때 그 컴포넌트에 바로 터치 이벤트가 들어오는게 아니다.
액티비티부터 시작해서 그 자식뷰로 전달 -> 전달 -> 하면서 이벤트가 전달되면서 최종적으로 가장 상위의 자식뷰까지 이벤트가 전이된다.
만약 자식뷰로 이벤트를 전달하지 않고 해당 뷰에 대해 터치이벤트를 처리해주고 싶다면 onInterceptTouchEvent() 메서드를 오버라이딩하여 true를 반환하게 만들도 onTouchEvent() 메서드 안에 원하는 동작을 처리해주면 된다.
(* Fragment에서는 dispatchTouchEvent() 나 onInterceptTouchEvent() 같은 메서드를 오버라이딩 할 수가 없다.)
이 내부 동작원리를 기반으로 내 코드에서의 이벤트 전달 경로는 아래와 같다.
- Home Activity → Map Fragment → FramLayout(뷰그룹)(id : map_fragment) → 네이버맵 객체(?)
FramLayout 위에 누군가 있다?
위에서 xml 코드를 보면 FramLayout 은 자식뷰를 가지고 있지 않다. 그래서 나는 FramLayout에 터치리스너를 달아 이벤트를 받으려고 했지만 FramLayout은 맵을 표시하게 될 도화지이고, 실제 그 위에 올라가는 맵 객체가 존재하게 되면서 최종적으로 맵 객체로 이벤트가 들어오게 되는 것이다.
(맵 객체에 터치 리스너를 달고 이벤트 전달 로그를 찍어본 결과 -> 최종적으로 맵 객체로 이벤트가 전달)
그렇다면 무조건 FramLayout의 onInterceptTouchEvent() 가 true를 반환하게 해서 이벤트를 처리하는게 답일까?
NO!! 좀 더 복잡한 고민을 해줘야 한다....
무조건 true를 반환하게 되면 맵을 드래그해서 탐색하거나 마커를 클릭해서 바텀시트를 올라오게 하거나, 혹은 바텀시트의 내용만 바꿔주는 작업을 할 수 없게 된다. 이런 작업들은 맵 객체까지 이벤트가 전달되어야 동작할 수 있는데 FramLayout에서 가로채버리기 때문이다 ㅠㅠ
1. 먼저 FramLayout의 onInterceptTouchEvnet() 코드에서 1차적으로 이 이벤트가 어떤 동작에 속하는지 체크해줘야 한다. 하지만 FramLayout의 경우에는 안드로이드에서 기본적으로 제공하고 있는 레이아웃 클래스이기 때문에 onInterceptTouchEvnet() 같은 내부 코드를 바꿀기는 어렵다. 따라서 FramLayout 를 implements 해서 Custom FramLayout을 만들고 여기서 onInterceptTouchEvent() 메서드를 오버라이딩 하면 된다.
만약 바텀시트가 up되어 있는 상태에서 터치 이벤트가 들어오게 된다면 일단 viewmodel._isShowBottomSheetTab.value = false 로 바꿔준다.
이벤트는 맵객체로 전달 되도록 false를 반환한다.
class CustomFragmentContainerView : FrameLayout {
private lateinit var bottomSheetBehavior: BottomSheetBehavior<*>
private lateinit var viewmodel: MapViewModel
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
private val TOUCH_THRESHOLD = 10
private var startX = 0f
private var startY = 0f
private var preSelectedMarker : MarkerOfMap? = null
fun setViewModel(viewmodel: MapViewModel) {
this.viewmodel = viewmodel // 바텀시트 조작해줘야 하므로 바텀시트 BottomSheetBehavior를 이 커스텀 레아아웃 클래스에 셋팅해놔야 함
}
fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>) {
this.bottomSheetBehavior = behavior // 바텀시트 조작해줘야 하므로 바텀시트 BottomSheetBehavior를 이 커스텀 레아아웃 클래스에 셋팅해놔야 함
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
// 터치 이벤트를 소비하여 ViewPager2로 전달하지 않음
// 외부 영역을 클릭한 경우 바텀시트를 숨김
when (ev!!.getAction()) {
MotionEvent.ACTION_DOWN -> {
// 화면에 손가락이 닿을 때 호출
startX = ev!!.x
startY = ev.y
}
MotionEvent.ACTION_MOVE -> {
// 손가락이 화면에 닿은채로 움직일때 호출 (단순 터치를 할때도 호출 되기 때문에 이 이벤트로 뭔가를 판별하긴 어려움)
}
MotionEvent.ACTION_UP -> {
// 화면에서 손가락이 떨어졌을 때 호출
val endX = ev!!.getX()
val endY = ev.getY()
val distance = Math.sqrt(
((endX - startX) * (endX - startX) + (endY - startY) * (endY - startY)).toDouble()
).toFloat()
// 손가락이 화면이 닿았을 때 위치와 떨어질때 위치를 비교해 일정 이상 차이나면 단순 터치가 아닌 드래그로 간주
if (distance < TOUCH_THRESHOLD) {
if (bottomSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN) { // 바텀시트가 up 되어 있다면
// 바텀시트를 숨기기 위한 플래그 설정
viewmodel._isShowBottomSheetTab.value = false
performClick()
}
}
}
/* 만약 맵 탐색하는 드래그 동작이거나 바텀시트가 up 되어 있지 않는 상태에서 마커를 클릭한 경우에는
* false를 반환하여 동작이 네이버맵으로 흘러가도록 해야 함
*/
}
return false
}
}
2. 빈화면 터치가 아닌 다른 마커를 터치한 경우
위에서 viewmodel._isShowBottomSheetTab.value = false 로 바꿔주었지만 바텀시트를 숨기기 위해 빈 화면을 클릭한 것이 아닌 다른 마커를 선택하기 위한 터치일 수 있다. 이 경우 바텀시트는 그대로 두고 바텀시트의 내용만 바꿔줘야 하기 때문에 선택한 마커 데이터에 변화가 생겼을 경우 다시 viewmodel._isShowBottomSheetTab.value = true 로 바꿔준다.
viewmodel.selectedMarker.observe(this, androidx.lifecycle.Observer {
// 검색바 텍스트 설정
if(it == null){
binding.searchbarTextView.text = "어디로 이동할까요?"
binding.searchbarTextView.setTextColor(ContextCompat.getColor(requireContext(), R.color.Gray3))
}
else {
Log.d("selectedMarker", "마커가 선택되었습니다")
viewmodel.getRestaurantSummary()
// 바텀시트 UP
viewmodel._isShowBottomSheetTab.value = true
persistenetBottomSheet.state = STATE_COLLAPSED
viewmodel._BottomSheetStep1.value = true
viewmodel._BottomSheetState.value = 1
}
})
3. 빈화면을 터치해 바텀시트를 숨겨야 하는 경우
마지막으로 이벤트를 받게 될 맵객체에 클릭 리스너를 달아 최종적으로 viewmodel._isShowBottomSheetTab.value 결과에 따라바텀시트를 숨겨준다.
override fun onMapReady(naverMap: NaverMap) {
naver_map = naverMap
naver_map!!.setOnMapClickListener { pointF, latLng ->
Log.d("NaverMap","NaverMap으로 터치 이벤트 전달")
if(viewmodel.isShowBottomSheetTab.value == true) { //다른 마커 클릭
} else {
val bottomSheet = binding.bottomSheetLayout
val persistenetBottomSheet = BottomSheetBehavior.from(bottomSheet)
val density = resources.displayMetrics.density
persistenetBottomSheet.peekHeight = (150 * density).toInt()
persistenetBottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
viewmodel._selectedMarker.value = null
viewmodel._BottomSheetStep1.value = true
viewmodel._BottomSheetState.value = 0
}
}
}
✔️ 결과 화면
'Android' 카테고리의 다른 글
[Android/세미나실] 2024 Google I/O 참가 후기 및 정리 (0) | 2024.08.15 |
---|---|
[Android/세미나실] 안드로이드를 더 잘하기 위한 자료 모음집 (0) | 2024.08.12 |
[안드로이드/아키텍쳐] 클린아키텍쳐 총정리 & 적용 중간 점검 (1) | 2024.02.10 |
[안드로이드/RecyclerView] 리사이클러뷰 + 데이터바인딩 + MVVM 적용하기 (0) | 2024.02.03 |
[안드로이드/소셜로그인&Oauth] 안드로이드에서 애플(Apple Sign-In) 로그인 구현하기 (1) | 2024.01.29 |