https://kotlinlang.org logo
t

Travis Griggs

07/31/2023, 7:05 PM
I am prototyping an app with a map (GoogleMap composable from maps-compose). I have a ZoomAllButton which zooms the map to a set of regions in my data model. I compose said button with a call like:
Copy code
ZoomAllButton(
   action = {
      var update =
         CameraUpdateFactory.newLatLngBounds(sortedRegions.map { region -> region.coordinateBounds }
            .reduce { merged, box -> merged.union(box) }, 20)
      scope.launch { cameraState.animate(update, 300) }
   }, modifier = Modifier.size(48.dp)
)
Passing these closures around, sometimes needing scope's to launch them, sometimes not, is still a bit unfamiliar for me. In this particular case, I find that I want to extract/refactor the action functionality. When my top level composable opens the first time, I want essentially "press" this button (well, zoom all) the same as if someone had pressed it. I think the recipe for doing something just once on first compose is:
Copy code
LaunchedEffect(true) { .. }
So essentially, rather than copy/paste the body there, I'm trying to figure out how to extract it and reuse it. It's unclear whether this should be another @Composable, when it seems more of a side effect. Should refactor Function... or Function to Scope... (I don't really understand the later refactoring). I feel like I'm just throwing hammers here to understand the execution/side effect model.
s

Stylianos Gakis

07/31/2023, 7:34 PM
Sounds like a function which takes in a cameraState instance to act on it. And is run in a coroutineScope, so just like you're doing it here, and yes through a LaunchedEffect(Unit) on initialize
Can even be an extension on CameraState if that feels more natural to you. I haven't played with the compose maps library but that's what I'd try first. Also why the
var
in there and not a val?
t

Travis Griggs

07/31/2023, 7:40 PM
I ended up with this:
Copy code
fun zoomAll(cameraState: CameraPositionState, regions: List<MappedRegion>, scope: CoroutineScope) {
   val bounds = regions.map { region -> region.coordinateBounds }
      .reduce { merged, box -> merged.union(box) }
   val update = CameraUpdateFactory.newLatLngBounds(bounds, 20)
   scope.launch { cameraState.animate(update, 300) }
}
(I could possibly make the merged regions a derivedState, since sortedRegions is a mutable state -- do derived states recompute on demand or immeidately on any source change?) ... that I had made it a val And now I can use it in two places:
Copy code
LaunchedEffect(true) {
   delay(100) // TODO: why is this necessary? don't like delays-to-make-things-work
   zoomAll(cameraState, sortedRegions, this)
}
and
Copy code
ZoomAllButton(
   action = { zoomAll(cameraState, sortedRegions, scope) },
   modifier = Modifier.size(48.dp)
)
Passing all 3 parameters seems... i dunno, but maybe that's just more normal in a more function oriented world
s

Stylianos Gakis

07/31/2023, 7:42 PM
Make it a suspend, don't pass the coroutine scope inside. That will also mean that returning means you're actually done with the action and makes it more clear that if the current coroutine gets cancelled the job will also get cancelled. And make camera state the receiver, that's just more stylistic but I'd definitely do that.
Doing derivedState would only work if you're inside a composable function already, what you're doing is fine I think regarding turning your data into the bounds etc
t

Travis Griggs

07/31/2023, 7:49 PM
I'll try some further refactoring. I've honestly been avoiding suspend like the plague. I'm not a huge fun, but I'm trying to be openminded (if admittedly relunctant). I appreciate the encouragement. Make CameraState the receiver. So basically add extension functions on CameraPositionState, e.g.
fun CameraPositionState.zoomAll(...)
. (there's a spin up here to doing a similar thing for my "pan to current location" functionality)
s

Stylianos Gakis

07/31/2023, 7:55 PM
Passing in the corouine scope only to do a launch on it is more or less like having a suspend but worse. Since you don't know if you're launching many things in parallel, how long they'll be running for, when they'll be done and so on. Using compose means you're using coroutines, it's a very core part of a ton of it's apis, even as simple as LaunchedEffect or produceState. If you need to hear one thing here, it's to try and embrace coroutines, they really are very good, albeit a bit hard to get into perhaps.
But yeah, that's what I mean, if it's the first parameter of your function the ide will offer a quick fix if you put the cursor on it and do option/alt + Enter. It will move it as a receiver, aka make it an extension function on it.
t

Travis Griggs

07/31/2023, 11:11 PM
Thanks for encouraging me. I'll keep chissling away at this. My zoom case did indeed get a lot simpler:
Copy code
suspend fun CameraPositionState.zoomBounds(bounds: LatLngBounds) {
   val update = CameraUpdateFactory.newLatLngBounds(bounds, 20)
   animate(update, 300)
}
(I moved the merge computation outside, because there's an empty regions case I want to test against before calling this using reduceOrNull, rather than just reduce). Where it got harder is my centerToCurrentLocation. It's working, but it feels pretty ugly:
Copy code
suspend fun CameraPositionState.centerToCurrentLocation(
   context: Context, isInitial: Boolean = true
) {
   val locationClient = LocationServices.getFusedLocationProviderClient(context)
   // TODO: Move this permission checking stuff somewhere else
   if (ActivityCompat.checkSelfPermission(
         context, Manifest.permission.ACCESS_FINE_LOCATION
      ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
         context, Manifest.permission.ACCESS_COARSE_LOCATION
      ) != PackageManager.PERMISSION_GRANTED
   ) {
      // T Consider calling
      //    ActivityCompat#requestPermissions
      // here to request the missing permissions, and then overriding
      //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
      //                                          int[] grantResults)
      // to handle the case where the user grants the permission. See the documentation
      // for ActivityCompat#requestPermissions for more details.
      "!!!PERMISSIONS FAILURE!!!".logged()
      return
   }
   val scope = CoroutineScope(coroutineContext)
   scope.launch(<http://Dispatchers.IO|Dispatchers.IO>) {
      await(
         locationClient.getCurrentLocation(
            PRIORITY_HIGH_ACCURACY, null
         )
      )?.let { location ->
         val coord = LatLng(location.latitude, location.longitude)
         val update = isInitial.opt({ CameraUpdateFactory.newLatLngZoom(coord, 15f) },
            { CameraUpdateFactory.newLatLng(coord) })
         scope.launch { animate(update, 300) }
      }
   }
}
Part of the reason it's ugly, because of the need (or at least IDE's desire) to have me do the permissions check there, so I have to pass in a context. There's got to be a different/better way to do all of that, but I haven't squared that away yet. I know Accompanist had/has some permissions stuff, but it didn't seem very robust yet. I would love to see a strong/current example of how one does the permissions dance in a pure compose implementation (no view models, acitivities, fragments, etc). That aside, the bottom part was still tricky (to me as well), because the location fetch has to happen off of the main thread (I guess), but the animate has to be back on the original scope/main thread. There's probably a way better to structure that (an extension on LocationClient maybe?), but the goal was to get it working. I'll try to do some more ingratiating with the ins and outs of coroutinism 😄 Thanks for your help guys.
2 Views