Skip to content

Commit

Permalink
Render content continuously
Browse files Browse the repository at this point in the history
  • Loading branch information
DSteve595 committed Feb 16, 2023
1 parent c53c8f2 commit cae783a
Show file tree
Hide file tree
Showing 5 changed files with 310 additions and 120 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package com.google.maps.android.compose.clustering

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.platform.AbstractComposeView
import androidx.core.graphics.applyCanvas
import androidx.core.view.doOnAttach
import androidx.core.view.doOnDetach
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.ClusterRenderer
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.ComposeUiViewRenderer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch

/**
* Implementation of [ClusterRenderer] that renders marker bitmaps from Compose UI content.
* [clusterContentState] renders clusters, and [clusterItemContentState] renders non-clustered
* items.
*/
internal class ComposeUiClusterRenderer<T : ClusterItem>(
private val context: Context,
private val scope: CoroutineScope,
map: GoogleMap,
clusterManager: ClusterManager<T>,
private val viewRendererState: State<ComposeUiViewRenderer>,
private val clusterContentState: State<@Composable ((Cluster<T>) -> Unit)?>,
private val clusterItemContentState: State<@Composable ((T) -> Unit)?>,
) : DefaultClusterRenderer<T>(
context,
map,
clusterManager
) {

private val fakeCanvas = Canvas()
private val keysToViews = mutableMapOf<ViewKey<T>, ViewInfo>()

override fun onClustersChanged(clusters: Set<Cluster<T>>) {
super.onClustersChanged(clusters)
val keys = clusters.flatMap { it.computeViewKeys() }

with(keysToViews.iterator()) {
forEach { (key, viewInfo) ->
if (key !in keys) {
remove()
viewInfo.renderHandle.dispose()
}
}
}
keys.forEach { key ->
if (key !in keysToViews.keys) {
createAndAddView(key)
}
}
}

/**
* A [Cluster] is represented by one or more elements on screen. Even if a cluster contains
* multiple items, it still might only need a single element, depending on
* [shouldRenderAsCluster].
* @return a set of [ViewKey]s for each element.
*/
private fun Cluster<T>.computeViewKeys(): Set<ViewKey<T>> {
return if (shouldRenderAsCluster(this)) {
setOf(ViewKey.Cluster(this))
} else {
items.mapTo(mutableSetOf()) { ViewKey.Item(it) }
}
}

private fun createAndAddView(key: ViewKey<T>) {
val view = InvalidatingComposeView(
context,
content = when (key) {
is ViewKey.Cluster -> {
{ clusterContentState.value?.invoke(key.cluster) }
}

is ViewKey.Item -> {
{ clusterItemContentState.value?.invoke(key.item) }
}
}
)
val renderHandle = viewRendererState.value.startRenderingView(view)
scope.launch {
collectInvalidationsAndRerender(key, view)
}

keysToViews[key] = ViewInfo(view, renderHandle)
}

/** Re-render the corresponding marker whenever [view] invalidates */
private suspend fun collectInvalidationsAndRerender(
key: ViewKey<T>,
view: InvalidatingComposeView
) {
callbackFlow {
// When invalidated, emit on the next frame
var invalidated = false
view.onInvalidate = {
if (!invalidated) {
launch {
awaitFrame()
trySend(Unit)
invalidated = false
}
invalidated = true
}
}
view.doOnAttach {
view.doOnDetach { close() }
}
awaitClose()
}
.collectLatest {
when (key) {
is ViewKey.Cluster -> getMarker(key.cluster)
is ViewKey.Item -> getMarker(key.item)
}?.setIcon(renderViewToBitmapDescriptor(view))
}

}

override fun getDescriptorForCluster(cluster: Cluster<T>): BitmapDescriptor {
return if (clusterContentState.value != null) {
val viewInfo = keysToViews.entries
.first { (key, _) -> (key as? ViewKey.Cluster)?.cluster == cluster }
.value
renderViewToBitmapDescriptor(viewInfo.view)
} else {
super.getDescriptorForCluster(cluster)
}
}

override fun onBeforeClusterItemRendered(item: T, markerOptions: MarkerOptions) {
super.onBeforeClusterItemRendered(item, markerOptions)

if (clusterItemContentState.value != null) {
val viewInfo = keysToViews.entries
.first { (key, _) -> (key as? ViewKey.Item)?.item == item }
.value
markerOptions.icon(renderViewToBitmapDescriptor(viewInfo.view))
}
}

private fun renderViewToBitmapDescriptor(view: AbstractComposeView): BitmapDescriptor {
/* AndroidComposeView triggers LayoutNode's layout phase in the View draw phase,
so trigger a draw to an empty canvas to force that */
view.draw(fakeCanvas)
val viewParent = (view.parent as ViewGroup)
view.measure(
View.MeasureSpec.makeMeasureSpec(viewParent.width, View.MeasureSpec.AT_MOST),
View.MeasureSpec.makeMeasureSpec(viewParent.height, View.MeasureSpec.AT_MOST),
)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
val bitmap = Bitmap.createBitmap(
view.measuredWidth,
view.measuredHeight,
Bitmap.Config.ARGB_8888
)
bitmap.applyCanvas {
view.draw(this)
}

return BitmapDescriptorFactory.fromBitmap(bitmap)
}

private sealed class ViewKey<T : ClusterItem> {
data class Cluster<T : ClusterItem>(
val cluster: com.google.maps.android.clustering.Cluster<T>
) : ViewKey<T>()

data class Item<T : ClusterItem>(
val item: T
) : ViewKey<T>()
}

private class ViewInfo(
val view: AbstractComposeView,
val renderHandle: ComposeUiViewRenderer.RenderHandle,
)

/**
* An [AbstractComposeView] that calls [onInvalidate] whenever the Compose render layer is
* invalidated. Works by reporting invalidations from its inner AndroidComposeView.
*/
private class InvalidatingComposeView(
context: Context,
private val content: @Composable () -> Unit,
) : AbstractComposeView(context) {

var onInvalidate: (() -> Unit)? = null

@Composable
override fun Content() = content()

override fun onDescendantInvalidated(child: View, target: View) {
super.onDescendantInvalidated(child, target)
onInvalidate?.invoke()
}
}

}
Original file line number Diff line number Diff line change
@@ -1,34 +1,23 @@
package com.google.maps.android.compose.clustering

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Handler
import android.os.Looper
import android.view.View.MeasureSpec
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.UiComposable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.applyCanvas
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.collections.MarkerManager
import com.google.maps.android.compose.ComposeUiViewRenderer
import com.google.maps.android.compose.GoogleMapComposable
import com.google.maps.android.compose.InputHandler
import com.google.maps.android.compose.MapEffect
Expand All @@ -48,10 +37,8 @@ import kotlinx.coroutines.launch
* non-clustered item
* @param onClusterItemInfoWindowLongClick a lambda invoked when the user long-clicks the info
* window of a non-clustered item
* @param clusterContent an optional Composable that is rendered for each [Cluster]. This content is
* static and cannot be animated.
* @param clusterContent an optional Composable that is rendered for each [Cluster].
* @param clusterItemContent an optional Composable that is rendered for each non-clustered item.
* This content is static and cannot be animated.
*/
@Composable
@GoogleMapComposable
Expand Down Expand Up @@ -119,11 +106,12 @@ private fun <T : ClusterItem> rememberClusterManager(
val renderer = if (hasCustomContent) {
ComposeUiClusterRenderer(
context,
scope = this,
map,
clusterManager,
viewRendererState,
clusterContentState = clusterContentState,
clusterItemContentState = clusterItemContentState,
clusterContentState,
clusterItemContentState,
)
} else {
DefaultClusterRenderer(context, map, clusterManager)
Expand Down Expand Up @@ -157,67 +145,3 @@ private fun ResetMapListeners(
}
}
}

