-
Notifications
You must be signed in to change notification settings - Fork 354
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Map Composable, view and renderer
- Loading branch information
Showing
6 changed files
with
398 additions
and
0 deletions.
There are no files selected for viewing
95 changes: 95 additions & 0 deletions
95
android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
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.remember | ||
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 { | ||
val previousLocation = | ||
rememberPrevious( | ||
current = targetCameraLocation, | ||
shouldUpdate = { prev, curr -> prev != curr } | ||
) ?: 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) |
83 changes: 83 additions & 0 deletions
83
android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
44 changes: 44 additions & 0 deletions
44
android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/RememberPrevious.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
package net.mullvad.mullvadvpn.lib.map | ||
|
||
/* | ||
* Code snippet taken from: | ||
* https://stackoverflow.com/questions/67801939/get-previous-value-of-state-in-composable-jetpack-compose | ||
*/ | ||
|
||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.MutableState | ||
import androidx.compose.runtime.SideEffect | ||
import androidx.compose.runtime.remember | ||
|
||
// TODO this file was copied for now and should be removed/broken out to a new module | ||
@Composable | ||
fun <T> rememberPrevious( | ||
current: T, | ||
shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b }, | ||
): T? { | ||
val ref = rememberRef<T>() | ||
|
||
// launched after render, so the current render will have the old value anyway | ||
SideEffect { | ||
if (shouldUpdate(ref.value, current)) { | ||
ref.value = current | ||
} | ||
} | ||
|
||
return ref.value | ||
} | ||
|
||
@Composable | ||
private fun <T> rememberRef(): MutableState<T?> { | ||
// for some reason it always recreated the value with vararg keys, | ||
// leaving out the keys as a parameter for remember for now | ||
return remember { | ||
object : MutableState<T?> { | ||
override var value: T? = null | ||
|
||
override fun component1(): T? = value | ||
|
||
override fun component2(): (T?) -> Unit = { value = it } | ||
} | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
126 changes: 126 additions & 0 deletions
126
android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
Oops, something went wrong.