Gestures

As you may have seen from the Get Started example, a map view by default supports all common map gestures, for example, pinch and double tap to zoom in. The following table is a summary of the available gestures and their corresponding default behavior on the map.

Tap the screen with one finger. This gesture does not have a predefined map action.
To zoom the map in a fixed amount, tap the screen twice with one finger.
Press and hold one finger to the screen. This gesture does not have a predefined map action.
To move the map, press and hold one finger to the screen, and move it in any direction. The map will keep moving with a little momentum after the finger was lifted.
To tilt the map, press and hold two fingers to the screen, and move them in a vertical direction. No behavior is predefined for other directions.
To zoom out a fixed amount, tap the screen with two fingers.
To zoom in or out continuously, press and hold two fingers to the screen, and increase or decrease the distance between them.

The HERE SDK for iOS provides support for the following gestures:

  • Tap: TapDelegate
  • Double Tap: DoubleTapDelegate
  • Long Press: LongPressDelegate
  • Pan: PanDelegate
  • Two Finger Pan: TwoFingerPanDelegate
  • Two Finger Tap: TwoFingerTapDelegate
  • Pinch Rotate: PinchRotateDelegate

Each delegate provides a dedicated callback that informs you whenever the user performs an action that could be detected, for example, the beginning or the end of that specific gesture. Usually, you want to add a specific behavior to your application after a gesture was detected, like placing a map marker after a long press.

Note that only one delegate can be set at a time for the same gesture.

Control Map Actions

Setting a delegate listener does not affect the default map behavior of the gestures. That can be controlled independently. By default, all default behaviors, such as zooming in when double tapping the map, are enabled.

For example, you can disable the default map gesture behavior for double tap (zooms in) and two finger tap (zooms out) as follows:

mapView.gestures.disableDefaultAction(forGesture: .doubleTap)
mapView.gestures.disableDefaultAction(forGesture: .twoFingerTap)

When disabling a default map action, you can still listen for the gesture event. This can be useful when you want to turn off the default action of a gesture to implement your own zooming behavior, for example. All gestures - except for tap and long press - provide a default map action. More details can be found in the overview above.

To bring back the default map gesture behavior, you can call:

mapView.gestures.enableDefaultAction(forGesture: .doubleTap)
mapView.gestures.enableDefaultAction(forGesture: .twoFingerTap)

Attach a Gesture Delegate

Let's see an example of how a gesture delegate can be attached to the map view. As soon as you set a delegate, it will receive all related events for that gesture via the dedicated callback, which is onTap() in case of a TapDelegate. The class conforming to this protocol will act as the delegate:

// Conform to the TapDelegate protocol.
func onTap(origin: Point2D) {
    let geoCoordinates = mapView.camera.viewToGeoCoordinates(viewCoordinates: origin)
    print("Tap at: \(String(describing: geoCoordinates))")
}

Finally, let the map view know that your class wants to get notified about the tap touch event and start listening:

mapView.gestures.tapDelegate = self

As soon as you set a delegate, you will begin to receive notifications that gestures have been detected.

The origin specifies the point from the device's screen where the gesture happened. By calling mapView.camera.viewToGeoCoordinates(viewCoordinates: origin), you can convert the pixels into geographic coordinates (as shown above).

Likewise, to stop listening, we can simply call:

mapView.gestures.tapDelegate = nil

For continuous gestures (like long press, pinch, pan, two finger pan), the begin gesture state will indicate that the gesture was detected. While the finger(s) still touch the display, you may receive update states, until the end state indicates that a finger has been lifted:

// Conform to the LongPressDelegate protocol.
func onLongPress(state: GestureState, origin: Point2D) {
    if (state == .begin) {
        let geoCoordinates = mapView.camera.viewToGeoCoordinates(viewCoordinates: origin)
        print("LongPress detected at: \(String(describing: geoCoordinates))")
    }

    if (state == .update) {
        let geoCoordinates = mapView.camera.viewToGeoCoordinates(viewCoordinates: origin)
        print("LongPress update at: \(String(describing: geoCoordinates))")
    }

    if (state == .end) {
        let geoCoordinates = mapView.camera.viewToGeoCoordinates(viewCoordinates: origin)
        print("LongPress finger lifted at: \(String(describing: geoCoordinates))")
    }
}

