Blog Infos
Author
Published
Topics
, , , ,
Published

Did you ever hear about CompositionLocal’s smaller brother: the ModifierLocal? This is your chance to become familiar with the power of ModifierLocal, and make analytics handling a breeze!

Android collecting analytics with Jetpack Compose, by Copilot

 

The assignment

It all started with our client wanting the sensible thing of analytic events throughout the app. Every button on every screen should have an analytics event fired when pressed. We did have all the screens named in the analytics collection, and we already had Composables in place that can fire analytics events when a screen is shown. However, collecting events from button presses was not implemented in a scalable way, other than some very custom solutions.

We of course try to reuse as much of our composables as possible. This means, that we do not always have unique buttons for every screen and therefore we may not be able to distinguish between different button presses on different locations in our analytics tools. Therefore we decided, that whenever we pressed a button, we would give it a simple name, something like “confirm” or “cancel”, to make our components reusable if necessary. We also add a SCREEN_NAME parameter to the analytics event, so we would know from where the event originated. If you want to know more about analytics themselves, please write a comment! This article will not take you to that journey: It is meant to show how to use whatever analytics implementation you have inside Jetpack Compose.

Existing code

We used the following composable to fire screen view events to our analytics:

@Composable
fun LaunchScreenViewEvent(
  screenName: String,
) {
  val analyticsEvents = injectAnalyticsEvents()
  LaunchedEffect(screenName) {
    analyticsEvents.logScreenViewEvent(screenName)
  }
}

@Composable
fun injectAnalyticsEvents(): AnalyticsEvents = if (LocalInspectionMode.current) {
  object : AnalyticsEvents {
      override fun logScreenViewEvent(screenName: String) = Unit
      // And other methods, this is just the example of a stub
  }
} else {
  koinInject<AnalyticsEvents>()
}

@Preview
@Composable
fun SomeScreenInTheApp(/*params*/) {
  LaunchScreenViewEvent(AnalyticsScreenName.SomeScreen)
  Column(
    modifier = Modifier.fillMaxSize(),
  ) {
    //... all the rest of the screen ...
  }
}

As you can see, it was rather simple to use and we just dumped it into our existing Composables. We made sure the injectAnalyticsEvents wouldn’t break our Previews by providing a stub implementation if we were in preview mode, but other than that it wasn’t very elaborate. My first idea was to make a CompositionLocal of the screen name, so children could consume it later on and we could use that data when buttons are pressed:

private val LocalScreenName = staticCompositionLocalOf<String?> { null }

@Composable
fun WithScreenName(
  screenName: String,
  content: @Composable () -> Unit,
) {
  LaunchScreenViewEvent(screenName)
  CompositionLocalProvider(LocalScreenName provides screenName, content = content)
}

@Preview
@Composable
fun SomeScreenInTheApp(/*params*/) {
  WithScreenName(AnalyticsScreenName.SomeScreen) {
    Column(
      modifier = Modifier.fillMaxSize(),
    ) {
      //... all the rest of the screen ...
      // We can now read out LocalScreenName.current from children composables
    }
  }
}

However, you can see that this is kind of ugly and would force us to add extra nesting to all the screens. Therefore, it was decided that there has to be a better way.

Using modifiers instead

For modifiers, we also have a kind of CompositionLocal: the ModifierLocal. I was happy to find it, and it basically does the same, but for modifiers! Basically you have a modifier calledmodifierLocalProvider and modifierLocalConsumer. You may guess what they do: One provides a value, the other consumes it! Now we can finally have some fun. First, we create a modifier that we can reuse in our screens to declare their name for analytics purposes:

private val ModifierScreenName = modifierLocalOf<String?> { null }

@Composable
fun Modifier.analyticsScreenName(
  screenName: String,
): Modifier {
  LaunchScreenViewEvent(screenName = screenName)
  return this.then(
    Modifier.modifierLocalProvider(ModifierScreenName) { screenName }
  )
}

@Preview
@Composable
fun SomeScreenInTheApp(/*params*/) {
  Column(
    modifier = Modifier
        .fillMaxSize()
        .analyticsScreenName(AnalyticsScreenName.SomeScreen),
  ) {
    //... all the rest of the screen ...
  }
}

