There are only two hard things in Computer Science: cache invalidation and naming things.

– Phil Karton, via Martin Fowler

For a particular project, I need to take a Compose-defined UI and completely shift all of its colors a bit in the blue direction of the color spectrum. This is to compensate for a red shift being introduced as part of the physical display. This includes all screens, with whatever those screens are rendering, including content from third-party libraries that use the classic View system.

After some fussing around, including asking on Stack Overflow, I came up with this solution:

@Composable
fun Modifier.scaleTint(redScale: Float = 0.533333f, greenScale: Float = 0.8f, blueScale: Float = 1f, alphaScale: Float = 1f): Modifier =
    this.drawWithCache {
        val graphicsLayer = obtainGraphicsLayer()
        val matrix = ColorMatrix().apply {
            setToSaturation(0f)
            setToScale(redScale, greenScale, blueScale, alphaScale)
        }

        graphicsLayer.apply {
            record {
                drawContent()
            }
            this.colorFilter = ColorFilter.colorMatrix(matrix)
        }

        onDrawWithContent {
            drawLayer(graphicsLayer)
        }
    }

You can then apply the modifier to something you want tinted, such as:

@Composable
fun Something(message: String, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxWidth().scaleTint()) {
        BasicText(message)
    }
}

I thought it worked. Indeed, it does work, so long as you never need to change the composable being tinted.

In the example shown above, imagine the following series of events:

We would expect bar to now be rendered on the screen, with the tint applied. Instead, we still see foo.

🧐

The cause of the issue stems from drawWithCache(). The KDocs for drawWithCache() lead off with:

Draw into a DrawScope with content that is persisted across draw calls as long as the size of the drawing area is the same or any state objects that are read have not changed. In the event that the drawing area changes, or the underlying state values that are being read change, this method is invoked again to recreate objects to be used during drawing.

In this case, message is not part of the state that drawWithCache() pays attention to. It only knows about the parameters to the scaleTint() modifier, and in my example, those are not changing. drawWithCache() is oblivious to the BasicText() having changed, so it continues to use its drawing cache.

So, we need to bust that cache somehow.

My instinctive reaction was “OK, there must be a key somewhere”. key is used in many places to say “please invalidate this composable if the key value changes”. Alas, drawWithCache() does not take a key parameter, only the lambda expression (or other function type) that represents what is to be drawn and cached.

Adding a key parameter to the scaleTint() declaration is easy enough:

@Composable
fun Modifier.scaleTint(key: Any, redScale: Float = 0.533333f, greenScale: Float = 0.8f, blueScale: Float = 1f, alphaScale: Float = 1f): Modifier =
    this.drawWithCache {
        // rest of previous logic here
    }

However, that is insufficient. The key needs to be used inside the lambda supplied to drawWithCache().

So, now we need to decide on how to use key in such a way that it is considered “read” yet has minimum other impact, since we are not using it for anything other than busting the cache.

As it turns out, at least right now, just referencing it is sufficient:

@Composable
fun Modifier.scaleTint(key: Any, redScale: Float = 0.533333f, greenScale: Float = 0.8f, blueScale: Float = 1f, alphaScale: Float = 1f): Modifier =
    this.drawWithCache {
        key // referenced to bust the cache

        val graphicsLayer = obtainGraphicsLayer()
        val matrix = ColorMatrix().apply {
            setToSaturation(0f)
            setToScale(redScale, greenScale, blueScale, alphaScale)
        }

        graphicsLayer.apply {
            record {
                drawContent()
            }
            this.colorFilter = ColorFilter.colorMatrix(matrix)
        }

        onDrawWithContent {
            drawLayer(graphicsLayer)
        }
    }

We then need to supply a value for key that is tied to the state change:

@Composable
fun Something(message: String, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxWidth().scaleTint(message)) {
        BasicText(message)
    }
}

Now, if message changes, our BasicText() will re-render and we will see the change on-screen.

I thought that the right answer would be to use the key() function, or perhaps remember() (used by LaunchedEffect). However, both of those functions are composables, and so they can only be invoked from another composable. The lambda parameter to drawWithCache() is not marked as @Composable, though, so neither key() nor remember() are available to us.

I am not completely comfortable with this solution, but it is working for now. If you happen to know of a better way to get drawWithCache() to update in this case, please let me know!