Testing Touch Events in Robolectric

Brian Terczynski
5 min readDec 21, 2022
Photo by Elia Pellegrini on Unsplash

This article describes one way to test Android touch events, with fine-grained control, in Robolectric.

Let’s say we create a custom stepper component, one that increments/decrements a numeric value. Here’s a hokey one I created:

The XML for it:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FirstFragment">

<ImageView
android:id="@+id/decrement"
android:layout_width="@dimen/button_width"
android:layout_height="@dimen/button_height"
android:src="@drawable/ic_decrement"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/value"
app:layout_constraintStart_toStartOf="parent" />

<TextView
android:id="@+id/value"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toEndOf="@id/decrement"
app:layout_constraintEnd_toStartOf="@id/increment"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/increment"
android:textSize="@dimen/value_text_size"
android:gravity="center"
tools:text="100" />

<ImageView
android:id="@+id/increment"
android:layout_width="@dimen/button_width"
android:layout_height="@dimen/button_height"
android:src="@drawable/ic_increment"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/value"
app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

The increment/decrement buttons will change the value via touch events. When a button is first pressed, it changes the value once, then waits X milliseconds before changing the value again, and then waits Y milliseconds before changing the value subsequently. X > Y. This mimics the behavior of other UI controls where a press-and-hold repeated action is delayed at first, and then the repeat action has a certain smaller delay between them.

The logic for my control is here:

val TAG = "TaskTag"
val INITIAL_PRESS_DELAY_OFFSET = 500

@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.value.text = getString(R.string.initial_value)
binding.increment.setOnTouchListener { view, motionEvent ->
touchListener(view, motionEvent, 1)
}
binding.decrement.setOnTouchListener { view, motionEvent ->
touchListener(view, motionEvent, -1)
}
}

private fun touchListener(view: View, motionEvent: MotionEvent, incrementValue: Int): Boolean {
return when (motionEvent.actionMasked) {
MotionEvent.ACTION_CANCEL -> {
view.isPressed = false
view.handler?.removeCallbacksAndMessages(TAG)
true
}
MotionEvent.ACTION_DOWN -> {
view.isPressed = true
if (performStep(view, incrementValue)) {
view.handler.postAtTime(
{ buttonAction(view, incrementValue) },
TAG,
SystemClock.uptimeMillis() + ViewConfiguration.getLongPressTimeout() + INITIAL_PRESS_DELAY_OFFSET
)
}
true
}
MotionEvent.ACTION_UP -> {
if (view.isPressed) {
view.performClick()
}
view.isPressed = false
view.handler?.removeCallbacksAndMessages(TAG)
true
}
else -> false
}
}

private fun performStep(view: View, incrementValue: Int): Boolean {
if (view.isPressed) {
val newValue = (binding.value.text.toString().toInt() + incrementValue)
binding.value.text = newValue.toString()
return true
}
return false
}

private fun buttonAction(view: View, incrementValue: Int) {
if (performStep(view, incrementValue)) {
view.handler.postAtTime(
{ buttonAction(view, incrementValue) },
TAG,
SystemClock.uptimeMillis() + ViewConfiguration.getLongPressTimeout()
)
}
}

We want to be able to unit test this with Robolectric. To do so, in our Robolectric test we can use dispatchTouchEvent, passing in a MotionEvent created with MotionEvent.obtain:

fragment.binding.increment.dispatchTouchEvent(
MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, 0f, 0f, 0)
)

As part of the obtain() call we pass in the coordinates of where the touch event took place, local to the View in question. For our case, the touch event could be anywhere in the control, so we’re just passing in (0, 0).

This method will trigger the ACTION_DOWN clause in our touchListener() , which should call performStep() and set our value right away. To verify that in our Robolectric test, we can do this:

// Initial value
assertEquals(activity.getString(R.string.initial_value), fragment.binding.value.text)
// Touch our increment control
fragment.binding.increment.dispatchTouchEvent(
MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, 0f, 0f, 0)
)
// Value updated right away after touch-down
assertEquals((activity.getString(R.string.initial_value).toInt() + 1).toString(), fragment.binding.value.text)

We run this test, and the assertions pass.

performStep() will return true indicating we updated the value, so the ACTION_DOWN block will also post a lambda to be run X milliseconds later. That lambda will update the value if it can, and if updated will then post yet another lambda to be run Y milliseconds later. And so on. This is what does the delay-then-repeat action of updating our numeric value so long as the button is pressed.

X = ViewConfiguration.getLongPressTimeout() + fragment.INITIAL_PRESS_DELAY_OFFSET . In other words, we want to use whatever the long press delay is on our current device to keep the behavior of our custom control consistent with other controls. But we want this first repeat to be delayed a little longer, so we have a fixed offset we use for that.

Y = ViewConfiguration.getLongPressTimeout() . So this one is just the long press delay on our current device. This is for subsequent repeats after the first one.

