부모 View와 자식 View의 eventListener 중첩은 앱을 개발하는 사람들은 한번쯤은 겪어볼만한 일이다. 한번은 무슨... 꽤 자주 겪는다. 그때마다 구글링하기가 귀찮아서 기록한다.
아래 해결방법을 적용하기 위해서 이 동작 원리를 알아야한다.
사진 출처 - https://suragch.medium.com/how-touch-events-are-delivered-in-android-eee3b607b038왼쪽 사진을 먼저 보면 Activity안에 ViewGroup A, ViewGroup A 안에 ViewGroup B, ViewGroup B안에 View가 있다. 이럴 경우 TouchEvent는 Activity -> ViewGroup A -> ViewGroup B -> View 순으로 event가 발생함을 알리고, 다시 거꾸로 View -> ViewGroup B -> ViewGroup A -> Activity 순으로 event가 동작하게 된다.
위의 사진으로 알 수 있듯이, TouchEvent가 발생하면 바로 동작이 되는게 아니고 TouchEvent가 발생하였다고 알리는 dispatchTouchEvent() 메소드가 먼저 작동한다. event가 발생했다는 것을 ViewGroup은 onInterceptTouchEvent()로 이 event를 하위 View(자식 뷰)에게 전달할지, 아닐지를 결정할 수 있다. onInterceptTouchEvent()의 반환값이 true면 ViewGroup은 이 event를 intercept(채가다)해간다. 이 말은 하위 View(자식 View)에게 event를 전달하지 않고 바로 event를 동작한다는 의미다.
이 내용을 이해했다면 아래 해결방법에서의 예시가 이해간다.
부모 View(ViewGroup)에 onInterceptTouchEvent()
를 오버라이딩하여, 어떤 특정 이벤트가 발생 시 자식 View에게 이 이벤트를 전달할 지 안할지 결정한다.
반환값이 false면 자식 View에게 이벤트를 전달하는 것을 의미한다.
onInterceptTouchEvent()
class MyViewGroup @JvmOverloads constructor(
context: Context,
private val mTouchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop
) : ViewGroup(context) {
...
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
/*
* This method JUST determines whether we want to intercept the motion.
* If we return true, onTouchEvent will be called and we do the actual
* scrolling there.
*/
return when (ev.actionMasked) {
// Always handle the case of the touch gesture being complete.
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
// Release the scroll.
mIsScrolling = false
false // Do not intercept touch event, let the child handle it
}
MotionEvent.ACTION_MOVE -> {
if (mIsScrolling) {
// We're currently scrolling, so yes, intercept the
// touch event!
true
} else {
// If the user has dragged her finger horizontally more than
// the touch slop, start the scroll
// left as an exercise for the reader
val xDiff: Int = calculateDistanceX(ev)
// Touch slop should be calculated using ViewConfiguration
// constants.
if (xDiff > mTouchSlop) {
// Start scrolling!
mIsScrolling = true
true
} else {
false
}
}
}
...
else -> {
// In general, we don't want to intercept touch events. They should be
// handled by the child view.
false
}
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
// Here we actually handle the touch event (e.g. if the action is ACTION_MOVE,
// scroll this container).
// This method will only be called if the touch event was intercepted in
// onInterceptTouchEvent
...
}
}
위의 예시를 난 아래처럼 적용했다.
모든 코드를 볼 필요는 없다. 수정 전,후로 onInterceptTouchEvent()에 추가된 when 구절이 해결방법이다.
CustomMotionLayout.kt(수정 전)
package com.dn.digitalnutrition.customs
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
import com.dn.digitalnutrition.R
class CustomMotionLayout(context: Context, attributes: AttributeSet? = null):
MotionLayout(context, attributes){
private var motionTouchStarted = false // 정확한 위치에서만 true
private val itemContainerView by lazy {
findViewById<View>(R.id.item_container)
}
private val hitRect = Rect()
init {
setTransitionListener(object : TransitionListener {
override fun onTransitionStarted(
motionLayout: MotionLayout?,
startId: Int,
endId: Int
) {}
override fun onTransitionChange(
motionLayout: MotionLayout?,
startId: Int,
endId: Int,
progress: Float
) {}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
motionTouchStarted = false
}
override fun onTransitionTrigger(
motionLayout: MotionLayout?,
triggerId: Int,
positive: Boolean,
progress: Float
) {}
})
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
motionTouchStarted = false
return super.onTouchEvent(event)
}
}
if (!motionTouchStarted) {
itemContainerView.getHitRect(hitRect)
motionTouchStarted = hitRect.contains(event.x.toInt(), event.y.toInt())
}
return super.onTouchEvent(event) && motionTouchStarted
}
private val gestureListener by lazy {
object : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
itemContainerView.getHitRect(hitRect)
return hitRect.contains(e1.x.toInt(), e1.y.toInt())
}
}
}
private val gestureDetector by lazy {
GestureDetector(context, gestureListener)
}
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
return gestureDetector.onTouchEvent(event)
}
}
CustomMotionLayout.kt(수정 후)
package com.dn.digitalnutrition.customs
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
import com.dn.digitalnutrition.R
class CustomMotionLayout(context: Context, attributes: AttributeSet? = null):
MotionLayout(context, attributes){
private var motionTouchStarted = false // 정확한 위치에서만 true
private val itemContainerView by lazy {
findViewById<View>(R.id.item_container)
}
private val hitRect = Rect()
init {
setTransitionListener(object : TransitionListener {
override fun onTransitionStarted(
motionLayout: MotionLayout?,
startId: Int,
endId: Int
) {}
override fun onTransitionChange(
motionLayout: MotionLayout?,
startId: Int,
endId: Int,
progress: Float
) {}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
motionTouchStarted = false
}
override fun onTransitionTrigger(
motionLayout: MotionLayout?,
triggerId: Int,
positive: Boolean,
progress: Float
) {}
})
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, -> {
motionTouchStarted = false
return super.onTouchEvent(event)
}
}
if (!motionTouchStarted) {
itemContainerView.getHitRect(hitRect)
motionTouchStarted = hitRect.contains(event.x.toInt(), event.y.toInt())
}
return super.onTouchEvent(event) && motionTouchStarted
}
private val gestureListener by lazy {
object : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
itemContainerView.getHitRect(hitRect)
return hitRect.contains(e1.x.toInt(), e1.y.toInt())
}
}
}
private val gestureDetector by lazy {
GestureDetector(context, gestureListener)
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_MOVE, MotionEvent.ACTION_SCROLL->
return false
}
return gestureDetector.onTouchEvent(event)
}
}