Skip to content

Commit bdd162f

Browse files
committed
Add Map Composable, view and renderer
1 parent 74abd20 commit bdd162f

File tree

6 files changed

+398
-0
lines changed

6 files changed

+398
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package net.mullvad.mullvadvpn.lib.map
2+
3+
import androidx.compose.animation.core.Animatable
4+
import androidx.compose.animation.core.EaseInOut
5+
import androidx.compose.animation.core.keyframes
6+
import androidx.compose.animation.core.tween
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.LaunchedEffect
9+
import androidx.compose.runtime.remember
10+
import kotlinx.coroutines.launch
11+
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
12+
import net.mullvad.mullvadvpn.lib.map.internal.DISTANCE_DURATION_SCALE_FACTOR
13+
import net.mullvad.mullvadvpn.lib.map.internal.FAR_ANIMATION_MAX_ZOOM_MULTIPLIER
14+
import net.mullvad.mullvadvpn.lib.map.internal.MAX_ANIMATION_MILLIS
15+
import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING
16+
import net.mullvad.mullvadvpn.lib.map.internal.MIN_ANIMATION_MILLIS
17+
import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS
18+
import net.mullvad.mullvadvpn.model.LatLong
19+
import net.mullvad.mullvadvpn.model.Latitude
20+
import net.mullvad.mullvadvpn.model.Longitude
21+
22+
@Composable
23+
fun animatedCameraPosition(
24+
baseZoom: Float,
25+
targetCameraLocation: LatLong,
26+
cameraVerticalBias: Float,
27+
): CameraPosition {
28+
val previousLocation =
29+
rememberPrevious(
30+
current = targetCameraLocation,
31+
shouldUpdate = { prev, curr -> prev != curr }
32+
) ?: targetCameraLocation
33+
34+
val distance =
35+
remember(targetCameraLocation) { targetCameraLocation.distanceTo(previousLocation).toInt() }
36+
val duration = distance.toAnimationDuration()
37+
38+
val longitudeAnimation = remember { Animatable(targetCameraLocation.longitude.value) }
39+
40+
val latitudeAnimation = remember { Animatable(targetCameraLocation.latitude.value) }
41+
val zoomOutMultiplier = remember { Animatable(1f) }
42+
43+
LaunchedEffect(targetCameraLocation) {
44+
launch { latitudeAnimation.animateTo(targetCameraLocation.latitude.value, tween(duration)) }
45+
launch {
46+
// Unwind longitudeAnimation into a Longitude
47+
val currentLongitude = Longitude.fromFloat(longitudeAnimation.value)
48+
49+
// Resolve a vector showing us the shortest path to the target longitude, e.g going
50+
// from 170 to -170 would result in 20 since we can wrap around the globe
51+
val shortestPathVector = currentLongitude.vectorTo(targetCameraLocation.longitude)
52+
53+
// Animate to the new camera location using the shortest path vector
54+
longitudeAnimation.animateTo(
55+
longitudeAnimation.value + shortestPathVector.value,
56+
tween(duration),
57+
)
58+
59+
// Current value animation value might be outside of range of a Longitude, so when the
60+
// animation is done we unwind the animation to the correct value
61+
longitudeAnimation.snapTo(targetCameraLocation.longitude.value)
62+
}
63+
launch {
64+
zoomOutMultiplier.animateTo(
65+
targetValue = 1f,
66+
animationSpec =
67+
keyframes {
68+
if (duration < SHORT_ANIMATION_CUTOFF_MILLIS) {
69+
durationMillis = duration
70+
1f at duration using EaseInOut
71+
} else {
72+
durationMillis = duration
73+
FAR_ANIMATION_MAX_ZOOM_MULTIPLIER at
74+
(duration * MAX_MULTIPLIER_PEAK_TIMING).toInt() using
75+
EaseInOut
76+
1f at duration using EaseInOut
77+
}
78+
}
79+
)
80+
}
81+
}
82+
83+
return CameraPosition(
84+
zoom = baseZoom * zoomOutMultiplier.value,
85+
latLong =
86+
LatLong(
87+
Latitude(latitudeAnimation.value),
88+
Longitude.fromFloat(longitudeAnimation.value)
89+
),
90+
verticalBias = cameraVerticalBias
91+
)
92+
}
93+
94+
private fun Int.toAnimationDuration() =
95+
(this * DISTANCE_DURATION_SCALE_FACTOR).coerceIn(MIN_ANIMATION_MILLIS, MAX_ANIMATION_MILLIS)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package net.mullvad.mullvadvpn.lib.map
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.DisposableEffect
5+
import androidx.compose.runtime.remember
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.platform.LocalLifecycleOwner
8+
import androidx.compose.ui.viewinterop.AndroidView
9+
import androidx.lifecycle.Lifecycle
10+
import androidx.lifecycle.LifecycleEventObserver
11+
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
12+
import net.mullvad.mullvadvpn.lib.map.data.GlobeColors
13+
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
14+
import net.mullvad.mullvadvpn.lib.map.data.Marker
15+
import net.mullvad.mullvadvpn.lib.map.internal.MapGLSurfaceView
16+
import net.mullvad.mullvadvpn.model.LatLong
17+
18+
@Composable
19+
fun Map(
20+
modifier: Modifier,
21+
cameraLocation: CameraPosition,
22+
markers: List<Marker>,
23+
globeColors: GlobeColors,
24+
) {
25+
val mapViewState = MapViewState(cameraLocation, markers, globeColors)
26+
Map(modifier = modifier, mapViewState = mapViewState)
27+
}
28+
29+
@Composable
30+
fun AnimatedMap(
31+
modifier: Modifier,
32+
cameraLocation: LatLong,
33+
cameraBaseZoom: Float,
34+
cameraVerticalBias: Float,
35+
markers: List<Marker>,
36+
globeColors: GlobeColors
37+
) {
38+
Map(
39+
modifier = modifier,
40+
cameraLocation =
41+
animatedCameraPosition(
42+
baseZoom = cameraBaseZoom,
43+
targetCameraLocation = cameraLocation,
44+
cameraVerticalBias = cameraVerticalBias
45+
),
46+
markers = markers,
47+
globeColors
48+
)
49+
}
50+
51+
@Composable
52+
internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) {
53+
54+
var view: MapGLSurfaceView? = remember { null }
55+
56+
val lifeCycleState = LocalLifecycleOwner.current.lifecycle
57+
58+
DisposableEffect(key1 = lifeCycleState) {
59+
val observer = LifecycleEventObserver { _, event ->
60+
when (event) {
61+
Lifecycle.Event.ON_RESUME -> {
62+
view?.onResume()
63+
}
64+
Lifecycle.Event.ON_PAUSE -> {
65+
view?.onPause()
66+
}
67+
else -> {}
68+
}
69+
}
70+
lifeCycleState.addObserver(observer)
71+
72+
onDispose {
73+
lifeCycleState.removeObserver(observer)
74+
view?.onPause()
75+
view = null
76+
}
77+
}
78+
79+
AndroidView(modifier = modifier, factory = { MapGLSurfaceView(it) }) { glSurfaceView ->
80+
view = glSurfaceView
81+
glSurfaceView.setData(mapViewState)
82+
}
83+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package net.mullvad.mullvadvpn.lib.map
2+
3+
/*
4+
* Code snippet taken from:
5+
* https://stackoverflow.com/questions/67801939/get-previous-value-of-state-in-composable-jetpack-compose
6+
*/
7+
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.MutableState
10+
import androidx.compose.runtime.SideEffect
11+
import androidx.compose.runtime.remember
12+
13+
// TODO this file was copied for now and should be removed/broken out to a new module
14+
@Composable
15+
fun <T> rememberPrevious(
16+
current: T,
17+
shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b },
18+
): T? {
19+
val ref = rememberRef<T>()
20+
21+
// launched after render, so the current render will have the old value anyway
22+
SideEffect {
23+
if (shouldUpdate(ref.value, current)) {
24+
ref.value = current
25+
}
26+
}
27+
28+
return ref.value
29+
}
30+
31+
@Composable
32+
private fun <T> rememberRef(): MutableState<T?> {
33+
// for some reason it always recreated the value with vararg keys,
34+
// leaving out the keys as a parameter for remember for now
35+
return remember {
36+
object : MutableState<T?> {
37+
override var value: T? = null
38+
39+
override fun component1(): T? = value
40+
41+
override fun component2(): (T?) -> Unit = { value = it }
42+
}
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package net.mullvad.mullvadvpn.lib.map.internal
2+
3+
internal const val VERTEX_COMPONENT_SIZE = 3
4+
internal const val COLOR_COMPONENT_SIZE = 4
5+
internal const val MATRIX_SIZE = 16
6+
7+
// Constant what will talk the distance in LatLng multiply it to determine the animation duration,
8+
// the result is then confined to the MIN_ANIMATION_MILLIS and MAX_ANIMATION_MILLIS
9+
internal const val DISTANCE_DURATION_SCALE_FACTOR = 20
10+
internal const val MIN_ANIMATION_MILLIS = 1300
11+
internal const val MAX_ANIMATION_MILLIS = 2500
12+
// The cut off where we go from a short animation (camera pans) to a far animation (camera pans +
13+
// zoom out)
14+
internal const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700
15+
16+
// Multiplier for the zoom out animation
17+
internal const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.30f
18+
// When in the far animation we reach the MAX_ZOOM_MULTIPLIER, value is between 0 and 1
19+
internal const val MAX_MULTIPLIER_PEAK_TIMING = .35f
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package net.mullvad.mullvadvpn.lib.map.internal
2+
3+
import android.content.res.Resources
4+
import android.opengl.GLES20
5+
import android.opengl.GLSurfaceView
6+
import android.opengl.Matrix
7+
import androidx.collection.LruCache
8+
import javax.microedition.khronos.egl.EGLConfig
9+
import javax.microedition.khronos.opengles.GL10
10+
import kotlin.math.tan
11+
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
12+
import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors
13+
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
14+
import net.mullvad.mullvadvpn.lib.map.internal.shapes.Globe
15+
import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker
16+
import net.mullvad.mullvadvpn.model.COMPLETE_ANGLE
17+
18+
internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer {
19+
20+
private lateinit var globe: Globe
21+
22+
// Due to location markers themselves containing colors we cache them to avoid recreating them
23+
// for every draw call.
24+
private val markerCache: LruCache<LocationMarkerColors, LocationMarker> =
25+
object : LruCache<LocationMarkerColors, LocationMarker>(100) {
26+
override fun entryRemoved(
27+
evicted: Boolean,
28+
key: LocationMarkerColors,
29+
oldValue: LocationMarker,
30+
newValue: LocationMarker?
31+
) {
32+
oldValue.onRemove()
33+
}
34+
}
35+
36+
private lateinit var viewState: MapViewState
37+
38+
override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
39+
globe = Globe(resources)
40+
markerCache.evictAll()
41+
initGLOptions()
42+
}
43+
44+
private fun initGLOptions() {
45+
GLES20.glEnable(GLES20.GL_CULL_FACE)
46+
GLES20.glCullFace(GLES20.GL_BACK)
47+
48+
GLES20.glEnable(GLES20.GL_BLEND)
49+
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
50+
}
51+
52+
private val projectionMatrix = newIdentityMatrix()
53+
54+
override fun onDrawFrame(gl10: GL10) {
55+
// Clear canvas
56+
clear()
57+
58+
val viewMatrix = newIdentityMatrix()
59+
60+
val yOffset = toOffsetY(viewState.cameraPosition)
61+
62+
Matrix.translateM(viewMatrix, 0, 0f, yOffset, -viewState.cameraPosition.zoom)
63+
64+
Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.latitude.value, 1f, 0f, 0f)
65+
Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.longitude.value, 0f, -1f, 0f)
66+
67+
globe.draw(projectionMatrix, viewMatrix, viewState.globeColors)
68+
69+
viewState.locationMarker.forEach {
70+
val marker =
71+
markerCache[it.colors]
72+
?: LocationMarker(it.colors).also { markerCache.put(it.colors, it) }
73+
74+
marker.draw(projectionMatrix, viewMatrix, it.latLong, it.size)
75+
}
76+
}
77+
78+
private fun Float.toRadians() = this * Math.PI.toFloat() / (COMPLETE_ANGLE / 2)
79+
80+
private fun toOffsetY(cameraPosition: CameraPosition): Float {
81+
val percent = cameraPosition.verticalBias
82+
val z = cameraPosition.zoom - 1f
83+
// Calculate the size of the plane at the current z position
84+
val planeSizeY = tan(FIELD_OF_VIEW.toRadians() / 2f) * z * 2f
85+
86+
// Calculate the start of the plane
87+
val planeStartY = planeSizeY / 2f
88+
89+
// Return offset based on the bias
90+
return planeStartY - planeSizeY * percent
91+
}
92+
93+
private fun clear() {
94+
// Redraw background color
95+
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
96+
GLES20.glClearDepthf(1.0f)
97+
GLES20.glEnable(GLES20.GL_DEPTH_TEST)
98+
GLES20.glDepthFunc(GLES20.GL_LEQUAL)
99+
100+
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
101+
}
102+
103+
override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {
104+
GLES20.glViewport(0, 0, width, height)
105+
106+
val ratio: Float = width.toFloat() / height.toFloat()
107+
Matrix.perspectiveM(
108+
projectionMatrix,
109+
0,
110+
FIELD_OF_VIEW,
111+
ratio,
112+
PERSPECTIVE_Z_NEAR,
113+
PERSPECTIVE_Z_FAR
114+
)
115+
}
116+
117+
fun setViewState(viewState: MapViewState) {
118+
this.viewState = viewState
119+
}
120+
121+
companion object {
122+
private const val PERSPECTIVE_Z_NEAR = 0.05f
123+
private const val PERSPECTIVE_Z_FAR = 10f
124+
private const val FIELD_OF_VIEW = 70f
125+
}
126+
}

0 commit comments

Comments
 (0)