4

What I want to do is something like this in the Google Photos app:

At first, the status bar and top app bar take up some space:

Expected behaviour in Google Photos

When starting to scroll, the top app bar disappears towards the top of the screen:

Expected behaviour in Google Photos after starting to scroll

After scrolling for a bit, the top app bar disappears completely and the whole screen is used to show the actual content. The status bar is still being drawn, but transparent with a slight tint:

Expected behaviour in Google Photos after finishing scrolling

It looks like the usual "scroll top bar away" is achieved by setting

val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)

on the Scaffold containing the TopAppBar. What I'm having difficulties with is making the status bar transparent while being able to scroll the TopAppBar behind the status bar like in the second image. The first attempt came pretty close, with the status bar background being the same color as the TopAppBar:

First attempt with status bar in the same color as TopAppBar

But when scrolling, the TopAppBar disappears instead of going behind the status bar:

TopAppBar disappears

Scrolling further gives the status bar this weird default(?) color, even though I've defined it to be transparent at different points:

TopAppBar default color

Combining different settings of windowInsets / windowInsetPadding, Scaffold's inner padding and the scrollBehaviour, the best I was able to do was to make the status bar transparent:

Status bar transparent, but TopAppBar is not at the top

This is done by setting .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top)) on TopAppBars modifier and not applying Scaffolds inner padding to the LazyColumn. This leads to the TopAppBar disappearing instead of going behind the status bar and the content from below the TopAppBar being drawn behind the status bar).

I also manage to make the TopAppBar start at the very top of the screen, but have the text etc. on it be behind the status bar from the start. I can't remember how to reproduce it right now, but it seemed promising. The only problem was that the TopAppBar was not 'tall' enough to extend behind the status bar all the way. I tried playing with its height / minheight and padding but couldn't get it right.

Gemini has me running in circles about this. I've reduced it to a minimal project with this code, some options and debug logs - nothing seems to work right:

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3Api::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)

        WindowCompat.setDecorFitsSystemWindows(window, false)

        window.statusBarColor = Color.Transparent.toArgb()
        window.navigationBarColor = Color.Transparent.toArgb()

        setContent {
            MinimalTheme {
                //val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
                val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())

                Scaffold(
                    modifier = Modifier
                        .fillMaxSize()
                        .nestedScroll(scrollBehavior.nestedScrollConnection),
                    containerColor = Color.Magenta,
                    topBar = {
                        TopAppBar(
                            title = { Text("My App") },
                            modifier = Modifier
                                .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Horizontal)) // or .Top
                                .fillMaxWidth(),
                                //.height(80.dp),
                            colors = TopAppBarDefaults.topAppBarColors(
                                containerColor = Color.Red,
                                titleContentColor = Color.White
                            ),
                            scrollBehavior = scrollBehavior
                            //windowInsets = TopAppBarDefaults.windowInsets.only(WindowInsetsSides.top)
                        )
                    }
                ) { scaffoldInnerPadding ->
                    LazyColumn(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(scaffoldInnerPadding)
                            .background(Color.Gray),
                        contentPadding = PaddingValues(16.dp)
                    ) {
                        items(50) { index ->
                            Text(
                                "Item $index",
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(vertical = 8.dp),
                                color = Color.Black
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun MinimalTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            Log.d("TransparencyTest", "MinimalTheme SideEffect - START")
            val window = (view.context as? android.app.Activity)?.window
            if (window == null) {
                Log.e("TransparencyTest", "Window is NULL in SideEffect")
                return@SideEffect
            }

            window.statusBarColor = android.graphics.Color.TRANSPARENT // Use framework's transparent
            Log.d("TransparencyTest", "statusBarColor RE-SET TO TRANSPARENT in SideEffect")

            val insetsController = WindowInsetsControllerCompat(window, view)
            insetsController.isAppearanceLightStatusBars = !darkTheme
            Log.d("TransparencyTest", "isAppearanceLightStatusBars SET TO ${!darkTheme}")
            insetsController.isAppearanceLightNavigationBars = !darkTheme
            Log.d("TransparencyTest", "MinimalTheme SideEffect - END")
        }
    }

    MaterialTheme(
        colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(),
        typography = Typography(),
        content = content
    )
}

Edit: I think Edric's comment is at least part of the solution. Setting the LazyColumn's contentPadding to Scaffold's padding, results in the list not starting at the very top, but below the TopAppBar:

Edric's idea

(This is in combination with

.windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top))

on the TopAppBar's modifier)

I think the only step that's left is extending the TopAppBar so it is seen below the status bar. Right now, the TopAppBar still disappears 'into' the invisible status bar.

Edit 2: Is it possible that it's not a TopAppBar at all, but just a Box or something that works like a TopAppBar? Is it possible to connect other elements with nestedScrolling?

2
  • 1
    Try passing the scaffold's inner padding as the contentPadding argument to LazyColumn as well as consuming the window insets with the consumeWindowInsets modifier perhaps, like the example code in the Scaffold documentation Commented Sep 4 at 1:56
  • Thanks, that at least helped to not show the LazyColumn's content behind the status bar - check my edit. Commented Sep 4 at 8:40

