diff options
| author | David Göransson <david.goransson90@gmail.com> | 2024-02-13 16:28:38 +0100 |
|---|---|---|
| committer | David Göransson <david.goransson90@gmail.com> | 2024-02-15 13:58:39 +0100 |
| commit | ec0662046cea00a0597ae7fe468500a451beafb3 (patch) | |
| tree | 43856445ad6a85e04db4205f27d3e7d5c74185a0 /android/lib/map | |
| parent | e059c0b66beb895505bf86d1de28cca7a190c9ff (diff) | |
| download | mullvadvpn-ec0662046cea00a0597ae7fe468500a451beafb3.tar.xz mullvadvpn-ec0662046cea00a0597ae7fe468500a451beafb3.zip | |
Add Map Composable, view and renderer
Diffstat (limited to 'android/lib/map')
5 files changed, 367 insertions, 0 deletions
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt new file mode 100644 index 0000000000..00fcf97ab7 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt @@ -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) diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt new file mode 100644 index 0000000000..a143a63cb8 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt @@ -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) + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt new file mode 100644 index 0000000000..dfd80d547d --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt @@ -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 diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt new file mode 100644 index 0000000000..bf44e5ee14 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt @@ -0,0 +1,133 @@ +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() { + // Enable cull face (To not draw the backside of triangles) + GLES20.glEnable(GLES20.GL_CULL_FACE) + GLES20.glCullFace(GLES20.GL_BACK) + + // Enable blend + 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() + + // Adjust zoom & vertical bias + val yOffset = toOffsetY(viewState.cameraPosition) + Matrix.translateM(viewMatrix, 0, 0f, yOffset, -viewState.cameraPosition.zoom) + + // Rotate to match the camera position + 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) + + // Draw location markers + 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() + + if (ratio.isFinite()) { + 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 + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt new file mode 100644 index 0000000000..19dd085524 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt @@ -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() + } +} |
