Camera

The HERE SDK for iOS provides several ways to change the view of the map. While with map styles you can change the look of the map, you can use a camera to look at the map from different perspectives.

For example, the HERE SDK for iOS allows you to set a target location, tilt the map, zoom in and out or set a bearing.

At a glance

  • Use the CameraLite accessible by mapView.camera to manipulate the view of the map.
  • Call camera.updateCamera(cameraUpdate:) to apply all camera properties in one go.
  • Monitor changes to a camera by registering a CameraObserverLite.
  • Put constraints on a camera by setting limits to the CameraLimitsLite object accessible by camera.limits.
  • Convert between view and geographical coordinate spaces by using geoToViewCoordinates(geoCoordinates:) and viewToGeoCoordinates(viewCoordinates:).
  • Get the bounds of the currently displayed area by accessing camera.boundingBox.

By default, the camera is located centered above the map. From a bird's eye view looking straight-down, the map is oriented North-up. This means that on your device, the top edge is pointing to the north of the map.

Rotate the Map

You can change the orientation of the map by setting a bearing angle. 'Bearing' is a navigational term, counted in degrees, from the North in clockwise direction.

Illustration: Set a bearing direction.

By default, the map is set to a bearing of 0°. When setting an angle of 45°, as visualized in the illustration above, the map rotates counter-clockwise and the direction of the bearing becomes the new upward direction on your map, pointing to the top edge of your device. This is similar to holding and rotating a paper map while hiking in a certain direction. Apparently, it is easier to orient yourself if the map's top edge points in the direction in which you want to go. However, this will not always be the true North direction (bearing = 0°).

Tilt the Map

The camera can also be used to turn the flat 2D map surface to a 3D perspective to see, for example, roads at a greater distance that may appear towards the horizon. By default, the map is not tilted (tilt = 0°).

The tilt angle is calculated from the vertical axis at the target location. This direction pointing straight-down below the observer is called the nadir. As visualized in the illustration below, tilting the map by 45° will change the bird's eye view of the camera to a 3D perspective. Although this will effectively move the camera, any subsequent tilt values will always be applied from the camera's default location. Tilt values above and below the limits are clamped, otherwise, no map may be visible. These absolute values are also available as constant for the minimum value (CameraLimitsLite.minTilt), or can be retrieved at runtime:

// Error cannot happen here as we set the allowed max value for zoom level 12.
try! cameraLimits.setMaxTilt(degreesFromNadir: CameraLimitsLite.getMaxTiltForZoomLevel(zoomLevel: 12))

This way, you can specify your own camera limits within the absolute range.

Illustration: Tilt the map.

Change the Map Location

By setting a new camera target, you can change the map location instantly. By default, the target is centered on the map. Overall, using the camera is very simple. See some examples in the following code snippets:

// Set the map to a new location - together with a new zoom level and tilt value.
mapView.camera.setTarget(GeoCoordinates(latitude: 52.530932, longitude: 13.384915))
mapView.camera.setZoomLevel(14)
mapView.camera.setTilt(degreesFromNadir: 60)

Alternatively, you can apply multiple changes in one go by setting a CameraUpdateLite.

The camera also allows you to specify a custom range to limit specific values, and it provides a way to observe updates on these values, for example, when a user has interacted with the map by performing a double tap gesture.

By setting a new target anchor point, you can change the default camera anchor point which is located at x = 0.5, y = 0.5 - this indicates the center of the map view. The target location will be used for all programmatical map transformations like rotate and tilt - or when setting a new target location. It does not affect default gestures like pinch rotate and two finger pan to tilt the map. This is how to set a new target anchor point:

let transformationCenter = Anchor2D(horizontal: normalizedX,
                                    vertical: normalizedY)
camera.targetAnchorPoint = transformationCenter

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. Therefore, after setting the anchor to (1, 1) any new target location would appear at the bottom-right corner of the map view. Values outside the map view will be clamped.

Setting a new anchor point for the target has no visible effect on the map - until you set a new target location, or when tilting or rotating the map programmatically: The tile angle is located at the horizontal coordinate of the anchor. Likewise, the rotation center point is equal to the anchor.

You can find an example on how to make use of target anchor points in this tutorial. It shows how to zoom in at a specific point on the map view.

Listen to Camera Changes

By adding an observer and conforming to the CameraObserverLite protocol, your class can be notified when the camera (and thus the map) is changed:

