Applying Kotlin context receivers

Applying Kotlin context receivers

Sometimes in our code, we want to use something only in a certain scope, without which it won’t work properly.

We can use context receivers to express constraints.

This article aims to demonstrate one of the many use cases and problems that Kotlin context receivers have come to solve by using a real-life case.

A real-life case with Jetpack Compose

Let’s take an example of Jetpack Compose. We have modifiers that can only be accessed inside a certain scope, like a type of Modifier.align(alignment: Alignment) which can only be used inside a BoxScope, the Modifier.align(alignment: Alignment.Horizontal) for ColumnScope,etc.

In Kotlin, you can define such a context-restricted declaration using a member extension function

interface BoxScope {
    fun Modifier.align(alignment: Alignment): Modifier
}

This modifier will be accessible only In a BoxScope’s context. In order to achieve that, compose Box use BoxScope as a receiver of its content, and it can be implemented as :

internal object BoxScopeImpl : BoxScope {
    override fun Modifier.align(alignment: Alignment): Modifier {
        /*...*/
    }
}

@Composable
fun Box(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
    Layout(
        content = { BoxScopeImpl.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

Box content will have access to BoxScope that means it can use this Modifier.align().

Disclaimer : in the code above I just tried to simplify the implementation of the compose box, the goal was not to redo it exactly but rather to show you one of the problems that context receivers come to solve

So what is the problem ?

Our problem concerns the way in which the Modifier is declared.

interface BoxScope {
    fun Modifier.align(alignment: Alignment): Modifier
}
  • The key one is that a member extension cannot be declared on a third-party class. So the only way to write another BoxScope’s Modifier is to write it as a member of BoxScope (that means it’s impossible)

  • It limits the ability to decouple, modularize and structure APIs in larger applications. Modifiers don’t have to be declared here, because we can create a file containing the definition of them all.

  • Another limitation is that only one receiver can represent a context. It limits composability of various abstractions, as we cannot declare a function that must be called only within two or more scopes present at the same time.

  • Etc

Context receivers to the rescue

Context Receivers are actually experimental and 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"]
    }
    /*...*/
}

Declaring context receivers

Let’s get back to our example of Modifier.align() and try to introduce context receivers

object BoxScope {}

context(BoxScope)
    fun Modifier.align(alignment: Alignment): Modifier {
    /*...*/
}

Instead of declaring our Modifier as a member extension function, we have declared it as a top level function and specified the scope in which it should be used.

Now since BoxScope doesn’t contain anything, so there’s no point in implementing it, I prefer to declare it directly as an object

Our box remains almost the same, except that we call directly BoxScope.content()

@Composable
fun Box(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
    Layout(
        content = { BoxScope.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

With that, we can, for example, have another scope that behaves like BoxScope, we won’t have to duplicate the code, we will just have to pass this scope as a receiver, we can even have a modifier for ColumnScope

context(BoxScope,AnOtherLikeBoxScope)
fun Modifier.align(alignment: Alignment): Modifier {
    /*...*/
}

context(ColumnScope)
fun Modifier.align(alignment: Alignment): Modifier {
    /*...*/
}

Conclusion

Context receivers cover various use cases, there are several articles that give prerequisites with basic examples to help you understand this concept and its use cases. Personally, I was wondering what would be the application of context receivers in one of the codes I’ve already written or seen, and this article is meant to help all those who can find themselves in my shoes.