Hello :wave: I am trying to draw a waveform and a ...
# compose
g
Hello 👋 I am trying to draw a waveform and a progress line on top of it in compose using Canvas. A naive implementation could look like this:
Copy code
@Composable
public fun NaiveWaveform(
  waveform: Waveform,
  progress: Float,
) {
  Box(
    modifier = Modifier
      .width(256.dp)
      .height(64.dp)
  ) {
    Canvas(modifier = Modifier.fillMaxSize()) {
      drawWaveform(waveform) // heavy drawing, rarely changes
      drawProgress(progress) // changes every 500ms
    }
  }
}
The waveform is basically a wrapper for a FloatArray and progress is a float that goes from 0..1. The problem here is that the waveform can have thousands of points to be drawn which makes it hard to be drawn alongside the progress line that updates every 500 ms. Therefore I thought of creating two different Composables, one for each component. By doing that I should be able to leverage smart recomposition (Waveform is @Immutable) and only draw the waveform when it changes, keeping it independent from the progress line. Something like:
Copy code
@Composable
public fun StackedWaveform(
  waveform: Waveform,
  progress: Float,
) {
  Box(
    Modifier
      .width(256.dp)
      .height(64.dp)
  ) {
    WaveformCanvas(waveform = waveform)
    ProgressCanvas(progress = progress)
  }
}

@Composable
public fun ProgressCanvas(
  progress: Float,
) {
  println("ProgressCanvas")
  Canvas(
    modifier = Modifier.fillMaxSize(),
  ) {
    println("Drawing Progress")
    drawProgress(progress)
  }
}

@Composable
public fun WaveformCanvas(
  waveform: Waveform,
) {
  println("WaveformCanvas")
  Canvas(
    modifier = Modifier.fillMaxSize(),
    onDraw = {
      println("Drawing waveform")
      drawWaveform(waveform)
    },
  )
}
And now comes the bit I can’t understand quite well, the logs for these (with the progress animating):
Copy code
I/System.out: WaveformCanvas
I/System.out: ProgressCanvas
I/System.out: Drawing waveform
I/System.out: Drawing Progress

-- this initial setup makes sense 👆, compose goes through each composable and draws on each Canvas, but then:

I/System.out: ProgressCanvas
I/System.out: Drawing waveform
I/System.out: Drawing Progress
I/System.out: ProgressCanvas
I/System.out: Drawing waveform
I/System.out: Drawing Progress
...
(repeats while progress is increasing)
Ideally, that second section in the logs should only have “ProgressCanvas” and “Drawing Progress” logs, but somehow it is also redrawing the waveform! Looking at the logs, Compose seems to be skipping
WaveformCanvas
(it only calls it once in the beggining, as intended), but moving forward the canvas inside still redraws the waveform everytime the progress changes đŸ€” If anyone knows a better way to approach this please let know, thanks in advance!
đŸ§” 6
l
when something gets invalidated in the draw pass, the entire layer that it would have been a part of gets invalidated
what you want is probably something like this:
Copy code
Box(
  Modifier
    .drawBehind { ... draw waveform ... }
    .graphicsLayer()
    .drawBehind { ... draw progress ... }
)
Various ways to do this, but by having a
graphicsLayer()
in between, when the progress drawing gets invalidated, it will restart drawing after the waveform
g
Thanks for the fast reply! This is definitely new to me but I am afraid I am still getting the waveform log with this approach đŸ€”
Copy code
@Composable
public fun BoxWaveform(
  waveform: Waveform,
  progress: Float,
) {
  Box(
    Modifier
      .width(256.dp)
      .height(128.dp)
      .drawBehind {
        println("Drawing waveform")
        drawWaveform(waveform)
      }
      .graphicsLayer()
      .drawBehind {
        println("Drawing progress")
        drawProgress(progress)
      }
  )
}
l
try structuring it the way you had it before, as separate nodes where one gets skipped and the other doesn’t
the problem is the drawBehind lambda is getting reallocated whenever BoxWaveform recomposes, which causes both drawBehinds to get updated, causing both layers to get invalidated
this will hopefully get smarter in the future btw, but right now it isn’t
g
So when I separate them, do I add the graphicsLayer modifier to both Boxes?
l
will
waveform == prevWaveform
in general if it hasn’t changed here?
m
Using the
Modifier.drawWithCache
might also be a useful approach.
l
i don’t think drawWithCache helps here (though might be wrong)
g
Yes waveform only changes on user input, so for most state emissions the waveform will be the same 👍
l
you could try
Copy code
val drawWaveform = remember(waveform) { Modifier.drawBehind { ... waveform ... } }
Box(
  Modifier
    .then(drawWaveform)
    .graphicsLayer()
    .drawBehind { ... progress ... }
)
g
Ok, good news, having them separated with a graphicsLayer before the drawBehind seems to work 🙌 I like this idea of remembering the modifier, will try it next! Thank you for your help!
l
ah. if the graphicsLayer() before it helped, it would indicate that something above it in the same layer was getting invalidated
g
This last idea with the
remember
didn’t work for me, but it makes sense 😅 Update on the other idea, I only need the graphicsLayer on the
ProgressCanvas
, the
WaveformCanvas
works fine without it
So this setup works
Copy code
@Composable
public fun ProgressCanvas(
  progress: Float,
) {
  println("ProgressCanvas")
  Box(
    Modifier
      .fillMaxSize()
      .graphicsLayer()
      .drawBehind {
        println("Drawing Progress")
        drawProgress(progress)
      }
  )
}

@Composable
public fun WaveformCanvas(
  waveform: Waveform,
) {
  println("WaveformCanvas")
  Box(
    Modifier
      .fillMaxSize()
      .drawBehind {
        println("Drawing waveform")
        drawWaveform(waveform)
      }
  )
}