SENG 440: Lecture 20 – Multitouch
Dear students,
Today we write an app that solves the problem of choosing of which player goes first in a game. All players place a finger on the phone, and the app chooses one of them randomly. The game is inspired by the commercial app Chwazi.
Next lecture we will discuss writing an app that is multitouch-aware. See the TODO below for the assigned exercises.
Lots
Following are the exercises assigned last time. We will use and discuss the solutions that you’ve submitted as we assemble our app, but I include my solutions here for reference.
- Declare a
PreferenceScreen
resource inxml/preferences.xml
to present a singleEditText
preference for the delay in seconds from the last down or up event until one of the active pointers is randomly picked. Make the preference a decimal number with keypickDelay
. Give it a title, a summary, a default of 3, and an icon of@drawable/timer
. Possible solution.<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <EditTextPreference android:defaultValue="3" android:icon="@drawable/timer" android:inputType="numberSigned|number|numberDecimal" android:key="pickDelay" android:selectAllOnFocus="true" android:singleLine="true" android:summary="Time in seconds until random player selected" android:title="Delay" /> </PreferenceScreen>
- Define class
SettingsFragment
as a subclass ofPreferenceFragmentCompat
that shows the app’sPreferenceScreen
. Possible solution.class SettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences, rootKey) findPreference<EditTextPreference>("delayToPick")?.let { it.setOnBindEditTextListener { editor -> editor.inputType = InputType.TYPE_CLASS_NUMBER } } } }
- Define field
pickTask
inPickerView
to be aRunnable
that setspickedId
to a randomly chosen key frompointerIdToPosition
. Also, declare fieldonFirstDown
inPickerView
to be a lambda that accepts no parameters and returns nothing. Define it to do nothing, but allow clients to redefine it to trigger some action when a first touch event occurs. Possible solution.private val pickTask = Runnable { pickedId = pointerIdToPosition.keys.random() }
- Write method
schedulePick
that invalidatespickedId
and unschedulespickTask
from the inheritedhandler
. If there are any entries left in the positions map, schedulepickTask
to execute after the pick delay, which you can access viaprefs
. Watch your units. Possible solution.private fun schedulePick() { pickedId = -1 handler.removeCallbacks(pickTask) if (pointerIdToPosition.isNotEmpty()) { val delay = ((prefs.getString("pickDelay", null) ?: "3").toFloat() * 1000).toLong() handler.postDelayed(pickTask, delay) } }
- Write method
handleDowns
inPickerView
to accept aMotionEvent
parameter. CallonFirstDown
when the first touch occurs. When any down event occurs, first or otherwise, do two things:- Reschedule the picking.
- Map the active pointer to position (0, 0)—but only if the map doesn’t already have an entry for the pointer.
private fun handleDowns(event: MotionEvent) { if (event.actionMasked == MotionEvent.ACTION_DOWN) { onFirstDown() } when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { val id = event.getPointerId(event.actionIndex) if (!pointerIdToPosition.containsKey(id)) { pointerIdToPosition[id] = PointF() } schedulePick() } } }
- Write method
handlePositions
inPickerView
to accept aMotionEvent
parameter. Iterate through all the currently tracked pointers and update the positions map. Possible solution.private fun handlePositions(event: MotionEvent) { repeat (event.pointerCount) { i -> id = event.getPointerId(i) pointerIdToPosition[id]?.apply { x = event.getX(i) y = event.getY(i) } } }
- Write method
handleUps
inPickerView
to accept aMotionEvent
parameter. When any up event occurs, first or otherwise, do two things:- Reschedule the picking.
- Remove any entry for the active pointer from the positions map.
private fun handleUps(event: MotionEvent) { when (event.actionMasked) { MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { pointerIdToPosition.remove(event.getPointerId(event.actionIndex)) schedulePick() } } }
- Write method
onTouchEvent
inPickerView
to handle any down events, update the tracked pointer positions, and handle any update events. Also, so that the UI reflects the pointer state, force the view to be redrawn. Possible solution.override fun onTouchEvent(event: MotionEvent): Boolean { handleDowns(event) handlePositions(event) handleUps(event) invalidate() return true }
- Write method
drawFingerprint
inPickerView
to accept aCanvas
parameter. Drawfingerprint
in the center of the view, ensuring that it fits within the bounds. Assume that the fingerprint is square. Possible solution.private fun drawFingerprint(canvas: Canvas) { val imageAspect = fingerprint.intrinsicWidth / fingerprint.intrinsicHeight.toFloat() val frameAspect = width / height.toFloat() val boxWidth: Int val boxHeight: Int if (frameAspect < imageAspect) { boxWidth = width boxHeight = (width / imageAspect).toInt() } else { boxHeight = height boxWidth = (height * imageAspect).toInt() } val centerX = (width * 0.5f).toInt() val centerY = (height * 0.5f).toInt() fingerprint.setBounds(centerX - boxWidth / 2, centerY - boxHeight / 2, centerX + boxWidth / 2, centerY + boxHeight / 2) fingerprint.draw(canvas) }
- Write method
onDraw
inPickerView
to accept aCanvas
parameter. If there are no currently tracked pointers, draw the fingerprint as a cue for the user to press the screen. Otherwise, draw circles around each pointer. UseunpickedPaint
by default, but if a pointer is the picked pointer, usepickedPaint
. Possible solution.override fun onDraw(canvas: Canvas) { if (pointerIdToPosition.isEmpty()) { drawFingerprint(canvas) } else { pointerIdToPosition.forEach { id, p -> canvas.drawCircle(p.x, p.y, 150f, if (id == pickedId) pickedPaint else unpickedPaint) } } }
- Write method
enableFullscreen
inMainActivity
to put the app into fullscreen immersive mode. Ensure that a stable layout is used—even when the navigation controls and status bar are visible. Possible solution.private fun enableFullscreen() { window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_IMMERSIVE or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN }
TODO
Next lecture we will examine two common gestures that occur on mobile platforms: pinch-and-zoom and panning. We won’t create a full app, just a custom view that displays an image and supports these two gestures. Here’s the start to the custom view:
class ZoomPanView @JvmOverloads constructor(context: Context, attributes: AttributeSet? = null, styleAttributes: Int = 0) : View(context, attributes, styleAttributes) {
private val image: Bitmap = BitmapFactory.decodeResource(resources, R.drawable.some_image)
private var bounds = RectF()
}
The image will be drawn in the view’s Canvas
inside the frame demarcated by bounds
. Most of the exercises deal with adjusting bounds
appropriately.
The main activity does nothing but load this layout:
<org.twodee.zoompan.ZoomPanView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFFF"
tools:context=".MainActivity" />
The exercises that you will collectively complete are listed below. Check your email for your assigned exercise and a link to submit your solution. Become an expert on your particular corner of the app, investigating background material as needed. Build on top of the activity and others’ code as you complete your exercise. No solution should be very long, but some have more overhead like curly braces and method signatures than others.
- Define field
panListener
to be aSimpleOnGestureListener
. When a scroll event happens, it shifts the bounding box by the scrolled distance and forces the view to be redrawn. - Define field
zoomListener
to be aSimpleOnScaleGestureListener
. When a scale event happens, scale the bounding box. Bonus points if you clamp the scale from becoming too small. Tall poppy points if you scale about fingers’ centroid rather than (0, 0). - Investigate gesture detectors in Android. Define fields
panDetector
andzoomDetector
to be detectors for the two listeners defined in the previous steps. - Define method
fillWidth
to set the bounds such that the image fills the view’s width. Set the height to maintain the image’s aspect ratio. - Define method
fillHeight
to set the bounds such that the image fills the view’s height. Set the width to maintain the image’s aspect ratio. - Define method
onSizeChanged
to set the bounds to fill the view as much possible—filling either the width or height. Bonus points if you figure out what criteria can fit the image to the view without cropping any of it. - Define method
onDraw
to draw the bitmap to fill the current bounds. - Define method
onTouchEvent
to let the detectors process the event.
See you next time!
P.S. It’s time for a haiku!
It’s not multitouch
When I flit across the phone
It’s finger skating