Great! Now you can see that with only minor changes, we can very easily add analytics screen view events to any screen in the app. The only thing that we have to do now, is consume these values and fire off analytics events whenever we press something on the screen!

Consuming a ModifierLocal

You cannot simply consume a ModifierLocal. You have to read them out from a lambda consumer you pass to a modifier, and then store them into a state. This is different from the way CompositionLocals are used, as you can get their current values from any composable context. In the following example, you will see how we read out the ModifierLocal and then store it in a simple MutableState:

@Composable
fun Modifier.onTapAnalyticsEvent(
  contentType: String,
): Modifier = onTapAnalyticsEvent { screenName ->
  val analyticsEvents = injectAnalyticsEvents()
  var screenName by remember { mutableStateOf<String?>(null) }
  return this then Modifier
      .modifierLocalConsumer { screenName = ModifierScreenName.current }
      .clickable {
        analyticsEvents.logSelectContentEvent(contentType)
      }
}

@Preview
@Composable
fun SomeScreenInTheApp(/*params*/) {
  Column(
    modifier = Modifier
        .fillMaxSize()
        .analyticsScreenName(AnalyticsScreenName.SomeScreen),
  ) {
    //... all the rest of the screen ...
    ConfirmButton {
      println("My general confirm button was pressed")
    }
  }
}

@Composable
fun ConfirmButton(onClick: () -> Unit) {
  Button(
    modifier = Modifier
      .onTapAnalyticsEvent(AnalyticsEvent.ConfirmButton),
    onClick = onClick,
  ) {
    Text(R.string.generic_confirm_button)
  }
}

It looks great! Almost done here. But there is a major problem: The clickable doesn’t seem to be doing what we want it to do.

Intercepting clicks twice

Boy oh boy, we cannot just add a clickable modifier to a button! The annoying thing here is that we already have a clickable modifier on the button, one which will consume any button press! So what do we do? We simply add the analytics event into the Button’s onClick and give up on our journey of making our code look cool and fancy? No, of course not! We will spend countless hours and find a way around it! I found this amazing post on StackOverflow which provides the workaround. In all the effort I put into implementing this story, I spent the most time on trying to figure out how to get two tap detectors working at the same time on one element. I will paste the result of that journey on the bottom, ready to copy and paste! For the workaround, we simply replace our clickable with something much more elaborate, for which we put away the implementation in a different file which, as mentioned, I will leave at the bottom of this article:

@Composable
fun Modifier.onTapAnalyticsEvent(
  contentType: String,
): Modifier = onTapAnalyticsEvent { screenName ->
  val analyticsEvents = injectAnalyticsEvents()
  var screenName by remember { mutableStateOf<String?>(null) }
  return this then Modifier
      .modifierLocalConsumer { screenName = ModifierScreenName.current }
      .pointerInput(Unit) { // Workaround for multiple tap listeners
        detectAlreadyConsumedTapGestures { // This method is found at the end
          analyticsEvents.logSelectContentEvent(contentType)
        }
      }
}

All that is left, is adding this modifier to all our Composables for which we want to log analytics events:

@Preview
@Composable
fun SomeScreenInTheApp(/*params*/) {
  Column(
    modifier = Modifier
        .fillMaxSize()
        .analyticsScreenName(AnalyticsScreenName.SomeScreen),
  ) {
    //... all the rest of the screen ...
    Text(
      modifier = Modifier
        .clickable { println("On random text clicked") }
        .onTapAnalyticsEvent("random"),
      text = "Some random text button",
    )
    ConfirmButton {
      println("My general confirm button was pressed")
    }
  }
}

@Composable
fun SomeOtherScreen() {
  Column(
    modifier = Modifier
        .fillMaxSize()
        .analyticsScreenName(AnalyticsScreenName.OtherScreen),
  ) {
    ConfirmButton {
      println("My general confirm button was pressed")
    }
  }
}

@Composable
fun ConfirmButton(onClick: () -> Unit) {
  Button(
    modifier = Modifier
      .onTapAnalyticsEvent(AnalyticsEvent.ConfirmButton),
    onClick = onClick,
  ) {
    Text(R.string.generic_confirm_button)
  }
}