For example, a user may still keep his finger on the screen after a long press event was detected - or even move it around. However, only the begin event will mark the point in time, when the long press gesture was detected.

A long press gesture can be useful to place a map marker onto the map. An example of this can be seen in the Search example app.

Note that for the non-continuous gestures (like tap, double tap, two finger tap), no GestureState is needed to handle the gesture.

Tutorial - Customize Map Actions

As we have already seen, by default, a double tap gesture zooms in the map in discrete steps - for example, from city level closer to street level. You can disable such default map gesture actions to implement your own behaviours - or you can add your desired actions to existing behaviors.

If needed, it is also possible to combine platform gesture handling with the HERE SDK gesture detection. The HERE SDK does not provide all kind of low level gesture events as it is primarily focusing on the common map gestures - for your convenience. If you need more granular control, you can always combine the gesture handling available from the HERE SDK with native gesture detection.

For this tutorial, we want to show how to enable custom zoom animations: The map should zoom gradually in or out after the user has performed a double tap or a two finger tap gesture.

Add Custom Zoom Behavior

Let's start with the animation. For this we can use Apple's CADisplayLink to synchronize our animations with the refresh rate of the display.

For convenience, we create a new class called GestureMapAnimator, that should handle all gesture related animations. It requires a reference to the map's CameraLite instance, as the map needs to be zoomed via the camera.

Inside the GestureMapAnimator we hold a reference to our zoom animation. The CADisplayLink instance can be initialized lazily. Note that we also declare the corresponding loop method animatorLoopZoom (which we show later):

