보통 스크롤 뷰 안에 스크롤 뷰를 넣을때 사용합니다.
부모의 스크롤 뷰에게 이벤트를 뺏기지 않고 자식의 스크롤 뷰에서 이벤트를 소비하고 싶을때 사용하며
parent.requestDisallowInterceptTouchEvent(true)로 주어 이벤트를 뺏기지 않게 막을 수 있습니다.
해당 메서드를 실행하면 자식 스크롤 뷰 이전까지 dispatchTouchEvent() = false로 설정되며 이벤트가 내려오게 됩니다.
이 후의 글은 이를 적용한 예시로써 NestedScrollableHost에 대한 설명입니다.
views-widgets-samples/NestedScrollableHost.kt at master · android/views-widgets-samples
구글에서 제공하는 NestedScrollableHost라는 클래스가 있습니다.
ViewPager내부에서 또 다른 ViewPager를 이용할 시 부모의 ViewPager가 자손의 스크롤 이벤트를 대신 소비하기 때문에 스크롤이 안되는 경우를 대비해 만들어졌습니다.
자식의ViewPager를 NestedScrollableHost 내부에 넣어주면 설정은 끝납니다.
(NestedScrollableHost 내부에는 자식 뷰페이저 하나만 들어가있어야 합니다.
해당 뷰는 처음 생성되면 touchSlop라는 변수를 초기화합니다.
이 변수는 ViewConfiguration 클래스를 사용하여 안드로이드에 사용되는 거리,속도,시간에 액세스하고 사용자가 화면상의 요소를 터치하였는지 스크롤 하였는지에 대한 기준을 정의해줍니다.
init{
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}
예를 들면 단위는 픽셀이며 해당 배너의 크기는 (360dp, 180dp) 이기때문에 픽셀로 변환했을경우 ( 1440, 720)입니다
val dx = e.x - initialX
val dy = e.y - initialY
parent ViewPager의 orientation 입니다. HORIZONTAL = 0 , VERTICAL = 1로 내려옵니다.
이 클래스에서는 isVpHorizontal : Boolean 값으로 저장하며 만약 parent의 orientation이 수평스크롤이라면 true, 수직스크롤이라면 false를 반환합니다.
val orientation = parentViewPager?.orientation
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
dx,dy값의 절대값입니다. 만약 수평 스크롤이면 dx의절대값의 절반, 수직 스크롤이라면 dy의 절대값의 절반입니다.
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
추후에 touchSlop과 비교하여 해당 이벤트가 스크롤인지 터치인지를 구분해줍니다.
childView가 scroll이 가능한지 아닌지를 판단합니다.그리고 해당 스크롤 이벤트가 자식 뷰의 스크롤의 끝에 다달았는지를 확인합니다.
orientation 0 : 수평 스크롤, 1 : 수직 스크롤
private fun canChildScroll(orientation: Int, delta: Float): Boolean {
val direction = -delta.sign.toInt()
return when (orientation) {
0 -> child?.canScrollHorizontally(direction) ?: false
1 -> child?.canScrollVertically(direction) ?: false
else -> throw IllegalArgumentException()
}
}
부모 뷰의 스크롤이 수평일때 canScrollHorizontally(direction)메서드를 실행합니다.
public boolean canScrollHorizontally(int direction) {
final int offset = computeHorizontalScrollOffset();
final int range = computeHorizontalScrollRange() - computeHorizontalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range - 1;
}
}
위의 메서드는 child의 스크롤이 끝에 다달았는지를 체크합니다. range == 0의 의미는 더이상 스크롤 될것이 없다는것을 의미하고 이때 false를 반환합니다.
class NestedScrollableHost : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
private var touchSlop = 0
private var initialX = 0f
private var initialY = 0f
private val parentViewPager: ViewPager2?
get() {
var v: View? = parent as? View
while (v != null && v !is ViewPager2) {
v = v.parent as? View
}
return v as? ViewPager2
}
private val child: View? get() = if (childCount > 0) getChildAt(0) else null
init {
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}
private fun canChildScroll(orientation: Int, delta: Float): Boolean {
val direction = -delta.sign.toInt()
return when (orientation) {
0 -> child?.canScrollHorizontally(direction) ?: false
1 -> child?.canScrollVertically(direction) ?: false
else -> throw IllegalArgumentException()
}
}
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
handleInterceptTouchEvent(e)
return super.onInterceptTouchEvent(e)
}
private fun handleInterceptTouchEvent(e: MotionEvent) {
val orientation = parentViewPager?.orientation ?: return
// Early return if child can't scroll in same direction as parent
if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
return
}
if (e.action == MotionEvent.ACTION_DOWN) {
initialX = e.x
initialY = e.y
parent.requestDisallowInterceptTouchEvent(true)
} else if (e.action == MotionEvent.ACTION_MOVE) {
val dx = e.x - initialX
val dy = e.y - initialY
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
// assuming ViewPager2 touch-slop is 2x touch-slop of child
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
if (scaledDx > touchSlop || scaledDy > touchSlop) {
if (isVpHorizontal == (scaledDy > scaledDx)) {
// Gesture is perpendicular, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
} else {
// Gesture is parallel, query child if movement in that direction is possible
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
// Child can scroll, disallow all parents to intercept
parent.requestDisallowInterceptTouchEvent(true)
} else {
// Child cannot scroll, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
}
}
}
else if (e.action == MotionEvent.ACTION_MOVE) {
//dx, dy값을 설정합니다.
val dx = e.x - initialX
val dy = e.y - initialY
//isVpHorizontal 을 설정합니다. 수평 스크롤시 true 수직 스크롤시 false
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
// assuming ViewPager2 touch-slop is 2x touch-slop of child
//touchSlop와 비교할 scaledDx,scaledDy를 설정합니다.
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
//touchSlop 와 비교하여 스크롤이 발생했다고 판단이 될 경우
if (scaledDx > touchSlop || scaledDy > touchSlop) {
//수평 스크롤 뷰인데 수직으로 이벤트를 준다면 requestDisallowInterceptTouchEvent(false)
if (isVpHorizontal == (scaledDy > scaledDx)) {
// Gesture is perpendicular, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
}
//수평 스크롤 뷰인데 수평으로 주었을때
else {
//canChildScroll 을 통해 해당 뷰의 스크롤이 끝에 도달했는지를 판단
//끝에 다달았다면 requestDisallowInterceptTouchEvent(false)
//끝이 아니라면 requestDisallowInterceptTouchEvent(true)를 반환
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
// Child can scroll, disallow all parents to intercept
parent.requestDisallowInterceptTouchEvent(true)
} else {
// Child cannot scroll, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
}
}
}