AndroidではConstraintLayoutが登場してから、より一層Viewのネストが少なく、フラットな階層のレイアウトを作れるようになりました。しかし、イベントのハンドリングなど、Viewにネストがあれば簡単だったことが難しくなっている部分も存在します。
例えば、以下のように上下にスクロールするシートと、横スクロールするカルーセルがあるとします。
横スクロールのカルーセルは横方向の操作しか消費しません。一方シートは縦方向の操作だけが必要です。
こういった場合で、特にシートの大きさが十分でない等の場合、カルーセル部分を触った場合でも、操作が上下方向だった場合、動かないカルーセルではなく、シートの操作に反映した方がユーザビリティが高い場合があります。
親子関係がある場合
このような要求がある場合、シートがカルーセルを子に持つViewGroupになっていれば実現可能です。
シートのViewを拡張して、onInterceptTouchEvent
をoverride、操作方向を検出してinterceptという流れですね。
ざっくりとした実装としてはこのような感じでしょうか。onTouchEventでの処理は適切に実装するとして、onInterceptTouchEvent
では、ACTION_DOWN
時点でのポインター位置を記録、ACTION_MOVE
ではscaledTouchSlop
以上動いた時点で、それが縦方向か横方向の操作なのかを判断し、縦方向であるならtrueを返します。
trueを返すと、子ViewにはACTION_CANCEL
が渡され、タッチイベントは終了し、このViewのonTouchEvent
がコールされるようになります。
private var startX = 0f
private var startY = 0f
private var judged = false
private val scaledTouchSlop: Int by lazy {
ViewConfiguration.get(context).scaledTouchSlop
}
override fun onInterceptTouchEvent(e: MotionEvent): Boolean =
when {
e.action == MotionEvent.ACTION_DOWN -> {
startX = e.rawX
startY = e.rawY
judged = false
false
}
!judged && e.action == MotionEvent.ACTION_MOVE -> {
if (hypot(e.rawX - startX, e.rawY - startY) > scaledTouchSlop) {
judged = true
abs(e.rawX - startX) < abs(e.rawY - startY)
} else {
false
}
}
else -> {
false
}
}
しかし、どちらのViewもConstraintLayoutの子である場合、こうはいきません。
どちらも同一階層の子なので、ConstraintLayout上の上にあるViewにのみタッチイベントが伝えられ、他の子Viewはその情報を受け取ることができません。
親子関係が無い場合
解決策として、ConstraintLayoutの子View間にタッチイベントの配信に関してだけの仮想的な親子関係を持ち込むことを考えます。
まずはそれらのViewよりも優先的にTouchEventを受け取ることができるようにする必要があります。
対策としてはConstraintLayoutを拡張するか、子Viewを拡張するか、ヘルパーViewを導入するか、ぐらいでしょうか?
子Viewの拡張は、子の間でイベントの受け渡しが必要であり、密結合な仕組みになってしまいますし、ConstraintLayoutの拡張にしても、ちょっとした機能要件を実現するために安易に継承を使うのはよろしくない気がします。
ってことで、ヘルパーViewを作ってみます。
やることは単純で、全部のViewの一番上に、透明なViewとして被さり、最優先でタッチイベントを受け取れるようにします。注意点としては、見えなくてもいいけど、全部のViewの最上位にある必要がありますが、XMLの記述順序よりもelevationの値の方が優先されるので、elevation指定を他のViewに設定している場合は、最上位レイヤーになっているかを確認しましょう。
<net.mm2d.myapplication.TouchInterceptHelper
android:id="@+id/touch_intercept_helper"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
TouchInterceptHelperの実装は以下のようになっています。
class TouchInterceptHelper @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
var parents: List<View> = emptyList()
var children: List<View> = emptyList()
var onTouch: (View, MotionEvent) -> Boolean = { _, _ -> true }
var onInterceptTouch: (View, MotionEvent) -> Boolean = { _, _ -> true }
private var interceptableView: View? = null
private var consumerView: View? = null
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean =
offsetAndInvoke(event, -x, -y) { e ->
if (e.action == MotionEvent.ACTION_DOWN) {
interceptableView = parents.firstOrNull { it.isTarget(e) }
consumerView = children.firstOrNull { it.isTarget(e) }
if (interceptableView == null && consumerView != null) {
interceptableView = consumerView
}
if (interceptableView != null && consumerView == null) {
consumerView = interceptableView
}
}
onTouchEventInner(e)
}
private fun onTouchEventInner(event: MotionEvent): Boolean {
val consumer = consumerView
val interceptable = interceptableView
consumer ?: return false
return if (consumer == interceptable) {
onTouch.invokeTouchListener(consumer, event)
} else if (interceptable != null) {
val intercept = onInterceptTouch.invokeTouchListener(interceptable, event)
if (intercept) {
if (event.action != MotionEvent.ACTION_DOWN) {
actionAndInvoke(event, MotionEvent.ACTION_CANCEL) { e ->
onTouch.invokeTouchListener(consumer, e)
}
}
consumerView = interceptable
onTouch.invokeTouchListener(interceptable, event)
} else {
onTouch.invokeTouchListener(consumer, event)
}
} else {
false
}
}
private fun ((View, MotionEvent) -> Boolean).invokeTouchListener(
v: View,
event: MotionEvent
): Boolean = offsetAndInvoke(event, v.x, v.y) { e -> invoke(v, e) }
private fun offsetAndInvoke(
e: MotionEvent,
dx: Float,
dy: Float,
block: (e: MotionEvent) -> Boolean
): Boolean {
e.offsetLocation(dx, dy)
try {
return block(e)
} finally {
e.offsetLocation(-dx, -dy)
}
}
private fun actionAndInvoke(e: MotionEvent, action: Int, block: (MotionEvent) -> Unit) {
val savedAction = e.action
e.action = action
block(e)
e.action = savedAction
}
private fun View.isTarget(e: MotionEvent): Boolean =
e.x in x..x + width && e.y in y..y + height
}
onTouchEvent
イベントで、監視対象のViewのどれがタッチされているのかを調べ、それらにonInterceptTouchEvent
、onTouchEvent
相当の処理を実行します。
ポイントとしては、MotionEventの座標は、そのViewの左上を原点とする相対座標に書き換わって渡ってくるため、親基準の座標にオフセット変換した後、各子Viewの座標に変換してコール、コール後、オフセットを戻す。という作業が必要になります。
そして、このViewに対して、親として処理したいView、子として処理したいViewをセットし、コールバック処理を書けばOKです。
binding.touchInterceptHelper.let {
it.children = listOf(binding.horizontalScrollView)
it.parents = listOf(binding.sheet)
it.onTouch = { v, e -> v.dispatchTouchEvent(e) }
it.onInterceptTouch = { v, e ->
when {
e.action == MotionEvent.ACTION_DOWN -> {
startX = e.rawX
startY = e.rawY
judged = false
false
}
!judged && e.action == MotionEvent.ACTION_MOVE ->
if (hypot(e.rawX - startX, e.rawY - startY) > scaledTouchSlop) {
judged = true
abs(e.rawX - startX) < abs(e.rawY - startY)
} else {
false
}
else -> false
}
}
}
ヘルパークラスで直接onInterceptTouchEventをコールしても良さそうですが、親子関係を持っていないということは、親として振る舞って欲しいけど、ViewGroupの子ではないViewもあるかもしれないので、コールバックで実装できるようにしています。
これらを実装するとこのように所望の動作を実装することができます。
ちゃんと実装しようとするとConstraintLayoutの拡張としてタッチでのみの階層関係を定義できるようにして~とか、いろいろ考えられます。(ここでの実装例はちょっと中途半端ですね)
ということで、ConstraintLayoutの子View間でタッチイベントをInterceptする仕組みの導入でした。