Skip to content

Commit b45abed

Browse files
committed
Add Map Composable, view and renderer
1 parent adebbf2 commit b45abed

File tree

5 files changed

+365
-0
lines changed

5 files changed

+365
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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.getValue
10+
import androidx.compose.runtime.mutableStateOf
11+
import androidx.compose.runtime.remember
12+
import androidx.compose.runtime.setValue
13+
import kotlinx.coroutines.launch
14+
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
15+
import net.mullvad.mullvadvpn.lib.map.internal.DISTANCE_DURATION_SCALE_FACTOR
16+
import net.mullvad.mullvadvpn.lib.map.internal.FAR_ANIMATION_MAX_ZOOM_MULTIPLIER
17+
import net.mullvad.mullvadvpn.lib.map.internal.MAX_ANIMATION_MILLIS
18+
import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING
19+
import net.mullvad.mullvadvpn.lib.map.internal.MIN_ANIMATION_MILLIS
20+
import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS
21+
import net.mullvad.mullvadvpn.model.LatLong
22+
import net.mullvad.mullvadvpn.model.Latitude
23+
import net.mullvad.mullvadvpn.model.Longitude
24+
25+
@Composable
26+
fun animatedCameraPosition(
27+
baseZoom: Float,
28+
targetCameraLocation: LatLong,
29+
cameraVerticalBias: Float,
30+
): CameraPosition {
31+
32+
var previousLocation by remember { mutableStateOf(targetCameraLocation) }
33+
var currentLocation by remember { mutableStateOf(targetCameraLocation) }
34+
35+
if (targetCameraLocation != currentLocation) {
36+
previousLocation = currentLocation
37+
currentLocation = targetCameraLocation
38+
}
39+
40+
val distance =
41+
remember(targetCameraLocation) { targetCameraLocation.distanceTo(previousLocation).toInt() }
42+
val duration = distance.toAnimationDuration()
43+
44+
val longitudeAnimation = remember { Animatable(targetCameraLocation.longitude.value) }
45+
46+
val latitudeAnimation = remember { Animatable(targetCameraLocation.latitude.value) }
47+
val zoomOutMultiplier = remember { Animatable(1f) }
48+
49+
LaunchedEffect(targetCameraLocation) {
50+
launch { latitudeAnimation.animateTo(targetCameraLocation.latitude.value, tween(duration)) }
51+
launch {
52+
// Unwind longitudeAnimation into a Longitude
53+
val currentLongitude = Longitude.fromFloat(longitudeAnimation.value)
54+
55+
// Resolve a vector showing us the shortest path to the target longitude, e.g going
56+
// from 170 to -170 would result in 20 since we can wrap around the globe
57+
val shortestPathVector = currentLongitude.vectorTo(targetCameraLocation.longitude)
58+
59+
// Animate to the new camera location using the shortest path vector
60+
longitudeAnimation.animateTo(
61+
longitudeAnimation.value + shortestPathVector.value,
62+
tween(duration),
63+
)
64+
65+
// Current value animation value might be outside of range of a Longitude, so when the
66+
// animation is done we unwind the animation to the correct value
67+
longitudeAnimation.snapTo(targetCameraLocation.longitude.value)
68+
}
69+
launch {
70+
zoomOutMultiplier.animateTo(
71+
targetValue = 1f,
72+
animationSpec =
73+
keyframes {
74+
if (duration < SHORT_ANIMATION_CUTOFF_MILLIS) {
75+
durationMillis = duration
76+
1f at duration using EaseInOut
77+
} else {
78+
durationMillis = duration
79+
FAR_ANIMATION_MAX_ZOOM_MULTIPLIER at
80+
(duration * MAX_MULTIPLIER_PEAK_TIMING).toInt() using
81+
EaseInOut
82+
1f at duration using EaseInOut
83+
}
84+
}
85+
)
86+
}
87+
}
88+
89+
return CameraPosition(
90+
zoom = baseZoom * zoomOutMultiplier.value,
91+
latLong =
92+
LatLong(
93+
Latitude(latitudeAnimation.value),
94+
Longitude.fromFloat(longitudeAnimation.value)
95+
),
96+
verticalBias = cameraVerticalBias
97+
)
98+
}
99+
100+
private fun Int.toAnimationDuration() =
101+
(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,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,131 @@
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+
// Enable cull face (To not draw the backside of triangles)
46+
GLES20.glEnable(GLES20.GL_CULL_FACE)
47+
GLES20.glCullFace(GLES20.GL_BACK)
48+
49+
// Enable blend
50+
GLES20.glEnable(GLES20.GL_BLEND)
51+
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
52+
}
53+
54+
private val projectionMatrix = newIdentityMatrix()
55+
56+
override fun onDrawFrame(gl10: GL10) {
57+
// Clear canvas
58+
clear()
59+
60+
val viewMatrix = newIdentityMatrix()
61+
62+
// Adjust zoom & vertical bias
63+
val yOffset = toOffsetY(viewState.cameraPosition)
64+
Matrix.translateM(viewMatrix, 0, 0f, yOffset, -viewState.cameraPosition.zoom)
65+
66+
// Rotate to match the camera position
67+
Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.latitude.value, 1f, 0f, 0f)
68+
Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.longitude.value, 0f, -1f, 0f)
69+
70+
globe.draw(projectionMatrix, viewMatrix, viewState.globeColors)
71+
72+
// Draw location markers
73+
viewState.locationMarker.forEach {
74+
val marker =
75+
markerCache[it.colors]
76+
?: LocationMarker(it.colors).also { markerCache.put(it.colors, it) }
77+
78+
marker.draw(projectionMatrix, viewMatrix, it.latLong, it.size)
79+
}
80+
}
81+
82+
private fun Float.toRadians() = this * Math.PI.toFloat() / (COMPLETE_ANGLE / 2)
83+
84+
private fun toOffsetY(cameraPosition: CameraPosition): Float {
85+
val percent = cameraPosition.verticalBias
86+
val z = cameraPosition.zoom - 1f
87+
// Calculate the size of the plane at the current z position
88+
val planeSizeY = tan(FIELD_OF_VIEW.toRadians() / 2f) * z * 2f
89+
90+
// Calculate the start of the plane
91+
val planeStartY = planeSizeY / 2f
92+
93+
// Return offset based on the bias
94+
return planeStartY - planeSizeY * percent
95+
}
96+
97+
private fun clear() {
98+
// Redraw background color
99+
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
100+
GLES20.glClearDepthf(1.0f)
101+
GLES20.glEnable(GLES20.GL_DEPTH_TEST)
102+
GLES20.glDepthFunc(GLES20.GL_LEQUAL)
103+
104+
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
105+
}
106+
107+
override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {
108+
GLES20.glViewport(0, 0, width, height)
109+
110+
val ratio: Float = width.toFloat() / height.toFloat()
111+
112+
Matrix.perspectiveM(
113+
projectionMatrix,
114+
0,
115+
FIELD_OF_VIEW,
116+
if (ratio.isFinite()) ratio else 1f,
117+
PERSPECTIVE_Z_NEAR,
118+
PERSPECTIVE_Z_FAR
119+
)
120+
}
121+
122+
fun setViewState(viewState: MapViewState) {
123+
this.viewState = viewState
124+
}
125+
126+
companion object {
127+
private const val PERSPECTIVE_Z_NEAR = 0.05f
128+
private const val PERSPECTIVE_Z_FAR = 10f
129+
private const val FIELD_OF_VIEW = 70f
130+
}
131+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package net.mullvad.mullvadvpn.lib.map.internal
2+
3+
import android.content.Context
4+
import android.opengl.GLSurfaceView
5+
import net.mullvad.mullvadvpn.lib.map.BuildConfig
6+
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
7+
8+
internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) {
9+
10+
private val renderer: MapGLRenderer
11+
12+
init {
13+
// Create an OpenGL ES 2.0 context
14+
setEGLContextClientVersion(2)
15+
16+
if (BuildConfig.DEBUG) {
17+
debugFlags = DEBUG_CHECK_GL_ERROR or DEBUG_LOG_GL_CALLS
18+
}
19+
20+
renderer = MapGLRenderer(context.resources)
21+
22+
// Set the Renderer for drawing on the GLSurfaceView
23+
setRenderer(renderer)
24+
renderMode = RENDERMODE_WHEN_DIRTY
25+
}
26+
27+
fun setData(viewState: MapViewState) {
28+
renderer.setViewState(viewState)
29+
requestRender()
30+
}
31+
}

0 commit comments

Comments
 (0)