func onCameraUpdated(_ cameraUpdate: CameraUpdateLite) {
    let mapCenter = cameraUpdate.target
    print("Current map center location: \(mapCenter). Current zoom level: \(cameraUpdate.zoomLevel)");
}

func addCameraObserver() {
    mapView.camera.addObserver(self)
}

In addition, you can manually perform fast and smooth interactions with the map. By default, a double tap zooms in, and panning allows you to move the map around with your fingers. You can read more about default map behaviors in the Gestures section.

Tutorial - Animate to a Location

By setting a new camera target, you can instantly jump to any location on the map - without delay. However, for some scenarios, it may be more user-friendly to show a map that moves slowly from the current location to a new location.

Such a Move-To-XY method can be realized by interpolating between the current and the new geographic coordinates. Each intermediate set of latitude / longitude pairs can then be set as the new camera target. Luckily, this animated transition is easy to achieve with Apple's CADisplayLink. An instance of this class can be used to to synchronize our camera updates with the refresh rate of the display - the result will be a smooth map transition.

To get started, we first define how our interfaces should look like. It's best to hold all animation code separated from the rest of your application code. Therefore we decide to create a new class called CameraAnimator. Usage should look like this:

private let defaultZoomLevel: Double = 14
private lazy var cameraAnimator = CameraAnimator(mapView.camera)
...
cameraAnimator.moveTo(geoCoordinates, defaultZoomLevel)

That's all we are about to show in this tutorial. First, we lazily create a new CameraAnimator instance that requires a CameraLite object as dependency. Our moveTo() method accepts the new camera target location and the desired zoom level.

As a next step, we add a displayLink instance to our new CameraAnimator class. We also specify the selector method animatorLoop which will be executed periodically once we unpause the displayLink later on:

