DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Alex
Alex

Posted on • Updated on

Context Receivers

On this third part we take a look at Kotlin Context Receivers and how it can help us, before we start putting every together in part 4 and finish up our Labeled Range Slider in part 5.

First let's see what a Context Receiver is, before we take a look at how it can help us make our component easier to use.

Important: Kotlin Context Receiver is an experimental API, this means it could still be subject to change in the future, before a final release. Also the IDE support is currently limited as well.

Enabling Context Receivers

Because of the experimental nature of Context Receivers, they are not enabled by default. To enable its usage we need to go to the build.gradle.kts or build.gradle file of our module and add -Xcontext-receivers as a free compiler arg.

In the build.gradle file of an Android module this looks something like this:

android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        freeCompilerArgs = ["-Xcontext-receivers"]
    }
}

Enter fullscreen mode Exit fullscreen mode

How it works

With Context Receivers we can add one or more contexts to our functions or methods. This provides the functionality of the declared context within our function, like having another this in that function. We are then able to call methods of that context as if our function is part of the object.

At a first glance this sounds a little bit like extension functions, similar to

fun String.toast(activity: Activity, duration: Int = Toast.LENGTH_LONG) {
    Toast.makeText(activity, this, duration).show()
}
Enter fullscreen mode Exit fullscreen mode

What you can see in this function is, that we also need an Android Activity to call the Toast.makeText function, therefore we need to call the function like this:

"Hello Toast".toast(this@MainActivity)
Enter fullscreen mode Exit fullscreen mode

We know we only want to call this function from an Android Activity, since this shows an UI element. What we can do with Context Receiver, we can declare our toast function to be callable within the Scope of an Android Activity.

context(Activity)
fun String.toast(duration: Int = Toast.LENGTH_LONG) {
    Toast.makeText(this@Activity, this, duration).show()
}

Enter fullscreen mode Exit fullscreen mode

As you can see, we removed the parameter Activity and instead added a Context Receiver, saying this function is valid within the Scope of an Activity. This allows us to call toast without passing an Activity directly, as long as we are within the Scope of one.

"Hello Toast".toast()
Enter fullscreen mode Exit fullscreen mode

On top of that our function now communicates to our team members, that it is only allowed to call this function in an Activity and not e.g. in a background service.

One question that comes up is, what happens when we are not within the Scope of an object needed as Context Receiver?
Just as an example, lets change our toast function to have an Activity and a String as Context Receivers

context(Activity, String)
fun toast(duration: Int = Toast.LENGTH_LONG) {
    Toast.makeText(this@Activity, this@String, duration).show()
}
Enter fullscreen mode Exit fullscreen mode

Now we need our String also to be in Scope. We can achieve this by using Kotlin's with function.

with("Hello Context") {
    toast()
}
Enter fullscreen mode Exit fullscreen mode

Sure, this is a little bit of an odd example, but it demonstrates how we can create a Scope and also how to use multiple Context Receivers :-).

How does this help us?

For our Labeled Range Slider, Context Receivers can help us makes our API cleaner and simpler to use for the caller and for us.

To start simple, we want to draw a text on a canvas and give the user the ability to configure the text size, as well as the horizontal and vertical translation of the positioning. In our Composable we need pixel values to position and draw the text. But we don't want the callers of our Composable to have to calculate pixel values by themselves and offer them an API which can be used with Dp and/or Sp values.
For that we create a configuration class TextConfig, holding the configurable properties and give them some defaults.

class TextConfig(
...
    private val verticalTranslation: Dp = 0.dp,
    private val horizontalTranslation: Dp = 0.dp,
    private val size: TextUnit = 8.sp,
...
)
Enter fullscreen mode Exit fullscreen mode

To be able to convert Dp or Sp values into pixels, we can use toPx, as we already saw in part two of this series. The function toPx is only available while we are within the DrawScope of a canvas. Therefore we could either call toPx manually every time in our canvas or we can use Context Receivers and create a nice computed properties, making it easier to use.

class TextConfig(
...
    private val verticalTranslation: Dp = 0.dp,
    private val horizontalTranslation: Dp = 0.dp,
    private val size: TextUnit = 8.sp,
...
) {
    context(DrawScope)
    val sizePx: Float
        get() = size.toPx()

    context(DrawScope)
    val verticalTranslationPx: Float
        get() = verticalTranslation.toPx()

    context(DrawScope)
    val horizontalTranslationPx: Float
        get() = horizontalTranslation.toPx()
}
Enter fullscreen mode Exit fullscreen mode

With that we can implement our Composable as follows:

@Composable
fun DrawText(
    textConfig: TextConfig
) {

    var enteredText by remember { mutableStateOf("") }

    Column(
        modifier = Modifier.padding(16.dp)
    ) {
        TextField(
            value = enteredText,
            onValueChange = { enteredText = it }
        )
        Canvas(
            modifier = Modifier
                         .fillMaxSize()
                         .background(color = Color.DarkGray)
        ) {
            val paint = Paint().apply {
                textSize = textConfig.sizePx
            }
            val x = (size.width / 2f) - (enteredText.length / 4f * textConfig.sizePx) + textConfig.horizontalTranslationPx
            val y = size.height / 2f + textConfig.sizePx / 2f + textConfig.verticalTranslationPx            

            drawIntoCanvas {    
                it.nativeCanvas.drawText(
                    enteredText,
                    x,
                    y,
                    paint
                )
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We can see, that as long as we are in the Scope of the Canvas we can call our properties, bounds to the DrawScope, because the lambda of the Canvas provides the DrawScope as an extension function parameter and there as this.

The user of the Composable can provide their configuration with device independent values.

val config = TextConfig(
    size = 32.sp,
    verticalTranslation = 16.dp,
    horizontalTranslation = 32.dp,
)
DrawText(
    textConfig = config
) 
Enter fullscreen mode Exit fullscreen mode

Conclusion

With the Context Receivers we can provide a nice API to our users, while also making our lives easier for the implementation of our Composable.

All the code shown here and a little bit more can be found on GitHub.

Now that we know how to draw elements on a canvas, interactive with them using gesture detection and also are able to create a nice simple API, we got all parts ready to implement our Labeled Range Slider.

Go to the fourth part right away

Top comments (0)

Dream Big


Use any Linode offering to create something unique or silly in the DEV x Linode Hackathon 2022 and win the Wacky Wildcard category.

β†’ Join the Hackathon <-