1 /*
2  * Copyright 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package androidx.recyclerview.widget
17 
18 import android.content.Context
19 import android.util.AttributeSet
20 import android.view.MotionEvent
21 import android.view.MotionEvent.ACTION_CANCEL
22 import android.view.MotionEvent.ACTION_DOWN
23 import android.view.MotionEvent.ACTION_MOVE
24 import android.view.MotionEvent.ACTION_UP
25 import android.view.View
26 import android.view.ViewGroup
27 import android.widget.FrameLayout
28 import androidx.test.core.app.ApplicationProvider
29 import androidx.test.ext.junit.runners.AndroidJUnit4
30 import androidx.test.filters.LargeTest
31 import com.google.common.truth.Truth.assertThat
32 import org.junit.Before
33 import org.junit.Test
34 import org.junit.runner.RunWith
35 
36 val ActionDown = MotionEventItem(0, ACTION_DOWN, 500f, 500f)
37 val ActionMove1 = MotionEventItem(100, ACTION_MOVE, 500f, 400f)
38 val ActionMove2 = MotionEventItem(200, ACTION_MOVE, 500f, 300f)
39 val ActionMove3 = MotionEventItem(300, ACTION_MOVE, 500f, 200f)
40 val ActionUp = MotionEventItem(400, ACTION_UP, 500f, 200f)
41 
42 @RunWith(AndroidJUnit4::class)
43 @LargeTest
44 class RecyclerViewOnItemTouchListenerTest {
45     private lateinit var parent: MyFrameLayout
46     private lateinit var recyclerView: RecyclerView
47     private lateinit var childView: MyView
48     private lateinit var onItemTouchListener: MyOnItemTouchListener
49 
50     @Before
setupnull51     fun setup() {
52         val context = ApplicationProvider.getApplicationContext<Context>()
53 
54         childView = MyView(context)
55         childView.minimumWidth = 1000
56         childView.minimumHeight = 1000
57 
58         recyclerView = RecyclerView(context)
59         recyclerView.layoutManager = LinearLayoutManager(context)
60         recyclerView.adapter = InternalTestAdapter(childView)
61 
62         onItemTouchListener = MyOnItemTouchListener()
63         recyclerView.addOnItemTouchListener(onItemTouchListener)
64 
65         parent = MyFrameLayout(context)
66         parent.addView(recyclerView)
67 
68         val measureSpec = View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY)
69         parent.measure(measureSpec, measureSpec)
70         parent.layout(0, 0, 1000, 1000)
71     }
72 
73     @Test
listenerDoesntIntercept_rvChildDoesntClick_correctListenerCallsnull74     fun listenerDoesntIntercept_rvChildDoesntClick_correctListenerCalls() {
75         listenerDoesntIntercept_correctListenerCalls(false)
76     }
77 
78     @Test
listenerDoesntIntercept_rvChildClicks_correctListenerCallsnull79     fun listenerDoesntIntercept_rvChildClicks_correctListenerCalls() {
80         listenerDoesntIntercept_correctListenerCalls(true)
81     }
82 
listenerDoesntIntercept_correctListenerCallsnull83     private fun listenerDoesntIntercept_correctListenerCalls(childClickable: Boolean) {
84         childView.isClickable = childClickable
85 
86         parent.dispatchMotionEventItem(ActionDown)
87         parent.dispatchMotionEventItem(ActionMove1)
88         parent.dispatchMotionEventItem(ActionUp)
89 
90         assertThat(onItemTouchListener.motionEventItems)
91             .isEqualTo(listOf(ActionDown to true, ActionMove1 to true, ActionUp to true))
92     }
93 
94     @Test
listenerInterceptsDown_rvChildClicks_correctListenerCallsnull95     fun listenerInterceptsDown_rvChildClicks_correctListenerCalls() {
96         listenerInterceptsDown_correctListenerCalls(false)
97     }
98 
99     @Test
listenerInterceptsDown_rvChildDoesntClick_correctListenerCallsnull100     fun listenerInterceptsDown_rvChildDoesntClick_correctListenerCalls() {
101         listenerInterceptsDown_correctListenerCalls(true)
102     }
103 
listenerInterceptsDown_correctListenerCallsnull104     private fun listenerInterceptsDown_correctListenerCalls(childClickable: Boolean) {
105         childView.isClickable = childClickable
106 
107         onItemTouchListener.motionEventItemToStartIntecepting = ActionDown
108 
109         parent.dispatchMotionEventItem(ActionDown)
110         parent.dispatchMotionEventItem(ActionMove1)
111         parent.dispatchMotionEventItem(ActionUp)
112 
113         assertThat(onItemTouchListener.motionEventItems)
114             .isEqualTo(
115                 listOf(
116                     ActionDown to true,
117                     ActionDown to false,
118                     ActionMove1 to false,
119                     ActionUp to false
120                 )
121             )
122     }
123 
124     @Test
listenerInterceptsMove_rvChildDoesntClick_correctListenerCallsnull125     fun listenerInterceptsMove_rvChildDoesntClick_correctListenerCalls() {
126         listenerInterceptsMove_correctListenerCalls(false)
127     }
128 
129     @Test
listenerInterceptsMove_rvChildClicks_correctListenerCallsnull130     fun listenerInterceptsMove_rvChildClicks_correctListenerCalls() {
131         listenerInterceptsMove_correctListenerCalls(true)
132     }
133 
134     @Test
listenerInterceptsMove_rvChildClicks_correctListenerCallsAndSendsCancelnull135     fun listenerInterceptsMove_rvChildClicks_correctListenerCallsAndSendsCancel() {
136         val actionCancelFromMove1 = MotionEventItem(100, ACTION_CANCEL, 500f, 400f)
137         val secondOnItemTouchListener = MyOnItemTouchListener()
138         recyclerView.addOnItemTouchListener(secondOnItemTouchListener)
139 
140         listenerInterceptsMove_correctListenerCalls(true)
141 
142         assertThat(secondOnItemTouchListener.motionEventItems)
143             .isEqualTo(listOf(ActionDown to true, actionCancelFromMove1 to true))
144     }
145 
listenerInterceptsMove_correctListenerCallsnull146     private fun listenerInterceptsMove_correctListenerCalls(childClickable: Boolean) {
147         childView.isClickable = childClickable
148         onItemTouchListener.motionEventItemToStartIntecepting = ActionMove1
149 
150         parent.dispatchMotionEventItem(ActionDown)
151         parent.dispatchMotionEventItem(ActionMove1)
152         parent.dispatchMotionEventItem(ActionMove2)
153         parent.dispatchMotionEventItem(ActionUp)
154 
155         assertThat(onItemTouchListener.motionEventItems)
156             .isEqualTo(
157                 listOf(
158                     ActionDown to true,
159                     ActionMove1 to true,
160                     ActionMove2 to false,
161                     ActionUp to false
162                 )
163             )
164     }
165 
166     @Test
listenerInterceptsDown_childOnTouchNotCallednull167     fun listenerInterceptsDown_childOnTouchNotCalled() {
168         childView.isClickable = true
169         onItemTouchListener.motionEventItemToStartIntecepting = ActionDown
170 
171         parent.dispatchMotionEventItem(ActionDown)
172         parent.dispatchMotionEventItem(ActionUp)
173 
174         assertThat(childView.motionEventItems).isEmpty()
175     }
176 
177     @Test
listenerInterceptsMove_childOnTouchCalledWithCorrectEventsnull178     fun listenerInterceptsMove_childOnTouchCalledWithCorrectEvents() {
179         childView.isClickable = true
180         onItemTouchListener.motionEventItemToStartIntecepting = ActionMove1
181 
182         parent.dispatchMotionEventItem(ActionDown)
183         parent.dispatchMotionEventItem(ActionMove1)
184 
185         assertThat(childView.motionEventItems)
186             .isEqualTo(listOf(ActionDown, ActionMove1.toCancelledVersion()))
187     }
188 
189     @Test
listenerInterceptsUp_childOnTouchCalledWithCorrectEventsnull190     fun listenerInterceptsUp_childOnTouchCalledWithCorrectEvents() {
191         childView.isClickable = true
192         onItemTouchListener.motionEventItemToStartIntecepting = ActionUp
193 
194         parent.dispatchMotionEventItem(ActionDown)
195         parent.dispatchMotionEventItem(ActionUp)
196 
197         assertThat(childView.motionEventItems)
198             .isEqualTo(listOf(ActionDown, ActionUp.toCancelledVersion()))
199     }
200 
201     @Test
listenerInterceptsThenParentIntercepts_correctListenerCallsnull202     fun listenerInterceptsThenParentIntercepts_correctListenerCalls() {
203         onItemTouchListener.motionEventItemToStartIntecepting = ActionMove1
204         parent.motionEventItemToStartIntecepting = ActionMove3
205 
206         parent.dispatchMotionEventItem(ActionDown)
207         parent.dispatchMotionEventItem(ActionMove1)
208         parent.dispatchMotionEventItem(ActionMove2)
209         parent.dispatchMotionEventItem(ActionMove3)
210         parent.dispatchMotionEventItem(ActionUp)
211 
212         assertThat(onItemTouchListener.motionEventItems)
213             .isEqualTo(
214                 listOf(
215                     ActionDown to true,
216                     ActionMove1 to true,
217                     ActionMove2 to false,
218                     ActionMove3.toCancelledVersion() to false
219                 )
220             )
221     }
222 }
223 
224 data class MotionEventItem(val eventTime: Long, val action: Int, val x: Float, val y: Float)
225 
toMotionEventnull226 private fun MotionEventItem.toMotionEvent(): MotionEvent =
227     MotionEvent.obtain(0, eventTime, action, x, y, 0)
228 
229 private fun MotionEventItem.toCancelledVersion() = copy(action = ACTION_CANCEL)
230 
231 private fun MotionEvent.toMotionEventItem() = MotionEventItem(eventTime, actionMasked, x, y)
232 
233 private fun View.dispatchMotionEventItem(motionEventItem: MotionEventItem) {
234     motionEventItem.toMotionEvent().also {
235         dispatchTouchEvent(it)
236         it.recycle()
237     }
238 }
239 
240 private class InternalTestAdapter(var view: View) : RecyclerView.Adapter<MyViewHolder>() {
onCreateViewHoldernull241     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = MyViewHolder(view)
242 
243     override fun onBindViewHolder(holder: MyViewHolder, position: Int) {}
244 
getItemCountnull245     override fun getItemCount() = 1
246 }
247 
248 private class MyViewHolder internal constructor(itemView: View?) :
249     RecyclerView.ViewHolder(itemView!!)
250 
251 private class MyOnItemTouchListener : RecyclerView.OnItemTouchListener {
252     var motionEventItemToStartIntecepting: MotionEventItem? = null
253     var motionEventItems = mutableListOf<Pair<MotionEventItem, Boolean>>()
254 
255     override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
256         motionEventItems.add(e.toMotionEventItem() to true)
257         return e.toMotionEventItem() == motionEventItemToStartIntecepting
258     }
259 
260     override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
261         motionEventItems.add(e.toMotionEventItem() to false)
262     }
263 
264     override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {}
265 }
266 
267 private class MyView : View {
268     var motionEventItems = mutableListOf<MotionEventItem>()
269 
270     constructor(context: Context?) : super(context)
271 
272     constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
273 
274     constructor(
275         context: Context?,
276         attrs: AttributeSet?,
277         defStyleAttr: Int
278     ) : super(context, attrs, defStyleAttr)
279 
onTouchEventnull280     override fun onTouchEvent(event: MotionEvent): Boolean {
281         motionEventItems.add(event.toMotionEventItem())
282         return super.onTouchEvent(event)
283     }
284 }
285 
286 private class MyFrameLayout(context: Context) : FrameLayout(context) {
287 
288     var motionEventItemToStartIntecepting: MotionEventItem? = null
289 
onInterceptTouchEventnull290     override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
291         super.onInterceptTouchEvent(ev)
292         return ev!!.toMotionEventItem() == motionEventItemToStartIntecepting
293     }
294 }
295