onebone/compose-collapsing-toolbar

Significant performance drop in case of rich toolbar layout

Closed this issue ยท 6 comments

My toolbar contains pretty large numbers of elements so its performance is especially important for me. Here is my toolbar:
Screenshot of my toolbar

In action it looks like this:
GIF of my toolbar

On the GIF you can see performance drop when scroll occur. It's important to node that the GIF is not compressed.

Here is the code of my toolbar layout:

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.FloatRange
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AppBarDefaults
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.primarySurface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import me.onebone.toolbar.ui.theme.CollapsingToolbarTheme

class PerformanceTestActivity: ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      CollapsingToolbarTheme {
        Surface(color = MaterialTheme.colors.background) {
          MyScaffold(
            modifier = Modifier.fillMaxSize(),
          )
        }
      }
    }
  }
}

@Composable
private fun MyScaffold(
  modifier: Modifier = Modifier,
) {
  MyAppBarScaffold(
    modifier = modifier.fillMaxSize(),
  ) {
    val timestamp = System.currentTimeMillis()
    LazyColumn {
      items(100) {
        val timestamp2 = System.currentTimeMillis()
        Text(
          modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
          text = "I'm item $it"
        )
        Log.d("perf list item", "list item draw speed = ${System.currentTimeMillis() - timestamp2}")
      }
    }
    Log.d("perf list", "list draw speed = ${System.currentTimeMillis() - timestamp}")
  }
}

@Composable
private fun MyAppBarScaffold(
  modifier: Modifier = Modifier,
  content: @Composable () -> Unit,
) {
  val collapsingToolbarScaffoldState = rememberCollapsingToolbarScaffoldState()

  CollapsingToolbarScaffold(
    modifier = modifier,
    state = collapsingToolbarScaffoldState,
    scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
    toolbarModifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation),
    toolbar = {
      val timestamp = System.currentTimeMillis()

      val progress = collapsingToolbarScaffoldState.toolbarState.progress
//      val progress = 1f

      Surface(
        modifier = Modifier
          .height(IntrinsicSize.Min)
//          .height(300.dp)
          .parallax(0.5f), // TODO: Affects performance
        color = MaterialTheme.colors.primarySurface,
        elevation = AppBarDefaults.TopAppBarElevation,
      ) {
        MyAppBarContent(
          progress = progress,
        )
      }


      MyExpandedAppBar(
        modifier = Modifier
          .road(Alignment.BottomStart, Alignment.BottomStart), // TODO: Affects performance
        progress = progress, // TODO: Affects performance
//        progress = 1f,
      )
      // Collapsing toolbar collapses its size as small as the that of a smallest child (this)
      MyCollapsedAppBar(
        modifier = Modifier.clickable(onClick = { }), // TODO: Affects performance
        progress = progress, // TODO: Affects performance
//        progress = 1f,
      )

      Log.d("perf", "toolbar draw speed = ${System.currentTimeMillis() - timestamp}, progress = $progress")

    },
    body = content
  )
}

@Composable
private fun MyExpandedAppBar(
  modifier: Modifier = Modifier,
  @FloatRange(from = 0.0, to = 1.0) progress: Float,
) {
  Log.d("redraw", "expanded bar redrawing")
  MyAppBar(
    modifier = modifier,
    title = {
      Log.d("redraw", "expanded bar title redrawing")
      val progressReversed = 1f - progress
      Text(
        modifier = Modifier.alpha(progressReversed.configureProgress(0.5f)),
        text = stringResource(R.string.app_name),
        color = MaterialTheme.colors.onPrimary
      )
    },
    actions = {
      Log.d("redraw", "expanded bar actions redrawing")
      IconButton(
        modifier = Modifier.alpha(progress.configureProgress(0.5f)),
        onClick = { }
      ) {
        Icon(imageVector = Icons.Filled.Share, contentDescription = null)
      }
    }
  )
}

@Composable
private fun MyCollapsedAppBar(
  modifier: Modifier = Modifier,
  @FloatRange(from = 0.0, to = 1.0) progress: Float,
) {
  Log.d("redraw", "collapsed bar redrawing")
  val popupExpanded = remember { mutableStateOf(false) }

  val popupOptions = arrayOf("option #1", "option #2")

  MyAppBar(
    modifier = modifier,
    title = {
      Log.d("redraw", "collapsed bar title redrawing")
      Text(
        modifier = Modifier.alpha(progress),
        text = "Collapsed app bar",
        color = MaterialTheme.colors.onPrimary
      )
    },
    actions = {
      Log.d("redraw", "collapsed actions redrawing")
      IconButton(onClick = { popupExpanded.value = true }) {
        Icon(
          imageVector = Icons.Filled.MoreVert,
          contentDescription = null
        )
      }
    }
  )
}

@Composable
private fun MyAppBarContent(
  modifier: Modifier = Modifier,
  @FloatRange(from = 0.0, to = 1.0) progress: Float,
) {
  Log.d("redraw", "content redrawing")
  Box(
    modifier = modifier
      .fillMaxWidth()
      .alpha(progress.configureProgress(0.5f)),
    contentAlignment = Alignment.Center
  ) {
    Log.d("redraw", "image redrawing")
    Image(
      modifier = Modifier.fillMaxSize(),
      painter = painterResource(R.drawable.ic_launcher_foreground),
      contentDescription = null,
      contentScale = ContentScale.Crop
    )
    Row(
      modifier = Modifier
        .padding(horizontal = 16.dp, vertical = MaterialAppBarHeight)
        .fillMaxSize(),
      horizontalArrangement = Arrangement.SpaceBetween,
      verticalAlignment = Alignment.CenterVertically
    ) {
      Log.d("redraw", "tile row redrawing")
      MyTile(
        title = "title #1",
        value = "123"
      )
      MyTile(
        title = "title #2",
        value = "456"
      )
    }
  }
}