If you click on the Confirm button on the SomeScreenInTheApp it will trigger an event where the SCREEN_NAME will be set to “SomeSreen” and the CONTENT_TYPE will be “confirm”. If we click on the same composable button in the SomeOtherScreen we don’t have any changes, but the SCREEN_NAME will be set to “other_screen” and the CONTENT_TYPE is the same old “confirm”.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Meta-programming with K2 compiler plugins

Let’s see what’s possible with plugins using the new K2 compiler, FIR. This live demo session will go through possible use cases that reduce boilerplate code and make your code safer.
Watch Video

Meta-programming with K2 compiler plugins

Tadeas Kriz
Senior Kotlin Developer
Touchlab

Meta-programming with K2 compiler plugins

Tadeas Kriz
Senior Kotlin Develo ...
Touchlab

Meta-programming with K2 compiler plugins

Tadeas Kriz
Senior Kotlin Developer
Touchlab

Jobs

Final words

Simply adding a modifier to your screen and a modifier to your clickable elements is a great way to easily implement analytics. I have used a button as example, but it could be any clickable element inside your UI. By using a ModifierLocal, we can avoid wrapping layouts in CompositionLocals and the nesting and cluttering that comes with that. You can write reusable components, add generic analytics event names to them and you do not have to pass extra parameters describing which screen the element is on. From the moment I have implemented these modifiers, adding analytics has been a breeze! I hope you can have the same pleasure handling analytics from now on.

Let me know what you think in the comments. And if you like what you see, please put those digital hands together! Joost out.

P.S.

As promised, the code to detect already consumed tap gestures:

import androidx.compose.foundation.gestures.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex

/**
 * Problem statement: We cannot have multiple tap detectors. To have a modifier to handle analytics events would be the best,
 * but it is hard to achieve as the actual elements' onclick will capture the event. Therefore, we add an on tap listener which
 * will also try to handle already consumed events.
 *
 * Solution found on:
 * https://stackoverflow.com/questions/68877700/how-can-i-detect-a-click-with-the-view-behind-a-jetpack-compose-button/68878910#68878910
 */
suspend fun PointerInputScope.detectAlreadyConsumedTapGestures(
  onTap: ((Offset) -> Unit)? = null,
) {
  val pressScope = PressGestureScopeImpl(this)
  coroutineScope {
    awaitEachGesture {
      launch { pressScope.reset() }

      awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final).also { it.consume() }

      val up = waitForUpOrCancellationInitial()
      if (up == null) {
        launch { pressScope.cancel() } // tap-up was canceled
      } else {
        launch { pressScope.release() }
        onTap?.invoke(up.position)
      }
    }
  }
}

suspend fun AwaitPointerEventScope.waitForUpOrCancellationInitial(): PointerInputChange? {
  while (true) {
    val event = awaitPointerEvent(PointerEventPass.Initial)
    if (event.changes.fastAll { it.changedToUp() }) {
      // All pointers are up
      return event.changes[0]
    }

    if (event.changes.fastAny { it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding) }) {
      return null // Canceled
    }

    // Check for cancel by position consumption. We can look on the Final pass of the
    // existing pointer event because it comes after the Initial pass we checked above.
    val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
    if (consumeCheck.changes.fastAny { it.isConsumed }) {
      return null
    }
  }
}

/**
 * [detectTapGestures]'s implementation of [PressGestureScope].
 */
private class PressGestureScopeImpl(
  density: Density,
) : PressGestureScope, Density by density {

  private var isReleased = false
  private var isCanceled = false
  private val mutex = Mutex(locked = false)

  /**
    * Called when a gesture has been canceled.
    */
  fun cancel() {
    isCanceled = true
    mutex.unlock()
  }

  /**
    * Called when all pointers are up.
    */
 fun release() {
   isReleased = true
   mutex.unlock()
 }

  /**
    * Called when a new gesture has started.
    */
  suspend fun reset() {
    mutex.lock()
    isReleased = false
    isCanceled = false
  }

  override suspend fun awaitRelease() {
    if (!tryAwaitRelease()) {
      throw GestureCancellationException("The press gesture was canceled.")
    }
  }

  override suspend fun tryAwaitRelease(): Boolean {
    if (!isReleased && !isCanceled) {
      mutex.lock()
      mutex.unlock()
    }
    return isReleased
  }
}
This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
Menu