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
3 changes: 3 additions & 0 deletions ComposeCollapsingTopBar/api/ComposeCollapsingTopBar.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ abstract interface com.flaringapp.compose.topbar/CollapsingTopBarScope { // com.
abstract fun (androidx.compose.ui/Modifier).floating(): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar/CollapsingTopBarScope.floating|floating@androidx.compose.ui.Modifier(){}[0]
abstract fun (androidx.compose.ui/Modifier).nestedCollapse(com.flaringapp.compose.topbar.nestedcollapse/CollapsingTopBarNestedCollapseElement): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar/CollapsingTopBarScope.nestedCollapse|nestedCollapse@androidx.compose.ui.Modifier(com.flaringapp.compose.topbar.nestedcollapse.CollapsingTopBarNestedCollapseElement){}[0]
abstract fun (androidx.compose.ui/Modifier).parallax(kotlin/Float): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar/CollapsingTopBarScope.parallax|parallax@androidx.compose.ui.Modifier(kotlin.Float){}[0]
abstract fun (androidx.compose.ui/Modifier).pin(kotlin/Boolean = ...): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar/CollapsingTopBarScope.pin|pin@androidx.compose.ui.Modifier(kotlin.Boolean){}[0]
abstract fun (androidx.compose.ui/Modifier).progress(com.flaringapp.compose.topbar/CollapsingTopBarProgressListener): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar/CollapsingTopBarScope.progress|progress@androidx.compose.ui.Modifier(com.flaringapp.compose.topbar.CollapsingTopBarProgressListener){}[0]
}

Expand Down Expand Up @@ -318,6 +319,7 @@ final val com.flaringapp.compose.topbar.scaffold/com_flaringapp_compose_topbar_s
final val com.flaringapp.compose.topbar.snap/com_flaringapp_compose_topbar_snap_CollapsingTopBarNoSnapBehavior$stableprop // com.flaringapp.compose.topbar.snap/com_flaringapp_compose_topbar_snap_CollapsingTopBarNoSnapBehavior$stableprop|#static{}com_flaringapp_compose_topbar_snap_CollapsingTopBarNoSnapBehavior$stableprop[0]
final val com.flaringapp.compose.topbar.snap/com_flaringapp_compose_topbar_snap_CollapsingTopBarThresholdSnapBehavior$stableprop // com.flaringapp.compose.topbar.snap/com_flaringapp_compose_topbar_snap_CollapsingTopBarThresholdSnapBehavior$stableprop|#static{}com_flaringapp_compose_topbar_snap_CollapsingTopBarThresholdSnapBehavior$stableprop[0]
final val com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarLayoutInfo$stableprop // com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarLayoutInfo$stableprop|#static{}com_flaringapp_compose_topbar_CollapsingTopBarLayoutInfo$stableprop[0]
final val com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarParentData_Pin$stableprop // com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarParentData_Pin$stableprop|#static{}com_flaringapp_compose_topbar_CollapsingTopBarParentData_Pin$stableprop[0]
final val com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarState$stableprop // com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarState$stableprop|#static{}com_flaringapp_compose_topbar_CollapsingTopBarState$stableprop[0]