So now we want to verify that while the button continues to be pressed, we update the value at the appropriate times. To do that, we need to mimic moving the system clock forward so that messages get run and affect our numeric value. We can do so with:

ShadowLooper.idleMainLooper(<amount_of_time>, TimeUnit.<time_unit>)

To test the first repeat — updating the value to “2” — we do:

// Move the clock forward enough so the value changes to "2"
ShadowLooper.idleMainLooper(
(ViewConfiguration.getLongPressTimeout() + fragment.INITIAL_PRESS_DELAY_OFFSET).toLong(),
TimeUnit.MILLISECONDS
)
// Verify the value is updated
assertEquals((activity.getString(R.string.initial_value).toInt() + 2).toString(), fragment.binding.value.text)

And then to test updating the value to “3”:

// Move the clock again
ShadowLooper.idleMainLooper(
ViewConfiguration.getLongPressTimeout().toLong(),
TimeUnit.MILLISECONDS
)
// Verify the value is updated
assertEquals((activity.getString(R.string.initial_value).toInt() + 3).toString(), fragment.binding.value.text)

To ensure we don’t update the value too soon:

// Roll the clock forward, but not enough to change the value
ShadowLooper.idleMainLooper(
ViewConfiguration.getLongPressTimeout().toLong() / 2L,
TimeUnit.MILLISECONDS
)
// Verify the value is unchanged
assertEquals((activity.getString(R.string.initial_value).toInt() + 3).toString(), fragment.binding.value.text)

Now, how do we test when we release (stop pressing) the button? With a MOTION_UP event:

fragment.binding.increment.dispatchTouchEvent(
MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 0f, 0f, 0)
)

Let’s verify that when we do this, the value is no longer updated:

// Stop pressing
fragment.binding.increment.dispatchTouchEvent(
MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 0f, 0f, 0)
)
// Roll the clock forward
ShadowLooper.idleMainLooper(
ViewConfiguration.getLongPressTimeout().toLong(),
TimeUnit.MILLISECONDS
)
// Verify value is unchanged
assertEquals((activity.getString(R.string.initial_value).toInt() + 3).toString(), fragment.binding.value.text)

When this is run, the assertions pass.

There are a bunch of other cases we could test, but I think this demonstrates the main points behind testing touch events with Robolectric:

  • You can use dispatchTouchEvent and MotionEvent.obtain to pass in different types of MotionEvents to your View.
  • You can test the timing of messages placed on the Looper’s queue with ShadowLooper.idleMainLooper .

And to summarize, here’s the complete test code from above:

@Test
fun `test stepper`() {
val activityController = Robolectric.buildActivity(MainActivity::class.java)
.setup()
val activity = activityController.get()
// Assuming we just have one fragment.
val fragment = (activity.supportFragmentManager.fragments[0] as NavHostFragment).childFragmentManager
.fragments[0] as FirstFragment
// Initial value
assertEquals(activity.getString(R.string.initial_value), fragment.binding.value.text)
// Touch our increment control
fragment.binding.increment.dispatchTouchEvent(
MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, 0f, 0f, 0)
)
// Value updated right away after touch-down
assertEquals((activity.getString(R.string.initial_value).toInt() + 1).toString(), fragment.binding.value.text)
// Move the clock forward enough so the value changes to "2"
ShadowLooper.idleMainLooper(
(ViewConfiguration.getLongPressTimeout() + fragment.INITIAL_PRESS_DELAY_OFFSET).toLong(),
TimeUnit.MILLISECONDS
)
// Verify the value is updated
assertEquals((activity.getString(R.string.initial_value).toInt() + 2).toString(), fragment.binding.value.text)
// Move the clock again
ShadowLooper.idleMainLooper(
ViewConfiguration.getLongPressTimeout().toLong(),
TimeUnit.MILLISECONDS
)
// Verify the value is updated
assertEquals((activity.getString(R.string.initial_value).toInt() + 3).toString(), fragment.binding.value.text)
// Roll the clock forward, but not enough to change the value
ShadowLooper.idleMainLooper(
ViewConfiguration.getLongPressTimeout().toLong() / 2L,
TimeUnit.MILLISECONDS
)
// Verify the value is unchanged
assertEquals((activity.getString(R.string.initial_value).toInt() + 3).toString(), fragment.binding.value.text)
// Stop pressing
fragment.binding.increment.dispatchTouchEvent(
MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 0f, 0f, 0)
)
// Roll the clock forward
ShadowLooper.idleMainLooper(
ViewConfiguration.getLongPressTimeout().toLong(),
TimeUnit.MILLISECONDS
)
// Verify value is unchanged
assertEquals((activity.getString(R.string.initial_value).toInt() + 3).toString(), fragment.binding.value.text)
}

--

--

Brian Terczynski

Documenting my learnings on my journey as a software engineer.