Skip to content

Commit

Permalink
Add Map Composable, view and renderer
Browse files Browse the repository at this point in the history
  • Loading branch information
Rawa committed Feb 14, 2024
1 parent 57216d6 commit 51c9a16
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package net.mullvad.mullvadvpn.lib.map

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
import net.mullvad.mullvadvpn.lib.map.internal.DISTANCE_DURATION_SCALE_FACTOR
import net.mullvad.mullvadvpn.lib.map.internal.FAR_ANIMATION_MAX_ZOOM_MULTIPLIER
import net.mullvad.mullvadvpn.lib.map.internal.MAX_ANIMATION_MILLIS
import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING
import net.mullvad.mullvadvpn.lib.map.internal.MIN_ANIMATION_MILLIS
import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS
import net.mullvad.mullvadvpn.model.LatLong
import net.mullvad.mullvadvpn.model.Latitude
import net.mullvad.mullvadvpn.model.Longitude

@Composable
fun animatedCameraPosition(
baseZoom: Float,
targetCameraLocation: LatLong,
cameraVerticalBias: Float,
): CameraPosition {

var previousLocation by remember { mutableStateOf(targetCameraLocation) }
var currentLocation by remember { mutableStateOf(targetCameraLocation) }

if (targetCameraLocation != currentLocation) {
previousLocation = currentLocation
currentLocation = targetCameraLocation
}

val distance =
remember(targetCameraLocation) { targetCameraLocation.distanceTo(previousLocation).toInt() }
val duration = distance.toAnimationDuration()

val longitudeAnimation = remember { Animatable(targetCameraLocation.longitude.value) }

val latitudeAnimation = remember { Animatable(targetCameraLocation.latitude.value) }
val zoomOutMultiplier = remember { Animatable(1f) }

LaunchedEffect(targetCameraLocation) {
launch { latitudeAnimation.animateTo(targetCameraLocation.latitude.value, tween(duration)) }
launch {
// Unwind longitudeAnimation into a Longitude
val currentLongitude = Longitude.fromFloat(longitudeAnimation.value)

// Resolve a vector showing us the shortest path to the target longitude, e.g going
// from 170 to -170 would result in 20 since we can wrap around the globe
val shortestPathVector = currentLongitude.vectorTo(targetCameraLocation.longitude)

// Animate to the new camera location using the shortest path vector
longitudeAnimation.animateTo(
longitudeAnimation.value + shortestPathVector.value,
tween(duration),
)

// Current value animation value might be outside of range of a Longitude, so when the
// animation is done we unwind the animation to the correct value
longitudeAnimation.snapTo(targetCameraLocation.longitude.value)
}
launch {
zoomOutMultiplier.animateTo(
targetValue = 1f,
animationSpec =
keyframes {
if (duration < SHORT_ANIMATION_CUTOFF_MILLIS) {
durationMillis = duration
1f at duration using EaseInOut
} else {
durationMillis = duration
FAR_ANIMATION_MAX_ZOOM_MULTIPLIER at
(duration * MAX_MULTIPLIER_PEAK_TIMING).toInt() using
EaseInOut
1f at duration using EaseInOut
}
}
)
}
}

return CameraPosition(
zoom = baseZoom * zoomOutMultiplier.value,
latLong =
LatLong(
Latitude(latitudeAnimation.value),
Longitude.fromFloat(longitudeAnimation.value)
),
verticalBias = cameraVerticalBias
)
}

private fun Int.toAnimationDuration() =
(this * DISTANCE_DURATION_SCALE_FACTOR).coerceIn(MIN_ANIMATION_MILLIS, MAX_ANIMATION_MILLIS)
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package net.mullvad.mullvadvpn.lib.map

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
import net.mullvad.mullvadvpn.lib.map.data.GlobeColors
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
import net.mullvad.mullvadvpn.lib.map.data.Marker
import net.mullvad.mullvadvpn.lib.map.internal.MapGLSurfaceView
import net.mullvad.mullvadvpn.model.LatLong

@Composable
fun Map(
modifier: Modifier,
cameraLocation: CameraPosition,
markers: List<Marker>,
globeColors: GlobeColors,
) {
val mapViewState = MapViewState(cameraLocation, markers, globeColors)
Map(modifier = modifier, mapViewState = mapViewState)
}

@Composable
fun AnimatedMap(
modifier: Modifier,
cameraLocation: LatLong,
cameraBaseZoom: Float,
cameraVerticalBias: Float,
markers: List<Marker>,
globeColors: GlobeColors
) {
Map(
modifier = modifier,
cameraLocation =
animatedCameraPosition(
baseZoom = cameraBaseZoom,
targetCameraLocation = cameraLocation,
cameraVerticalBias = cameraVerticalBias
),
markers = markers,
globeColors
)
}

@Composable
internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) {

var view: MapGLSurfaceView? = remember { null }

val lifeCycleState = LocalLifecycleOwner.current.lifecycle

DisposableEffect(key1 = lifeCycleState) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
view?.onResume()
}
Lifecycle.Event.ON_PAUSE -> {
view?.onPause()
}
else -> {}
}
}
lifeCycleState.addObserver(observer)

onDispose {
lifeCycleState.removeObserver(observer)
view?.onPause()
view = null
}
}

