Busting drawWithCache() in Compose
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 allof its colors a bit in the blue direction of the color spectrum. This is to compensatefor a red shift being introduced as part of the physical display. This includes allscreens, with whatever those screens are rendering, including content from third-partylibraries that use the classic View system.
After some fussing around, including asking on Stack Overflow,I came up with this solution:
@Composablefun 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:
@Composablefun 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 thecomposable being tinted.
In the example shown above, imagine the following series of events:
Something() gets invoked with "foo" passed as the value for message foo gets rendered on the screen, with the tint applied The user does something which causes a state change As a result, Something() gets called again to recompose, this time with "bar" as the value for messageWe 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() leadoff 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 attentionto. 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 manyplaces to say ���please invalidate this composable if the key value changes���. Alas,drawWithCache() does not take a key parameter, only the lambda expression (or otherfunction type) that represents what is to be drawn and cached.
Adding a key parameter to the scaleTint() declaration is easy enough:
@Composablefun 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 todrawWithCache().
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 bustingthe cache.
As it turns out, at least right now, just referencing it is sufficient:
@Composablefun 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:
@Composablefun 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 changeon-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 sothey can only be invoked from another composable. The lambda parameter to drawWithCache()is not marked as @Composable, though, so neither key() nor remember() are availableto us.
I am not completely comfortable with this solution, but it is working for now. If youhappen to know of a better way to get drawWithCache() to update in this case,please let me know!