private lazy var displayLink = CADisplayLink(target: self,
                                             selector: #selector(animatorLoop))

Additionally, we allow our loop to run with a variable duration, therefore we allow to overwrite its default duration:

private var animationDurationInSeconds: Double = 2

func setDurationInSeconds(animationDurationInSeconds: Double) {
    self.animationDurationInSeconds = animationDurationInSeconds
}

We plan to implement a linear animation type for our moveTo() method, so short distances will animate slower than distances that are farther away from the current location. By default, the run loop should stop after 2 seconds - as defined above.

The implementation of the moveTo() method should take care to start the animation. The animation should also stop automatically after it has ended (as we show later):

func moveTo(_ destination: GeoCoordinates, _ targetZoom: Double) {
    currentCamera = CameraUpdateLite(tilt: camera.getTilt(),
                                     bearing: camera.getBearing(),
                                     zoomLevel: camera.getZoomLevel(),
                                     target: camera.getTarget())

    // Take the shorter bearing difference.
    let targetBearing: Double = camera.getBearing() > 180 ? 360 : 0

    targetCamera = CameraUpdateLite(tilt: 0,
                                    bearing: targetBearing,
                                    zoomLevel: targetZoom,
                                    target: destination)

    // Start the run loop to execute animatorLoop() periodically.
    startTimeInSeconds = -1
    displayLink.isPaused = false
    displayLink.add(to: .current, forMode: .common)
}

Here we create two new CameraUpdateLite instances as we want to animate not only the location, but also other camera parameters like tilt and rotation. The first instance holds the current map state, while the second one defines the desired end state that we want to reach when the animation stops.

Irrespective of what tilt value is currently set, we would like to animate back to a non-tilted map (tilt: 0). For the rotation, we need to decide which bearing value is faster to reach: A non-rotated map has a bearing of 0°, which is the same as 360° - therefore we check the current bearing value: If it is 200°, for example, it is faster for us to animate until we reach 360°.

Note that we get the exact start time startTimeInSeconds from our displayLink instance - after we started the loop. displayLink.timestamp provides a Double value which is precise enough to know the elapsed milliseconds. Basically, we are interested in the current and previous timestamps of each execution cycle. This allows us to calculate the frameDurationInSeconds for each cycle. Each call to our loop results in a new frame where we update the current camera. Let's see how our loop does this:

@objc private func animatorLoop() {
    if startTimeInSeconds == -1 {
        // 1st frame, there's no previous frame.
        startTimeInSeconds = displayLink.timestamp
        previousTimeInSeconds = startTimeInSeconds
        return
    }

    let currentTimeInSeconds = displayLink.timestamp
    let elapsedTimeInSeconds = currentTimeInSeconds - startTimeInSeconds

    let frameDurationInSeconds = currentTimeInSeconds - previousTimeInSeconds
    let remainingFrames = (animationDurationInSeconds - elapsedTimeInSeconds) / frameDurationInSeconds

    // Calculate the new camera update.
    currentCamera = interpolate(currentCamera, targetCamera, remainingFrames)

    if elapsedTimeInSeconds >= animationDurationInSeconds {
        displayLink.isPaused = true
        camera.updateCamera(cameraUpdate: targetCamera)
        return
    }

    camera.updateCamera(cameraUpdate: currentCamera)
    previousTimeInSeconds = displayLink.timestamp
}

As you can see above, we pause the animation after the elapsed time (since we called moveTo()) reaches or exceeds animationDurationInSeconds - no matter how far away the new location is. This requires us to calculate the intermediate camera values based on the total duration.

Usually, a display link will execute with 60 FPS (frames per second). In case there are some drops due to a heavier load on the CPU, we calculate an estimation of the remainingFrames per cycle - based on the previous cycle times. For example, we might need less if some frames must be skipped. This way we can adjust the next values for each step to be in sync with the desired interpolation.

An interpolator defines how fast (or slow) a value should change over time. For our purpose, we decided to use a linear algorithm that provides a constant speed throughout the animation, but varies depending on the distance between current and target location.

Before we come to the actual interpolation, we need to feed in the values:

private func interpolate(_ currentCamera: CameraUpdateLite,
                         _ targetCamera: CameraUpdateLite,
                         _ remainingFrames: Double) -> CameraUpdateLite {
    let newTilt = interpolateLinear(currentValue: currentCamera.tilt,
                                    targetValue: targetCamera.tilt,
                                    remainingFrames: remainingFrames)

    let newBearing = interpolateLinear(currentValue: currentCamera.bearing,
                                       targetValue: targetCamera.bearing,
                                       remainingFrames: remainingFrames)

    let newZoomLevel = interpolateLinear(currentValue: currentCamera.zoomLevel,
                                         targetValue: targetCamera.zoomLevel,
                                         remainingFrames: remainingFrames)

    let newTargetLatitude = interpolateLinear(currentValue: currentCamera.target.latitude,
                                              targetValue: targetCamera.target.latitude,
                                              remainingFrames: remainingFrames)

    let newTargetLongitude = interpolateLinear(currentValue: currentCamera.target.longitude,
                                               targetValue: targetCamera.target.longitude,
                                               remainingFrames: remainingFrames)

    return CameraUpdateLite(tilt: newTilt,
                            bearing: newBearing,
                            zoomLevel: newZoomLevel,
                            target: GeoCoordinates(latitude: newTargetLatitude,
                                                   longitude: newTargetLongitude))
}

As mentioned above, we decided to interpolate different camera parameters. Therefore, we need to calculate five different interpolations:

  • Zoom interpolation: Interpolates from current zoom level to our target zoom level.
  • Tilt interpolation: Interpolates from current tilt value to our target tilt value.
  • Bearing interpolation: Interpolates from the current bearing degree to our target bearing value. This effectively rotates the map.
  • Latitude and longitude interpolation: Both interpolate a single coordinate from the current target location to the desired new target.

Since the code to create each interpolation is the same, we externalized it to this separate method:

private func interpolateLinear(currentValue: Double,
                               targetValue: Double,
                               remainingFrames: Double) -> Double {
    let delta = (currentValue - targetValue) / remainingFrames
    let newValue = currentValue - delta

    // Overflow check.
    if (currentValue < targetValue) {
        if newValue >= targetValue {
            return targetValue
        }
    } else {
        if newValue <= targetValue {
            return targetValue
        }
    }

    return newValue
}

For our linear animation style, the algorithm is quite simple - we just subtract the difference between the current value and the targeted end value divided by the expected number of frames. When the animation runs stable, we will reach the specified end value when the animation stops. In case there are instabilities, we add an overflow check to not exceed the desired value.

Each resulting value will be added to a new CameraUpdateLite instance that we use in the animatorLoop() to adjust the map view by calling:

camera.updateCamera(cameraUpdate: currentCamera)

Calling updateCamera() will instantly change the map's appearance to the specified values. As this code is executed many times per second, it will appear as a smooth animation to the human eye.

Feel free to play around with the above code snippets to try out different behaviors.

This is just an example of how to implement custom transitions from one location to another with the CameraLite. By using different interpolators you can realize any animation style. From classic bow transitions (zooms out and then in again) over straight-forward linear transitions to any other transition you would like to have.

results matching ""

    No results matching ""