Gesture Detector
The gesture detector of MapLibre Android is encapsulated in the maplibre-gestures-android
package.
Gesture Listeners
You can add listeners for move, rotate, scale and shove gestures. For example, adding a move gesture listener with MapLibreMap.addOnRotateListener
:
maplibreMap.addOnMoveListener(
object : OnMoveListener {
override fun onMoveBegin(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "MOVE START")
)
}
override fun onMove(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "MOVE PROGRESS")
)
}
override fun onMoveEnd(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "MOVE END")
)
recalculateFocalPoint()
}
}
)
Refer to the full example below for examples of listeners for the other gesture types.
Settings
You can access an UISettings
object via MapLibreMap.uiSettings
. Available settings include:
- Toggle Quick Zoom. You can double tap on the map to use quick zoom. You can toggle this behavior on and off (
UiSettings.isQuickZoomGesturesEnabled
). - Toggle Velocity Animations. By default flicking causes the map to continue panning (while decelerating). You can turn this off with
UiSettings.isScaleVelocityAnimationEnabled
. - Toggle Rotate Enabled. Use
uiSettings.isRotateGesturesEnabled
. - Toggle Zoom Enabled. Use
uiSettings.isZoomGesturesEnabled
.
Full Example Activity
GestureDetectorActivity.kt
package org.maplibre.android.testapp.activity.camera
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.IntDef
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.maplibre.android.gestures.AndroidGesturesManager
import org.maplibre.android.gestures.MoveGestureDetector
import org.maplibre.android.gestures.RotateGestureDetector
import org.maplibre.android.gestures.ShoveGestureDetector
import org.maplibre.android.gestures.StandardScaleGestureDetector
import org.maplibre.android.annotations.Marker
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapLibreMap.CancelableCallback
import org.maplibre.android.maps.MapLibreMap.OnMoveListener
import org.maplibre.android.maps.MapLibreMap.OnRotateListener
import org.maplibre.android.maps.MapLibreMap.OnScaleListener
import org.maplibre.android.maps.MapLibreMap.OnShoveListener
import org.maplibre.android.maps.MapView
import org.maplibre.android.testapp.R
import org.maplibre.android.testapp.styles.TestStyles
import org.maplibre.android.testapp.utils.FontCache
import org.maplibre.android.testapp.utils.ResourceUtils
/** Test activity showcasing APIs around gestures implementation. */
class GestureDetectorActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var maplibreMap: MapLibreMap
private lateinit var recyclerView: RecyclerView
private var gestureAlertsAdapter: GestureAlertsAdapter? = null
private var gesturesManager: AndroidGesturesManager? = null
private var marker: Marker? = null
private var focalPointLatLng: LatLng? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_gesture_detector)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map: MapLibreMap ->
maplibreMap = map
maplibreMap.setStyle(TestStyles.getPredefinedStyleWithFallback("Streets"))
initializeMap()
}
recyclerView = findViewById(R.id.alerts_recycler)
recyclerView.setLayoutManager(LinearLayoutManager(this))
gestureAlertsAdapter = GestureAlertsAdapter()
recyclerView.setAdapter(gestureAlertsAdapter)
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
gestureAlertsAdapter!!.cancelUpdates()
mapView.onPause()
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
private fun initializeMap() {
gesturesManager = maplibreMap.gesturesManager
val layoutParams = recyclerView.layoutParams as RelativeLayout.LayoutParams
layoutParams.height = (mapView.height / 1.75).toInt()
layoutParams.width = mapView.width / 3
recyclerView.layoutParams = layoutParams
attachListeners()
fixedFocalPointEnabled(maplibreMap.uiSettings.focalPoint != null)
}
fun attachListeners() {
// # --8<-- [start:addOnMoveListener]
maplibreMap.addOnMoveListener(
object : OnMoveListener {
override fun onMoveBegin(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "MOVE START")
)
}
override fun onMove(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "MOVE PROGRESS")
)
}
override fun onMoveEnd(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "MOVE END")
)
recalculateFocalPoint()
}
}
)
// # --8<-- [end:addOnMoveListener]
maplibreMap.addOnRotateListener(
object : OnRotateListener {
override fun onRotateBegin(detector: RotateGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "ROTATE START")
)
}
override fun onRotate(detector: RotateGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "ROTATE PROGRESS")
)
recalculateFocalPoint()
}
override fun onRotateEnd(detector: RotateGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "ROTATE END")
)
}
}
)
maplibreMap.addOnScaleListener(
object : OnScaleListener {
override fun onScaleBegin(detector: StandardScaleGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "SCALE START")
)
if (focalPointLatLng != null) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(
GestureAlert.TYPE_OTHER,
"INCREASING MOVE THRESHOLD"
)
)
gesturesManager!!.moveGestureDetector.moveThreshold =
ResourceUtils.convertDpToPx(this@GestureDetectorActivity, 175f)
gestureAlertsAdapter!!.addAlert(
GestureAlert(
GestureAlert.TYPE_OTHER,
"MANUALLY INTERRUPTING MOVE"
)
)
gesturesManager!!.moveGestureDetector.interrupt()
}
recalculateFocalPoint()
}
override fun onScale(detector: StandardScaleGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "SCALE PROGRESS")
)
}
override fun onScaleEnd(detector: StandardScaleGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "SCALE END")
)
if (focalPointLatLng != null) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(
GestureAlert.TYPE_OTHER,
"REVERTING MOVE THRESHOLD"
)
)
gesturesManager!!.moveGestureDetector.moveThreshold = 0f
}
}
}
)
maplibreMap.addOnShoveListener(
object : OnShoveListener {
override fun onShoveBegin(detector: ShoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "SHOVE START")
)
}
override fun onShove(detector: ShoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "SHOVE PROGRESS")
)
}
override fun onShoveEnd(detector: ShoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "SHOVE END")
)
}
}
)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_gestures, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val uiSettings = maplibreMap.uiSettings
when (item.itemId) {
R.id.menu_gesture_focus_point -> {
fixedFocalPointEnabled(focalPointLatLng == null)
return true
}
R.id.menu_gesture_animation -> {
uiSettings.isScaleVelocityAnimationEnabled =
!uiSettings.isScaleVelocityAnimationEnabled
uiSettings.isRotateVelocityAnimationEnabled =
!uiSettings.isRotateVelocityAnimationEnabled
uiSettings.isFlingVelocityAnimationEnabled =
!uiSettings.isFlingVelocityAnimationEnabled
return true
}
R.id.menu_gesture_rotate -> {
uiSettings.isRotateGesturesEnabled = !uiSettings.isRotateGesturesEnabled
return true
}
R.id.menu_gesture_tilt -> {
uiSettings.isTiltGesturesEnabled = !uiSettings.isTiltGesturesEnabled
return true
}
R.id.menu_gesture_zoom -> {
uiSettings.isZoomGesturesEnabled = !uiSettings.isZoomGesturesEnabled
return true
}
R.id.menu_gesture_scroll -> {
uiSettings.isScrollGesturesEnabled = !uiSettings.isScrollGesturesEnabled
return true
}
R.id.menu_gesture_double_tap -> {
uiSettings.isDoubleTapGesturesEnabled = !uiSettings.isDoubleTapGesturesEnabled
return true
}
R.id.menu_gesture_quick_zoom -> {
uiSettings.isQuickZoomGesturesEnabled = !uiSettings.isQuickZoomGesturesEnabled
return true
}
R.id.menu_gesture_scroll_horizontal -> {
uiSettings.isHorizontalScrollGesturesEnabled =
!uiSettings.isHorizontalScrollGesturesEnabled
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun fixedFocalPointEnabled(enabled: Boolean) {
if (enabled) {
focalPointLatLng = LatLng(51.50325, -0.12968)
marker = maplibreMap.addMarker(MarkerOptions().position(focalPointLatLng))
maplibreMap.easeCamera(
CameraUpdateFactory.newLatLngZoom(focalPointLatLng!!, 16.0),
object : CancelableCallback {
override fun onCancel() {
recalculateFocalPoint()
}
override fun onFinish() {
recalculateFocalPoint()
}
}
)
} else {
if (marker != null) {
maplibreMap.removeMarker(marker!!)
marker = null
}
focalPointLatLng = null
maplibreMap.uiSettings.focalPoint = null
}
}
private fun recalculateFocalPoint() {
if (focalPointLatLng != null) {
maplibreMap.uiSettings.focalPoint =
maplibreMap.projection.toScreenLocation(focalPointLatLng!!)
}
}
private class GestureAlertsAdapter : RecyclerView.Adapter<GestureAlertsAdapter.ViewHolder>() {
private var isUpdating = false
private val updateHandler = Handler(Looper.getMainLooper())
private val alerts: MutableList<GestureAlert> = ArrayList()
class ViewHolder internal constructor(view: View) : RecyclerView.ViewHolder(view) {
var alertMessageTv: TextView
init {
val typeface = FontCache.get("Roboto-Regular.ttf", view.context)
alertMessageTv = view.findViewById(R.id.alert_message)
alertMessageTv.typeface = typeface
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view =
LayoutInflater.from(parent.context)
.inflate(R.layout.item_gesture_alert, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val alert = alerts[position]
holder.alertMessageTv.text = alert.message
holder.alertMessageTv.setTextColor(
ContextCompat.getColor(holder.alertMessageTv.context, alert.color)
)
}
override fun getItemCount(): Int {
return alerts.size
}
fun addAlert(alert: GestureAlert) {
for (gestureAlert in alerts) {
if (gestureAlert.alertType != GestureAlert.TYPE_PROGRESS) {
break
}
if (alert.alertType == GestureAlert.TYPE_PROGRESS && gestureAlert == alert) {
return
}
}
if (itemCount >= MAX_NUMBER_OF_ALERTS) {
alerts.removeAt(itemCount - 1)
}
alerts.add(0, alert)
if (!isUpdating) {
isUpdating = true
updateHandler.postDelayed(updateRunnable, 250)
}
}
@SuppressLint("NotifyDataSetChanged")
private val updateRunnable = Runnable {
notifyDataSetChanged()
isUpdating = false
}
fun cancelUpdates() {
updateHandler.removeCallbacksAndMessages(null)
}
}
private class GestureAlert(
@field:Type @param:Type
val alertType: Int,
val message: String?
) {
@Retention(AnnotationRetention.SOURCE)
@IntDef(TYPE_NONE, TYPE_START, TYPE_PROGRESS, TYPE_END, TYPE_OTHER)
annotation class Type
@ColorInt var color = 0
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other == null || javaClass != other.javaClass) {
return false
}
val that = other as GestureAlert
if (alertType != that.alertType) {
return false
}
return if (message != null) message == that.message else that.message == null
}
override fun hashCode(): Int {
var result = alertType
result = 31 * result + (message?.hashCode() ?: 0)
return result
}
companion object {
const val TYPE_NONE = 0
const val TYPE_START = 1
const val TYPE_END = 2
const val TYPE_PROGRESS = 3
const val TYPE_OTHER = 4
}
init {
when (alertType) {
TYPE_NONE -> color = android.R.color.black
TYPE_END -> color = android.R.color.holo_red_dark
TYPE_OTHER -> color = android.R.color.holo_purple
TYPE_PROGRESS -> color = android.R.color.holo_orange_dark
TYPE_START -> color = android.R.color.holo_green_dark
}
}
}
companion object {
private const val MAX_NUMBER_OF_ALERTS = 30
}
}