// A run loop to zoom in/out the map continuously until zoomVelocity is zero.
private lazy var displayLinkZoom = CADisplayLink(target: self,
                                                 selector: #selector(animatorLoopZoom))

Now we can already implement a stopAnimations() method that simply pauses all ongoing animations of the corresponding CADisplayLink instance:

// Stop any ongoing zoom animation.
func stopAnimations() {
    displayLinkZoom.isPaused = true
}

By default, the map zooms in/out in one discrete step at the location where the finger touches the map - without intermediate steps. For this tutorial, we simplify the behavior to zoom the map at the default target location - but, of course, we will add intermediate steps to introduce the desired animation. Later on we will also show how to zoom in at the finger's touch point.

Let's hook up the needed gesture events:

mapView.gestures.disableDefaultAction(forGesture: .doubleTap)
mapView.gestures.disableDefaultAction(forGesture: .twoFingerTap)

// Conform to the DoubleTapDelegate protocol.
func onDoubleTap(origin: Point2D) {
    // Start our custom zoom in animation.
    gestureMapAnimator.zoomIn()
}

// Conform to the TwoFingerTapDelegate protocol.
func onTwoFingerTap(origin: Point2D) {
    // Start our custom zoom out animation.
    gestureMapAnimator.zoomOut()
}

No magic here: We simply listen for the two gesture events. We just have to make sure to disable the default zoom behavior. The zoomIn() and zoomOut() methods from above lead to a new method in our GestureMapAnimator you can see below.

Additionally, we have to define two constants to determine the starting value startZoomVelocity for our zoom animation and the amount zoomDelta by which the current zoomVelocity value should decrease over time. The startZoomAnimation() method takes care to start the run loop:

private let startZoomVelocity: Double = 0.1
private let zoomDelta: Double = 0.005

// Starts the zoom in animation.
func zoomIn() {
    isZoomIn = true
    startZoomAnimation()
}

// Starts the zoom out animation.
func zoomOut() {
    isZoomIn = false
    startZoomAnimation()
}

private func startZoomAnimation() {
    stopAnimations()

    zoomVelocity = startZoomVelocity
    displayLinkZoom.isPaused = false
    displayLinkZoom.add(to: .current, forMode: .common)
}

Note that we use the flag isZoomIn to be able to handle the zooming code in one method. To start the animation we unpause the displayLinkZoom loop and update the zoom values in the corresponding selector method animatorLoopZoom(). This method will be called periodically until displayLinkZoom is paused again by calling the stopAnimations() method we have already seen before:

@objc private func animatorLoopZoom() {
    var zoom = camera.getZoomLevel()
    zoom = isZoomIn ? zoom + zoomVelocity : zoom - zoomVelocity;
    camera.setZoomLevel(zoom)
    zoomVelocity = zoomVelocity - zoomDelta
    if (zoomVelocity <= 0) {
        stopAnimations()
    }
}

The simple algorithm above interpolates the zoomVelocity value from 0.1 close to 0. zoomVelocity is the animated value we can use to gradually zoom the map. Our starting value is defined in startZoomVelocity as 0.1. We use zoomVelocity as argument to change the current zoom value from the previous frame. As zoomVelocity slowly reaches 0, the resulting zoom step will get smaller and smaller.

The boolean flag isZoomIn indicates if the map should be zoomed in or out. This enables us to use the code for both zoom cases: The only difference is that the animated value zoomVelocity is added or subtracted from the current zoom level.

Zoom In at the Touch Point

Above we have simplified our custom zoom in behavior as we have ignored the touch origin. Ideally, the map should zoom in at the point where the finger has touched the screen. How to make this possible? By default, all programmatical map manipulations are based on the target anchor point which is centered on the map view. Therefore, the map zooms at the map's center location. You can read more about the target anchor point concept here.

All we have to do is to change that point to the point where the finger has touched the screen. As a result, a call to zoom the map will use this new anchor point as target. The double tap gesture already provides a Point2D instance that indicates the touch origin. Changing the target anchor point is easy - we only need to add one more line of code to our zoom in method:

func zoomIn(_ mapView: MapViewLite, _ origin: Point2D) {
    // Change the anchor point to zoom in at the touched point.
    camera.targetAnchorPoint = getAnchorPoint(mapView, origin)

    isZoomIn = true
    startZoomAnimation()
}

The anchor point can be specified with normalized coordinates where (0, 0) marks the top-left corner and (1, 1) the bottom-right corner of the map view. In opposition, the touch point delivered from the double tap gesture is providing the pixel position on the map view. This requires us to calculate the new anchor using the following method:

private func getAnchorPoint(_ mapView: MapViewLite, _ origin: Point2D) -> Anchor2D {
    let scaleFactor = UIScreen.main.scale
    let mapViewWidthInPixels = Double(mapView.bounds.width * scaleFactor)
    let mapViewHeightInPixels = Double(mapView.bounds.height * scaleFactor)

    let normalizedX = (1.0 / mapViewWidthInPixels) * origin.x
    let normalizedY = (1.0 / mapViewHeightInPixels) * origin.y

    let transformCenter = Anchor2D(horizontal: Float(normalizedX),
                                   vertical: Float(normalizedY))
    return transformCenter
}

First, we need to get the pixel size of our map view depending on the device's scaleFactor. Then we can calculate the normalized touch point on that view which will make up our new Anchor2D instance. Once this new target anchor point is set, the map will smoothly zoom in at the touched location.

Since zooming out is performed using two fingers, it may be desired to perform that gesture using the map's center as target. Therefore, when stopping your animations, you may wish to optionally reset the anchor point to it's default position:

// Reset anchor point to its default location.
camera.targetAnchorPoint = Anchor2D(horizontal: 0.5, vertical: 0.5)

If your application requires different anchor points, you may instead store the previous anchor point before starting the zoom animation - and reset to it after the animation has stopped. Keep in mind that the target anchor point also determines where a new map location is shown in relation to the map view - usually, this should be at the center of the view.

Now, that's it for our little excursion. Feel free to adapt the above code snippets to your own needs. For example, start by playing around with different interpolators, different animated values - or a different duration for an animation.

results matching ""

    No results matching ""