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:
Something()
gets invoked with "foo"
passed as the value for message
foo
gets rendered on the screen, with the tint appliedSomething()
gets called again to recompose, this time with "bar"
as the value for message
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!