1 Answer 1

1
+200

Edit:

If you want to modify the default collapsing animation, I would suggest that you implement the collapsing using a custom NestedScrollConnection. Please have a look at the following code:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomCollapsingToolbar() {

    val localDensity = LocalDensity.current
    val statusBarDp = with(localDensity) { WindowInsets.statusBars.getTop(localDensity).toDp() }
    var maxToolbarHeightPx by remember(statusBarDp) { mutableFloatStateOf((TopAppBarDefaults.TopAppBarExpandedHeight + statusBarDp).dpToPx(localDensity)) }
    var currentToolbarHeightPx by remember(maxToolbarHeightPx) { mutableFloatStateOf(maxToolbarHeightPx) }

    val nestedScrollConnection = remember(maxToolbarHeightPx) {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                val delta = available.y
                val newHeaderHeightPx = currentToolbarHeightPx + delta
                currentToolbarHeightPx = newHeaderHeightPx.coerceIn(0f, maxToolbarHeightPx)
                val unconsumedPx = newHeaderHeightPx - currentToolbarHeightPx
                return Offset(x = 0f, y = delta - unconsumedPx)
            }
        }
    }

    Scaffold(
        modifier = Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection),
        contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
    ) { scaffoldInnerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(scaffoldInnerPadding)
                .background(Color.Gray)
        ) {
            Box(
                modifier = Modifier
                    .height(currentToolbarHeightPx.toInt().pxToDp(localDensity))
                    .wrapContentHeight(Alignment.Bottom, unbounded = true),
            ) {
                TopAppBar(
                    title = {
                        Text(
                            modifier = Modifier.padding(top = statusBarDp),
                            text = "My App"
                        )
                    },
                    expandedHeight = TopAppBarDefaults.TopAppBarExpandedHeight + statusBarDp,
                    modifier = Modifier.fillMaxWidth(),
                    colors = TopAppBarDefaults.topAppBarColors(
                        containerColor = Color.Red,
                        titleContentColor = Color.White
                    ),
                    windowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)
                )
            }
            LazyColumn(
                modifier = Modifier.weight(1f),
                contentPadding = PaddingValues(16.dp)
            ) {
                items(50) { index ->
                    Text(
                        "Item $index",
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 8.dp),
                        color = Color.Black
                    )
                }
            }
        }
    }
}

Output:

Screen Recording

Answer using default collapsing animation:

You can try to use a combination of expandedHeight together with WindowInsets.statusBars.getTop():

// enableEdgeToEdge()
// no other window options needed

val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val localDensity = LocalDensity.current
val statusBarDp = with(localDensity) { WindowInsets.statusBars.getTop(localDensity).toDp() }

Scaffold(
    modifier = Modifier
        .fillMaxSize()
        .nestedScroll(scrollBehavior.nestedScrollConnection),
    // contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
    topBar = {
        TopAppBar(
            title = {
                Text(
                    modifier = Modifier.padding(top = statusBarDp),
                    text = "My App"
                )
                    },
            expandedHeight = TopAppBarDefaults.TopAppBarExpandedHeight + statusBarDp,
            modifier = Modifier
                .fillMaxWidth()
                .consumeWindowInsets(WindowInsets.statusBars),
            colors = TopAppBarDefaults.topAppBarColors(
                containerColor = Color.Red,
                titleContentColor = Color.White
            ),
            scrollBehavior = scrollBehavior,
        )
    }
) { scaffoldInnerPadding ->
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(scaffoldInnerPadding)
            .background(Color.Gray),
        contentPadding = PaddingValues(16.dp)
    ) {
        items(50) { index ->
            Text(
                "Item $index",
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = 8.dp),
                color = Color.Black
            )
        }
    }
}

Output:

Screen Recording

Note:

Please test this thoroughly, in my case there was some strange white flickering happening when scrolling fastly. This is the best that I managed to achieve.

Sign up to request clarification or add additional context in comments.

7 Comments

We're getting there! I noticed some flashing too, in my case it was black (dark theme maybe). The issue here is that the bar only reappears when reaching the top of the list. This is fixed by changing to enterAlwaysScrollBehavior. However, that turns the bar black until I scroll to the top of the list.
I think we did it!! The black color is controlled by setting scrolledContainerColor. I will test this in my actual project when I get home and award you the bounty. Thanks for your help.
There is one more issue. If you view your .gif carefully, you can see that when closing the TopAppBar, it disappears from both the top and the bottom at the same time. I want it to not be cut off on the bottom while closing. How do we manage that?
Yes, that is how the TopAppBar collapsing animation is by default on Jetpack Compose. Overriding this is a little complicated as we will probably need a custom NestedScrollConnection, but if you want it that way, I will give it a shot and will edit my post if I manage to achieve that.
(@ your comment) sure, go ahead :) Another approach that I'm working on is using a MediumTopAppBar and setting the collapsedSize to 0dp. This makes the TopAppBar collapse from the bottom. The only issue here is that it seems to make the contents of the bar more transparent the more it is collapsed.
See my edited answer, is this what you want to achieve?
This looks perfect. Thanks! I'll get back to you and accept the answer once I get the time to test this myself.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.