final fun (androidx.compose.ui/Modifier).com.flaringapp.compose.topbar.dependent/collapsingTopBarDependentStateConnection(com.flaringapp.compose.topbar/CollapsingTopBarState, kotlin/Function1<com.flaringapp.compose.topbar/CollapsingTopBarLayoutInfo, kotlin/Unit>): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar.dependent/collapsingTopBarDependentStateConnection|collapsingTopBarDependentStateConnection@androidx.compose.ui.Modifier(com.flaringapp.compose.topbar.CollapsingTopBarState;kotlin.Function1<com.flaringapp.compose.topbar.CollapsingTopBarLayoutInfo,kotlin.Unit>){}[0]
Expand Down Expand Up @@ -348,5 +350,6 @@ final fun com.flaringapp.compose.topbar.snap/com_flaringapp_compose_topbar_snap_
final fun com.flaringapp.compose.topbar.snap/rememberCollapsingTopBarSnapBehavior(kotlin/Float, androidx.compose.animation.core/AnimationSpec<kotlin/Float>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): com.flaringapp.compose.topbar.snap/CollapsingTopBarSnapBehavior // com.flaringapp.compose.topbar.snap/rememberCollapsingTopBarSnapBehavior|rememberCollapsingTopBarSnapBehavior(kotlin.Float;androidx.compose.animation.core.AnimationSpec<kotlin.Float>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun com.flaringapp.compose.topbar/CollapsingTopBar(androidx.compose.ui/Modifier?, com.flaringapp.compose.topbar/CollapsingTopBarState?, kotlin/Boolean, kotlin/Function3<com.flaringapp.compose.topbar/CollapsingTopBarScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.flaringapp.compose.topbar/CollapsingTopBar|CollapsingTopBar(androidx.compose.ui.Modifier?;com.flaringapp.compose.topbar.CollapsingTopBarState?;kotlin.Boolean;kotlin.Function3<com.flaringapp.compose.topbar.CollapsingTopBarScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarLayoutInfo$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarLayoutInfo$stableprop_getter|com_flaringapp_compose_topbar_CollapsingTopBarLayoutInfo$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarParentData_Pin$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarParentData_Pin$stableprop_getter|com_flaringapp_compose_topbar_CollapsingTopBarParentData_Pin$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarState$stableprop_getter(): kotlin/Int // com.flaringapp.compose.topbar/com_flaringapp_compose_topbar_CollapsingTopBarState$stableprop_getter|com_flaringapp_compose_topbar_CollapsingTopBarState$stableprop_getter(){}[0]
final fun com.flaringapp.compose.topbar/rememberCollapsingTopBarState(kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): com.flaringapp.compose.topbar/CollapsingTopBarState // com.flaringapp.compose.topbar/rememberCollapsingTopBarState|rememberCollapsingTopBarState(kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ private fun processPlaceable(
topBarSize: IntSize,
layoutDirection: LayoutDirection,
): IntOffset {
val currentTopBarHeight = layoutInfo.height.roundToInt()

val parentData = placeable.topBarParentData

val alignmentOffset = (parentData?.alignment ?: Alignment.TopStart).align(
Expand All @@ -199,10 +201,24 @@ private fun processPlaceable(
layoutDirection = layoutDirection,
)

val parallaxY = parentData?.parallaxRatio?.let { parallaxRatio ->
val parallaxOffsetY = parentData?.parallaxRatio?.let { parallaxRatio ->
-(layoutInfo.collapseHeightDelta * parallaxRatio).roundToInt()
} ?: 0

val pinOffsetY = parentData?.pin?.let { pin ->
val baseY = alignmentOffset.y + parallaxOffsetY
val maxAllowedY = currentTopBarHeight - placeable.height

var pinnedY = baseY.coerceAtMost(maxAllowedY)
if (pin.stopAtTop) {
pinnedY = pinnedY.coerceAtLeast(0)
}

pinnedY - baseY
} ?: 0

val placementOffsetY = parallaxOffsetY + pinOffsetY

val collapsibleSegmentStart = alignmentOffset.y.coerceAtLeast(layoutInfo.collapsedHeight)
val collapsibleSegmentEnd = alignmentOffset.y + placeable.height
val placeableCollapsibleDistance =
Expand All @@ -211,10 +227,10 @@ private fun processPlaceable(
val placeableProgress = if (placeableCollapsibleDistance == 0) {
1f
} else {
val visibleCollapsibleSegmentStart = (collapsibleSegmentStart + parallaxY)
.coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.roundToInt())
val visibleCollapsibleSegmentEnd = (collapsibleSegmentEnd + parallaxY)
.coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.roundToInt())
val visibleCollapsibleSegmentStart = (collapsibleSegmentStart + placementOffsetY)
.coerceIn(layoutInfo.collapsedHeight, currentTopBarHeight)
val visibleCollapsibleSegmentEnd = (collapsibleSegmentEnd + placementOffsetY)
.coerceIn(layoutInfo.collapsedHeight, currentTopBarHeight)
val visibleCollapsibleDistance =
(visibleCollapsibleSegmentEnd - visibleCollapsibleSegmentStart).coerceAtLeast(0)

Expand All @@ -226,7 +242,7 @@ private fun processPlaceable(
itemProgress = placeableProgress,
)

return IntOffset(alignmentOffset.x, alignmentOffset.y + parallaxY)
return IntOffset(alignmentOffset.x, alignmentOffset.y + placementOffsetY)
}

/**
Expand Down Expand Up @@ -255,6 +271,17 @@ public interface CollapsingTopBarScope {
*/
public fun Modifier.parallax(ratio: Float): Modifier

/**
* Make the element ride with top bar collapse after its bottom touches current top bar bottom.
*
* By default, pinned content continues riding even after crossing the top edge. Set
* [stopAtTop] to true to stop the element at y = 0 instead.
*
* This modifier affects item placement only and does not participate in resolving minimum
* (collapsed) height.
*/
public fun Modifier.pin(stopAtTop: Boolean = false): Modifier

/**
* Exclude the element from resolving minimum height among all elements. Useful for 'floating'
* elements with custom motion on collapse.
Expand Down Expand Up @@ -292,6 +319,10 @@ private object CollapsingTopBarScopeInstance : CollapsingTopBarScope {
return then(ParallaxModifier(ratio))
}

override fun Modifier.pin(stopAtTop: Boolean): Modifier {
return then(PinModifier(stopAtTop))
}

override fun Modifier.floating(): Modifier {
return then(FloatingModifier())
}
Expand Down Expand Up @@ -323,6 +354,16 @@ private class ParallaxModifier(
}
}

private class PinModifier(
private val stopAtTop: Boolean,
) : CollapsingTopBarParentDataModifier() {
override fun modifyParentData(parentData: CollapsingTopBarParentData) {
parentData.pin = CollapsingTopBarParentData.Pin(
stopAtTop = stopAtTop,
)
}
}

private class FloatingModifier : CollapsingTopBarParentDataModifier() {
override fun modifyParentData(parentData: CollapsingTopBarParentData) {
parentData.isFloating = true
Expand Down Expand Up @@ -359,10 +400,16 @@ private abstract class CollapsingTopBarParentDataModifier : ParentDataModifier {
private data class CollapsingTopBarParentData(
var alignment: Alignment? = null,
var parallaxRatio: Float? = null,
var pin: Pin? = null,
var isFloating: Boolean = false,
var progressListener: CollapsingTopBarProgressListener? = null,
var nestedCollapseElement: CollapsingTopBarNestedCollapseElement? = null,
)
) {

data class Pin(
val stopAtTop: Boolean,
)
}

private val Placeable.topBarParentData: CollapsingTopBarParentData?
get() = parentData as? CollapsingTopBarParentData
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,51 @@ CollapsingTopBarScaffold(

</details>

<details>
<summary>Pin</summary>

#### Pin

```kotlin
Modifier.pin(stopAtTop = false)
```

Keeps an element at its regular position until its bottom edge reaches the visible bottom of
`CollapsingTopBar`, then makes it ride upward with the collapsing top bar.

By default, a pinned element continues riding past the top edge. Set `stopAtTop = true` to stop
it at `y = 0`.

`pin(...)` affects placement only and does not change minimum height resolution. Combine it with
`floating()` when the pinned element should behave like an overlay and stay out of collapsed height
calculation.

```kotlin
CollapsingTopBarScaffold(
scrollMode = CollapsingTopBarScaffoldScrollMode.collapse(expandAlways = false),
topBar = {
SampleTopBarImage()
PinnedElement(
modifier = Modifier
.floating()
.align(Alignment.BottomEnd)
.pin(),
)
SampleTopAppBar(
containerColor = Color.Transparent,
)
},
body = {
SampleContent()
},
)
```

> In this example the bottom-end element is pinned and keeps riding with the top bar, excluded from
> collapsed height calculation.

</details>

<details>
<summary>Floating</summary>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.BodyResiz
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
import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.PinSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.SnapCollapsingSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.basic.CollapsingExitExpandAlwaysSample
import com.flaringapp.compose.topbar.sample.shared.ui.samples.basic.CollapsingExitExpandAtTopSample
Expand Down Expand Up @@ -66,6 +67,7 @@ object CollapsingTopBarSampleGroups {
get() = listOf(
AlignmentSample,
ParallaxCollapsingSample,
PinSample,
SnapCollapsingSample,
AppBarShadowSample,
AppBarScrimSample,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* 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.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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 androidx.compose.ui.unit.dp
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.SampleContent
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 PinSample : CollapsingTopBarSample {

override val name: String = "Pin"

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

@Composable
fun PinSampleContent(
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,
)

TallElement(
modifier = Modifier
.align(Alignment.Center)
.pin(stopAtTop = true),
)

SmallElement(
modifier = Modifier
.floating()
.align(Alignment.BottomEnd)
.pin(),
)

SampleTopAppBar(
title = "Pin",
onBack = onBack,
containerColor = Color.Transparent,
)
},
body = {
SampleContent()
},
)
}

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

Box(
modifier = modifier
.size(width = 40.dp, height = 200.dp)
.background(brush = gradient, shape = MaterialTheme.shapes.medium),
)
}

@Composable
private fun SmallElement(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(40.dp)
.background(
color = MaterialTheme.colorScheme.primary,
shape = MaterialTheme.shapes.medium,
),
)
}

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