@Composable
private fun MyTile(
  modifier: Modifier = Modifier,
  title: String,
  value: String,
) {
  Log.d("redraw", "tile redrawing")
  val fontScale = LocalContext.current.resources.configuration.fontScale

  Column(
    modifier = modifier.height(MyStatisticsTileHeight.times(fontScale)),
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      modifier = Modifier.padding(vertical = 8.dp),
      text = title
    )
    Box(
      modifier = Modifier
        .padding(bottom = 8.dp)
        .aspectRatio(1f)
        .border(
          width = MyStatisticsTileBorderWidth,
          color = MaterialTheme.colors.onPrimary,
          shape = RoundedCornerShape(8.dp)
        )
        .fillMaxSize(),
      contentAlignment = Alignment.Center,
    ) {
      Text(
        modifier = Modifier
          .padding(horizontal = MyStatisticsTileBorderWidth.times(2)),
        text = value,
        textAlign = TextAlign.Center
      )
    }
  }
}

@Composable
private fun MyAppBar(
  modifier: Modifier = Modifier,
  title: @Composable () -> Unit,
  actions: @Composable RowScope.() -> Unit = {},
) {
  TopAppBar(
    modifier = modifier.height(MaterialAppBarHeight),
    title = title,
    actions = actions,
    backgroundColor = Color.Transparent,
    contentColor = contentColorFor(MaterialTheme.colors.primarySurface),
    elevation = 0.dp
  )
}

private val MyStatisticsTileHeight = 118.dp
private val MyStatisticsTileBorderWidth = 5.dp
private val MaterialAppBarHeight = 56.dp

/**
 * Applies configurations on value that represent a progress (e.g. animation)
 *
 * @param startAt Sets the starting point for the value. The resulting progress will begin
 * to increase only when the original progress value reaches passed value.
 */
fun Float.configureProgress(@FloatRange(from = 0.0, to = 1.0) startAt: Float): Float {
  val start = (1f - startAt).coerceAtLeast(0f)
  val multiplier = 1f / start
  return (this - start) * multiplier
}

I added some logs to figure out what parts of UI affected by recomposition. It turned out that they all were affected after any change in the toolbar state. Even if I remove alpha modifier the recomposition process still affect all toolbar composables. I think this behaviour directly contradicts the basic principle of Compose - doing recomposition only if state of composable has changed

In my case there is no need to recompose toolbars (only they titles). So there must a way not to trigger recomposition of all tolbar content

I'm still in research of this problem. But it's important to discuss this problem.

I have found a way to speed up this layout more than 60%. Thanks to this doc.

Passing progress as a lambda makes re-execute only layout and draw phases of Compose rather than trigger recomposition:

toolbar = {
    val progress = { collapsingToolbarScaffoldState.toolbarState.progress }
    
    // ...
},

I think we should implement an example screen with demonstration of this concept. It will be useful for creating complex toolbars.

But there are still two problems: one with Modifier.road() and other with Modifier.clickable()

Road() issue

The problem is that Modifier.road() triggers recomposition phase but in fact it affects only layout phase. For now I don't know how to fix this but I guess implementing LayoutModifier might be a started point.

Clickable() strange behavior

In the code above lets look at toolbar composable lambda where MyCollapseAppBar is placed:

MyCollapsedAppBar(
  modifier = Modifier.clickable(onClick = { }), // TODO: Affects performance
  progress = progress,
)

Clickable modifier will trigger recomposition al entire MyCollapsedAppBar each time collapsingToolbarScaffoldState.toolbarState is changed. But there is no any state reader in clickable. But the problem is gone if we move clickable inside MyCollapsedAppBar:

@Composable
private fun MyCollapsedAppBar(
  modifier: Modifier = Modifier,
  onClick: () -> Unit,
  @FloatRange(from = 0.0, to = 1.0) progress: Float,
) {
  // ...

  MyAppBar(
    modifier = Modifier.clickable(onClick = onClick), // Now MyCollapsedAppBar won't recomposing
    title = {
      Log.d("redraw", "collapsed bar title redrawing")
      Text(
        modifier = Modifier.alpha(progress),
        text = "Collapsed app bar",
        color = MaterialTheme.colors.onPrimary
      )
    }
  )

  // ...
}

For now I can't explain how it works. Maybe you can? It should be noted that this trick not work with Modifier.road().

I will try to play with collapsing toolbar modifiers but it will very helpful if you share your knowledge about this problems.

Thank you @RareScrap , that would help the library to gain performance a lot! Maybe we can refer to the behavior of lambda version Modifier.graphicsLayer to fix the road modifier issue. For clickable modifier, I will investigate what triggers recomposition.

I have one thing to note here: since Row, Column, Box are inline composable functions, they do not have their own recompose scope meaning that composable lambda passed to these functions will always recompose at the same time with their call sites.

I'm happy to report that I have found a way to enormously improve the performance of CollapsingToolbarScaffold (even with disabled AOT). The problem was that SubcomposeLayout executes recomposition phase immediately after the measuring phase which allows measurement results to be used as an argument during recomposition. The implementation of CollapsingToolbarScaffold is not depend on measurement results so using SubcomposeLayout is unnecessary and inefficient here. That's why we can achieve our goal just using Layout. Also modifiers won't trigger recomposition if Layout is used.

I also want to implement an example activity with rich toolbar layout and create choosing page which allow to run different examples without manifest editing. What are you think?

@RareScrap Thank you! I will review your PR this weekend.

And it would be very nice to see the new example screen! If you are going to refactor examples please submit a separate PR ๐Ÿ˜€

Closing the issue with your contribution! Please create another issue if the performance issue seems to persist ๐Ÿ˜ƒ