AndroidView(modifier = modifier, factory = { MapGLSurfaceView(it) }) { glSurfaceView ->
view = glSurfaceView
glSurfaceView.setData(mapViewState)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package net.mullvad.mullvadvpn.lib.map.internal

internal const val VERTEX_COMPONENT_SIZE = 3
internal const val COLOR_COMPONENT_SIZE = 4
internal const val MATRIX_SIZE = 16

// Constant what will talk the distance in LatLng multiply it to determine the animation duration,
// the result is then confined to the MIN_ANIMATION_MILLIS and MAX_ANIMATION_MILLIS
internal const val DISTANCE_DURATION_SCALE_FACTOR = 20
internal const val MIN_ANIMATION_MILLIS = 1300
internal const val MAX_ANIMATION_MILLIS = 2500
// The cut off where we go from a short animation (camera pans) to a far animation (camera pans +
// zoom out)
internal const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700

// Multiplier for the zoom out animation
internal const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.30f
// When in the far animation we reach the MAX_ZOOM_MULTIPLIER, value is between 0 and 1
internal const val MAX_MULTIPLIER_PEAK_TIMING = .35f
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package net.mullvad.mullvadvpn.lib.map.internal

import android.content.res.Resources
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.opengl.Matrix
import androidx.collection.LruCache
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
import kotlin.math.tan
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
import net.mullvad.mullvadvpn.lib.map.internal.shapes.Globe
import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker
import net.mullvad.mullvadvpn.model.COMPLETE_ANGLE

internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer {

private lateinit var globe: Globe

// Due to location markers themselves containing colors we cache them to avoid recreating them
// for every draw call.
private val markerCache: LruCache<LocationMarkerColors, LocationMarker> =
object : LruCache<LocationMarkerColors, LocationMarker>(100) {
override fun entryRemoved(
evicted: Boolean,
key: LocationMarkerColors,
oldValue: LocationMarker,
newValue: LocationMarker?
) {
oldValue.onRemove()
}
}

private lateinit var viewState: MapViewState

override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
globe = Globe(resources)
markerCache.evictAll()
initGLOptions()
}

private fun initGLOptions() {
GLES20.glEnable(GLES20.GL_CULL_FACE)
GLES20.glCullFace(GLES20.GL_BACK)

GLES20.glEnable(GLES20.GL_BLEND)
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
}

private val projectionMatrix = newIdentityMatrix()

override fun onDrawFrame(gl10: GL10) {
// Clear canvas
clear()

val viewMatrix = newIdentityMatrix()

val yOffset = toOffsetY(viewState.cameraPosition)

Matrix.translateM(viewMatrix, 0, 0f, yOffset, -viewState.cameraPosition.zoom)

Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.latitude.value, 1f, 0f, 0f)
Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.longitude.value, 0f, -1f, 0f)

globe.draw(projectionMatrix, viewMatrix, viewState.globeColors)

viewState.locationMarker.forEach {
val marker =
markerCache[it.colors]
?: LocationMarker(it.colors).also { markerCache.put(it.colors, it) }

marker.draw(projectionMatrix, viewMatrix, it.latLong, it.size)
}
}

private fun Float.toRadians() = this * Math.PI.toFloat() / (COMPLETE_ANGLE / 2)

private fun toOffsetY(cameraPosition: CameraPosition): Float {
val percent = cameraPosition.verticalBias
val z = cameraPosition.zoom - 1f
// Calculate the size of the plane at the current z position
val planeSizeY = tan(FIELD_OF_VIEW.toRadians() / 2f) * z * 2f

// Calculate the start of the plane
val planeStartY = planeSizeY / 2f

// Return offset based on the bias
return planeStartY - planeSizeY * percent
}

private fun clear() {
// Redraw background color
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
GLES20.glClearDepthf(1.0f)
GLES20.glEnable(GLES20.GL_DEPTH_TEST)
GLES20.glDepthFunc(GLES20.GL_LEQUAL)

GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
}

override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)

val ratio: Float = width.toFloat() / height.toFloat()
Matrix.perspectiveM(
projectionMatrix,
0,
FIELD_OF_VIEW,
ratio,
PERSPECTIVE_Z_NEAR,
PERSPECTIVE_Z_FAR
)
}

fun setViewState(viewState: MapViewState) {
this.viewState = viewState
}

companion object {
private const val PERSPECTIVE_Z_NEAR = 0.05f
private const val PERSPECTIVE_Z_FAR = 10f
private const val FIELD_OF_VIEW = 70f
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package net.mullvad.mullvadvpn.lib.map.internal

import android.content.Context
import android.opengl.GLSurfaceView
import net.mullvad.mullvadvpn.lib.map.BuildConfig
import net.mullvad.mullvadvpn.lib.map.data.MapViewState

internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) {

private val renderer: MapGLRenderer

init {
// Create an OpenGL ES 2.0 context
setEGLContextClientVersion(2)

if (BuildConfig.DEBUG) {
debugFlags = DEBUG_CHECK_GL_ERROR or DEBUG_LOG_GL_CALLS
}

renderer = MapGLRenderer(context.resources)

// Set the Renderer for drawing on the GLSurfaceView
setRenderer(renderer)
renderMode = RENDERMODE_WHEN_DIRTY
}

fun setData(viewState: MapViewState) {
renderer.setViewState(viewState)
requestRender()
}
}

0 comments on commit 51c9a16

Please sign in to comment.