private class ComposeUiClusterRenderer <T : ClusterItem>(
private val context: Context,
map: GoogleMap,
clusterManager: ClusterManager<T>,
private val viewRendererState: State<ComposeUiViewRenderer>,
private val clusterContentState: State<@Composable ((Cluster<T>) -> Unit)?>,
private val clusterItemContentState: State<@Composable ((T) -> Unit)?>,
) : DefaultClusterRenderer<T>(
context,
map,
clusterManager
) {

private val composeView = ComposeView(context)
private val fakeCanvas = Canvas()

override fun getDescriptorForCluster(cluster: Cluster<T>): BitmapDescriptor {
return if (clusterContentState.value != null) {
composeView.setContent { clusterContentState.value?.invoke(cluster) }
renderViewToBitmapDescriptor(composeView)
} else {
super.getDescriptorForCluster(cluster)
}
}

override fun onBeforeClusterItemRendered(item: T, markerOptions: MarkerOptions) {
super.onBeforeClusterItemRendered(item, markerOptions)

if (clusterItemContentState.value != null) {
composeView.setContent { clusterItemContentState.value?.invoke(item) }
markerOptions.icon(renderViewToBitmapDescriptor(composeView))
}
}

private fun renderViewToBitmapDescriptor(view: ComposeView): BitmapDescriptor {
lateinit var bitmap: Bitmap // onAddedToWindow is called in place
viewRendererState.value.renderView(
view = view,
onAddedToWindow = {
/* AndroidComposeView triggers LayoutNode's layout phase in the View draw phase,
so trigger a draw to an empty canvas to force that */
view.draw(fakeCanvas)
// TODO do we need a max size?
view.measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
bitmap = Bitmap.createBitmap(
view.measuredWidth,
view.measuredHeight,
Bitmap.Config.ARGB_8888
)
bitmap.applyCanvas {
view.draw(this)
}
}
)

return BitmapDescriptorFactory.fromBitmap(bitmap)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ internal class ComposeInfoWindowAdapter(
val view = ComposeView(mapView.context).apply {
setContent { content(marker) }
}
mapView.renderComposeView(view, parentContext = markerNode.compositionContext)
mapView.renderComposeViewOnce(view, parentContext = markerNode.compositionContext)
return view
}

Expand All @@ -62,7 +62,7 @@ internal class ComposeInfoWindowAdapter(
val view = ComposeView(mapView.context).apply {
setContent { infoWindow(marker) }
}
mapView.renderComposeView(view, parentContext = markerNode.compositionContext)
mapView.renderComposeViewOnce(view, parentContext = markerNode.compositionContext)
return view
}

Expand Down
Loading

0 comments on commit cae783a

Please sign in to comment.