Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ComposeCollapsingTopBar/api/ComposeCollapsingTopBar.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ abstract interface com.flaringapp.compose.topbar.nestedscroll/CollapsingTopBarNe
open suspend fun (androidx.compose.foundation.gestures/ScrollableState).fling(androidx.compose.foundation.gestures/FlingBehavior, kotlin/Float): kotlin/Float // com.flaringapp.compose.topbar.nestedscroll/CollapsingTopBarNestedScrollHandler.fling|fling@androidx.compose.foundation.gestures.ScrollableState(androidx.compose.foundation.gestures.FlingBehavior;kotlin.Float){}[0]
}

abstract interface com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldBodyScope { // com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldBodyScope|null[0]
abstract fun (androidx.compose.ui/Modifier).resizeWithCollapse(): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldBodyScope.resizeWithCollapse|resizeWithCollapse@androidx.compose.ui.Modifier(){}[0]
}

abstract interface com.flaringapp.compose.topbar.snap/CollapsingTopBarSnapBehavior { // com.flaringapp.compose.topbar.snap/CollapsingTopBarSnapBehavior|null[0]
abstract suspend fun (com.flaringapp.compose.topbar.snap/CollapsingTopBarSnapScope).snap(kotlin/Boolean) // com.flaringapp.compose.topbar.snap/CollapsingTopBarSnapBehavior.snap|snap@com.flaringapp.compose.topbar.snap.CollapsingTopBarSnapScope(kotlin.Boolean){}[0]
}
Expand Down Expand Up @@ -332,7 +336,7 @@ final fun com.flaringapp.compose.topbar.nestedscroll/com_flaringapp_compose_topb
final fun com.flaringapp.compose.topbar.nestedscroll/com_flaringapp_compose_topbar_nestedscroll_CollapsingTopBarNestedScrollExpand_AtTop$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar.nestedscroll/com_flaringapp_compose_topbar_nestedscroll_CollapsingTopBarNestedScrollExpand_AtTop$stableprop_getter|com_flaringapp_compose_topbar_nestedscroll_CollapsingTopBarNestedScrollExpand_AtTop$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar.nestedscroll/com_flaringapp_compose_topbar_nestedscroll_CollapsingTopBarNestedScrollSnap$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar.nestedscroll/com_flaringapp_compose_topbar_nestedscroll_CollapsingTopBarNestedScrollSnap$stableprop_getter|com_flaringapp_compose_topbar_nestedscroll_CollapsingTopBarNestedScrollSnap$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar.nestedscroll/com_flaringapp_compose_topbar_nestedscroll_MultiNestedScrollConnection$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar.nestedscroll/com_flaringapp_compose_topbar_nestedscroll_MultiNestedScrollConnection$stableprop_getter|com_flaringapp_compose_topbar_nestedscroll_MultiNestedScrollConnection$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffold(com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldScrollMode, androidx.compose.ui/Modifier?, com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldState?, kotlin/Boolean, com.flaringapp.compose.topbar.snap/CollapsingTopBarSnapBehavior?, androidx.compose.ui/Modifier?, kotlin/Boolean, kotlin/Function4<com.flaringapp.compose.topbar/CollapsingTopBarScope, com.flaringapp.compose.topbar/CollapsingTopBarState, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffold|CollapsingTopBarScaffold(com.flaringapp.compose.topbar.scaffold.CollapsingTopBarScaffoldScrollMode;androidx.compose.ui.Modifier?;com.flaringapp.compose.topbar.scaffold.CollapsingTopBarScaffoldState?;kotlin.Boolean;com.flaringapp.compose.topbar.snap.CollapsingTopBarSnapBehavior?;androidx.compose.ui.Modifier?;kotlin.Boolean;kotlin.Function4<com.flaringapp.compose.topbar.CollapsingTopBarScope,com.flaringapp.compose.topbar.CollapsingTopBarState,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffold(com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldScrollMode, androidx.compose.ui/Modifier?, com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldState?, kotlin/Boolean, com.flaringapp.compose.topbar.snap/CollapsingTopBarSnapBehavior?, androidx.compose.ui/Modifier?, kotlin/Boolean, kotlin/Function4<com.flaringapp.compose.topbar/CollapsingTopBarScope, com.flaringapp.compose.topbar/CollapsingTopBarState, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, kotlin/Function3<com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffoldBodyScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.flaringapp.compose.topbar.scaffold/CollapsingTopBarScaffold|CollapsingTopBarScaffold(com.flaringapp.compose.topbar.scaffold.CollapsingTopBarScaffoldScrollMode;androidx.compose.ui.Modifier?;com.flaringapp.compose.topbar.scaffold.CollapsingTopBarScaffoldState?;kotlin.Boolean;com.flaringapp.compose.topbar.snap.CollapsingTopBarSnapBehavior?;androidx.compose.ui.Modifier?;kotlin.Boolean;kotlin.Function4<com.flaringapp.compose.topbar.CollapsingTopBarScope,com.flaringapp.compose.topbar.CollapsingTopBarState,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;kotlin.Function3<com.flaringapp.compose.topbar.scaffold.CollapsingTopBarScaffoldBodyScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun com.flaringapp.compose.topbar.scaffold/com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldScrollMode$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar.scaffold/com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldScrollMode$stableprop_getter|com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldScrollMode$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar.scaffold/com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldScrollMode_Exit$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar.scaffold/com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldScrollMode_Exit$stableprop_getter|com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldScrollMode_Exit$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar.scaffold/com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldState$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar.scaffold/com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldState$stableprop_getter|com_flaringapp_compose_topbar_scaffold_CollapsingTopBarScaffoldState$stableprop_getter(){}[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@

package com.flaringapp.compose.topbar.scaffold

import androidx.compose.foundation.layout.LayoutScopeMarker
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
Expand All @@ -26,6 +28,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
Expand Down Expand Up @@ -63,9 +68,13 @@ import kotlin.math.max
* @param topBarClipToBounds the flag whether or not to automatically clip top bar content [topBar]
* to the actual collapse height.
* @param topBar the content of [CollapsingTopBar].
* @param body the scrollable content under collapsing top bar. If you don't use any by default
* scrollable container (e.g. [androidx.compose.foundation.lazy.LazyList]), then make sure to
* apply custom [androidx.compose.foundation.verticalScroll] modifier.
* @param body the content under collapsing top bar. By default, direct body children are measured
* against collapsed top bar height to minimize remeasurement during collapse. Use
* [CollapsingTopBarScaffoldBodyScope.resizeWithCollapse] only for direct body children that must
* resize with the current top bar height instead, such as lightweight overlays. It may increase
* remeasurement cost and is generally not recommended for scrollable content. If you don't use any
* by default scrollable container (e.g. [androidx.compose.foundation.lazy.LazyList]), then make
* sure to apply custom [androidx.compose.foundation.verticalScroll] modifier.
*
* @see CollapsingTopBar
*/
Expand All @@ -80,7 +89,7 @@ public fun CollapsingTopBarScaffold(
topBarModifier: Modifier = Modifier,
topBarClipToBounds: Boolean = true,
topBar: @Composable CollapsingTopBarScope.(topBarState: CollapsingTopBarState) -> Unit,
body: @Composable () -> Unit,
body: @Composable CollapsingTopBarScaffoldBodyScope.() -> Unit,
) {
LaunchedEffect(scrollMode.canExit) {
if (!scrollMode.canExit) {
Expand Down Expand Up @@ -125,7 +134,7 @@ public fun CollapsingTopBarScaffold(
topBar(state.topBarState)
}

body()
CollapsingTopBarScaffoldBodyScopeInstance.body()
},
modifier = modifier
.then(
Expand All @@ -140,15 +149,26 @@ public fun CollapsingTopBarScaffold(
val topBarPlaceable = measurables[0].measure(topBarConstraints)

val bodyPlaceables = if (measurables.size > 1) {
val bodyHeight = (constraints.maxHeight - topBarMinHeightState).coerceAtLeast(0)

val bodyConstraints = constraints.copy(
val collapsedBodyConstrains = constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = bodyHeight,
maxHeight = (constraints.maxHeight - topBarMinHeightState).coerceAtLeast(0),
)
val resizedBodyConstrains = collapsedBodyConstrains.copy(
maxHeight = (constraints.maxHeight - state.totalTopBarHeight.toInt())
.coerceAtLeast(0),
)

val bodyMeasurables = measurables.subList(1, measurables.size)
bodyMeasurables.map { it.measure(bodyConstraints) }
bodyMeasurables.map { measurable ->
val nodeConstraints = if (measurable.bodyParentData?.resizeWithCollapse == true) {
resizedBodyConstrains
} else {
collapsedBodyConstrains
}

measurable.measure(nodeConstraints)
}
} else {
emptyList()
}
Expand All @@ -175,3 +195,53 @@ public fun CollapsingTopBarScaffold(
}
}
}

/**
* Scope for direct body children of [CollapsingTopBarScaffold].
*/
@LayoutScopeMarker
@Immutable
public interface CollapsingTopBarScaffoldBodyScope {

/**
* Opt this direct scaffold body child into measurement against current visible top bar height
* instead of collapsed top bar height.
*
* This may increase remeasurement cost during collapse. Prefer the default scaffold body
* behavior unless this child must respond to the current visible top bar height, e.g. for a
* lightweight overlay.
*/
public fun Modifier.resizeWithCollapse(): Modifier
}

private object CollapsingTopBarScaffoldBodyScopeInstance : CollapsingTopBarScaffoldBodyScope {

override fun Modifier.resizeWithCollapse(): Modifier {
return then(ResizeWithCollapseModifier())
}
}

private class ResizeWithCollapseModifier : CollapsingTopBarScaffoldBodyParentDataModifier() {
override fun modifyParentData(parentData: CollapsingTopBarScaffoldBodyParentData) {
parentData.resizeWithCollapse = true
}
}

private abstract class CollapsingTopBarScaffoldBodyParentDataModifier : ParentDataModifier {

override fun Density.modifyParentData(parentData: Any?): Any {
val data = parentData as? CollapsingTopBarScaffoldBodyParentData
?: CollapsingTopBarScaffoldBodyParentData()
this@CollapsingTopBarScaffoldBodyParentDataModifier.modifyParentData(data)
return data
}

protected abstract fun modifyParentData(parentData: CollapsingTopBarScaffoldBodyParentData)
}

private data class CollapsingTopBarScaffoldBodyParentData(
var resizeWithCollapse: Boolean = false,
)

private val Measurable.bodyParentData: CollapsingTopBarScaffoldBodyParentData?
get() = parentData as? CollapsingTopBarScaffoldBodyParentData
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ dependencyResolutionManagement {

## Usage

You need a scrollable content for the collapsing header to work. For the sake of optimization, this
library does something similar to `CoordinatorLayout`: instead of resizing a content as a top bar
collapses, the content is simply offset downward. That's why you need a `Scaffold`-like wrapper.
You need scrollable content for the collapsing header to work. For the sake of optimization, this
library does something similar to `CoordinatorLayout`: by default, body content is measured as if
the top bar were already collapsed, and then simply offset as the top bar expands and
collapses. That's why you need a `Scaffold`-like wrapper.

```kotlin
CollapsingTopBarScaffold(
Expand All @@ -63,6 +64,31 @@ CollapsingTopBarScaffold(
)
```

In some cases you may want a direct body child to resize together with collapse instead of using
the default fixed measurement strategy. For those cases, `CollapsingTopBarScaffold()` exposes
`CollapsingTopBarScaffoldBodyScope.resizeWithCollapse()`:

```kotlin
CollapsingTopBarScaffold(
scrollMode = CollapsingTopBarScaffoldScrollMode.collapse(expandAlways = false),
topBar = {
TopBarImage()
TopAppBar()
},
body = {
ScrollableContent()
Overlay(
modifier = Modifier.resizeWithCollapse(),
)
},
)
```

Use `resizeWithCollapse()` as a targeted override, typically for lightweight direct children such
as overlays or panels that must track the currently visible body height. It may increase
remeasurement cost during collapse, so the default scaffold behavior remains the preferred choice
for scrollable content.

`CollapsingTopBarScaffold()` is flexible enough to cover a variety of use cases. However, there's
some extra room for customization: you can use `CollapsingTopBar()` as the actual collapsing header,
and implement your own mechanism to control header and content positioning. Feel free to take a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.flaringapp.compose.topbar.sample.shared.ui.samples

import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.AppBarScrimSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.AppBarShadowSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.BodyResizeSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.FloatingElementSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.ManualCollapsingControlsSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.ParallaxCollapsingSample
Expand Down Expand Up @@ -67,6 +68,7 @@ object CollapsingTopBarSampleGroups {
AppBarShadowSample,
AppBarScrimSample,
ManualCollapsingControlsSample,
BodyResizeSample,
FloatingElementSample,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2026 Flaringapp
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.flaringapp.compose.topbar.sample.shared.ui.samples.CollapsingTopBarSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.CollapsingTopBarSampleDogDefaults
import com.flaringapp.compose.topbar.sample.shared.ui.samples.common.SampleTopAppBar
import com.flaringapp.compose.topbar.sample.shared.ui.samples.common.SampleTopBarImage
import com.flaringapp.compose.topbar.sample.shared.ui.theme.ComposeCollapsingTopBarTheme
import com.flaringapp.compose.topbar.scaffold.CollapsingTopBarScaffold
import com.flaringapp.compose.topbar.scaffold.CollapsingTopBarScaffoldScrollMode

object BodyResizeSample : CollapsingTopBarSample {

override val name: String = "Body Resize"

@Composable
override fun Content(onBack: () -> Unit) {
BodyResizeSampleContent(onBack = onBack)
}
}

@Composable
fun BodyResizeSampleContent(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxSize(),
) {
CollapsingContent(
onBack = onBack,
)
}
}

@Composable
private fun CollapsingContent(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
CollapsingTopBarScaffold(
modifier = modifier,
scrollMode = CollapsingTopBarScaffoldScrollMode.collapse(expandAlways = false),
topBar = {
SampleTopBarImage(
dog = CollapsingTopBarSampleDogDefaults.Advanced,
)

SampleTopAppBar(
title = "Body Resize",
onBack = onBack,
containerColor = Color.Transparent,
)
},
body = {
GradientContent(
modifier = Modifier.resizeWithCollapse(),
)
},
)
}

@Composable
private fun GradientContent(
modifier: Modifier = Modifier,
) {
val colorScheme = MaterialTheme.colorScheme
val gradient = remember(colorScheme) {
Brush.verticalGradient(
colors = listOf(
colorScheme.primary,
colorScheme.surface,
colorScheme.tertiaryContainer,
),
)
}

Box(
modifier = modifier
.fillMaxSize()
.background(gradient)
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.Center,
) {
Text("Center")
}
}

@Preview
@Composable
private fun Preview() {
ComposeCollapsingTopBarTheme {
BodyResizeSampleContent(
onBack = {},
)
}
}
Loading