From 928e2ebe55f86629f506633308daf63bd6b7baaa Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Sun, 5 Apr 2026 12:11:04 +0300 Subject: [PATCH 01/11] Cleanup order of modifiers in top app bar --- .../compose/topbar/CollapsingTopBar.kt | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt index 056564f..721fac8 100644 --- a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt +++ b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt @@ -209,16 +209,6 @@ private fun resolveCollapsedHeight(placeables: List): Int { @Immutable public interface CollapsingTopBarScope { - /** - * Registers a progress listener to be notified every time top bar collapse height changes. - * Only the last modifier in chain takes effect. - * - * @param listener The listener that gets notified of every collapse progress update. - * - * @see CollapsingTopBarProgressListener - */ - public fun Modifier.progress(listener: CollapsingTopBarProgressListener): Modifier - /** * Position the element dynamically while collapsing by offsetting up by [ratio] as a fraction * of collapsible height. Value 0f means there is no parallax and the element simply sits in @@ -233,6 +223,16 @@ public interface CollapsingTopBarScope { */ public fun Modifier.floating(): Modifier + /** + * Registers a progress listener to be notified every time top bar collapse height changes. + * Only the last modifier in chain takes effect. + * + * @param listener The listener that gets notified of every collapse progress update. + * + * @see CollapsingTopBarProgressListener + */ + public fun Modifier.progress(listener: CollapsingTopBarProgressListener): Modifier + /** * Define an explicit minimum (collapsed) height nested collapse connection between the top bar * and this element. The element is responsible for dispatching its own minimum height using @@ -246,10 +246,6 @@ public interface CollapsingTopBarScope { private object CollapsingTopBarScopeInstance : CollapsingTopBarScope { - override fun Modifier.progress(listener: CollapsingTopBarProgressListener): Modifier { - return then(ProgressListenerModifier(listener)) - } - override fun Modifier.parallax(ratio: Float): Modifier { return then(ParallaxModifier(ratio)) } @@ -258,6 +254,10 @@ private object CollapsingTopBarScopeInstance : CollapsingTopBarScope { return then(FloatingModifier()) } + override fun Modifier.progress(listener: CollapsingTopBarProgressListener): Modifier { + return then(ProgressListenerModifier(listener)) + } + override fun Modifier.nestedCollapse( element: CollapsingTopBarNestedCollapseElement, ): Modifier { @@ -265,14 +265,6 @@ private object CollapsingTopBarScopeInstance : CollapsingTopBarScope { } } -private class ProgressListenerModifier( - private val listener: CollapsingTopBarProgressListener, -) : CollapsingTopBarParentDataModifier() { - override fun modifyParentData(parentData: CollapsingTopBarParentData) { - parentData.progressListener = listener - } -} - private class ParallaxModifier( private val ratio: Float, ) : CollapsingTopBarParentDataModifier() { @@ -287,6 +279,14 @@ private class FloatingModifier : CollapsingTopBarParentDataModifier() { } } +private class ProgressListenerModifier( + private val listener: CollapsingTopBarProgressListener, +) : CollapsingTopBarParentDataModifier() { + override fun modifyParentData(parentData: CollapsingTopBarParentData) { + parentData.progressListener = listener + } +} + private class NestedCollapseModifier( private val element: CollapsingTopBarNestedCollapseElement, ) : CollapsingTopBarParentDataModifier() { @@ -307,9 +307,9 @@ private abstract class CollapsingTopBarParentDataModifier : ParentDataModifier { } private data class CollapsingTopBarParentData( - var progressListener: CollapsingTopBarProgressListener? = null, var parallaxRatio: Float? = null, var isFloating: Boolean = false, + var progressListener: CollapsingTopBarProgressListener? = null, var nestedCollapseElement: CollapsingTopBarNestedCollapseElement? = null, ) From dfc3f5bb125110e22331327f79687dbf9b91cfd6 Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Sun, 5 Apr 2026 20:35:57 +0300 Subject: [PATCH 02/11] Add todo doc for itemProgress update --- COLLAPSING_TOP_BAR_ITEM_PROGRESS.md | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 COLLAPSING_TOP_BAR_ITEM_PROGRESS.md diff --git a/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md b/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md new file mode 100644 index 0000000..a769309 --- /dev/null +++ b/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md @@ -0,0 +1,71 @@ +# CollapsingTopBar itemProgress semantics + +This note defines how `CollapsingTopBarProgressListener.itemProgress` should behave in `CollapsingTopBar`. + +## Contract + +`itemProgress` is a `0f..1f` value describing how much of the child's collapsible portion is still visible. + +- `1f` means the collapsible portion is fully visible. +- `0f` means the collapsible portion is no longer visible. +- This matches the existing listener direction used by `CollapsingTopBarColumn`: higher means more visible and less collapsed. + +## What counts as collapsible portion + +The child's collapsible portion is the part of the child outside the collapsed-height baseline area. + +The portion of the child that already lies within the top bar's collapsed-height area is treated as non-collapsible baseline content and is excluded from `itemProgress`. + +Because of that: + +- a child may still be partially visible when `itemProgress == 0f` +- a child whose height is less than or equal to the collapsed height has no collapsible portion, so its `itemProgress` is always `1f` + +## Layout-aware measurement + +`itemProgress` must be measured from final placed bounds, after layout placement effects are applied: + +- alignment +- parallax + +Visibility is measured against the current visible top bar viewport. + +This means: + +- alignment changes when collapse starts affecting the child +- parallax can reduce `itemProgress` by moving the collapsible portion out of view, even before full top bar collapse + +## Examples + +### Top-aligned child, no parallax + +- Top bar height: `200 -> 50` +- Child height: `100` +- Collapsed height: `50` + +The child's lower `50dp` is the collapsible portion. +`itemProgress` stays `1f` until top bar height goes below `100dp`, then decreases to `0f` at `50dp`. + +### Child height equals collapsed height + +- Child height: `50` +- Collapsed height: `50` + +The child has no collapsible portion. +`itemProgress` is always `1f`. + +### Center-aligned child + +If a center-aligned child already overlaps the collapsed-height area in expanded state, that overlapping area is excluded from the metric. Only the remaining area participates in `itemProgress`. + +### 100% parallax + +If the child's collapsible portion slides upward and leaves the viewport due to parallax, `itemProgress` should decrease accordingly and may reach `0f` before the top bar is fully collapsed. + +## Invariants for future implementation + +- `itemProgress` must remain within `0f..1f` +- `itemProgress` direction must match `CollapsingTopBarColumn`: larger value means more visible and less collapsed +- baseline collapsed-height content is excluded from the metric +- `align` and `parallax` affect measurement through final placed bounds +- zero collapsible portion returns `1f` From 5e4aab0f547cdd3deea1728dfed848d8d164eab7 Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Sun, 5 Apr 2026 23:29:40 +0300 Subject: [PATCH 03/11] Fix item progress calculation to account for parallax --- .../compose/topbar/CollapsingTopBar.kt | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt index 721fac8..c170f9d 100644 --- a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt +++ b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt @@ -133,37 +133,33 @@ private class CollapsingTopBarMeasurePolicy( return layout(width, measuredLayoutInfo.expandedHeight) { val layoutInfo = state.layoutInfo - val progress = layoutInfo.collapseProgress - val collapsibleDistance = layoutInfo.collapsibleDistance placeables.forEach { placeable -> val parentData = placeable.topBarParentData + var placeableY = 0 + + parentData?.parallaxRatio?.let { parallaxRatio -> + placeableY -= (layoutInfo.collapseHeightDelta * parallaxRatio).roundToInt() + } + val placeableCollapsibleDistance = (placeable.height - layoutInfo.collapsedHeight).coerceAtLeast(0) val placeableProgress = if (placeableCollapsibleDistance == 0) { 1f } else { - val placeableCollapseHeight = with(layoutInfo) { - height.coerceAtMost(placeable.height.toFloat()) - collapsedHeight - } - placeableCollapseHeight / placeableCollapsibleDistance + val placeableYetToCollapseHeight = (placeableCollapsibleDistance + placeableY) + .coerceIn(0, layoutInfo.expandHeightDelta.toInt()) + + placeableYetToCollapseHeight.toFloat() / placeableCollapsibleDistance } parentData?.progressListener?.onProgressUpdate( - totalProgress = progress, + totalProgress = layoutInfo.collapseProgress, itemProgress = placeableProgress, ) - parentData?.parallaxRatio?.let { parallaxRatio -> - placeable.placeRelative( - x = 0, - y = -(collapsibleDistance * (1 - progress) * parallaxRatio).roundToInt(), - ) - return@forEach - } - - placeable.placeRelative(0, 0) + placeable.placeRelative(0, placeableY) } } } From e5e6627add72956d2697cb61d18635fa99f84f69 Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Sun, 5 Apr 2026 23:50:54 +0300 Subject: [PATCH 04/11] Cleanup code - extract placeable calculations to a separate method --- .../compose/topbar/CollapsingTopBar.kt | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt index c170f9d..6375003 100644 --- a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt +++ b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.layout.ParentDataModifier import androidx.compose.ui.layout.Placeable import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth @@ -135,31 +136,11 @@ private class CollapsingTopBarMeasurePolicy( val layoutInfo = state.layoutInfo placeables.forEach { placeable -> - val parentData = placeable.topBarParentData - - var placeableY = 0 - - parentData?.parallaxRatio?.let { parallaxRatio -> - placeableY -= (layoutInfo.collapseHeightDelta * parallaxRatio).roundToInt() - } - - val placeableCollapsibleDistance = - (placeable.height - layoutInfo.collapsedHeight).coerceAtLeast(0) - val placeableProgress = if (placeableCollapsibleDistance == 0) { - 1f - } else { - val placeableYetToCollapseHeight = (placeableCollapsibleDistance + placeableY) - .coerceIn(0, layoutInfo.expandHeightDelta.toInt()) - - placeableYetToCollapseHeight.toFloat() / placeableCollapsibleDistance - } - - parentData?.progressListener?.onProgressUpdate( - totalProgress = layoutInfo.collapseProgress, - itemProgress = placeableProgress, + val offset = processPlaceable( + placeable = placeable, + layoutInfo = layoutInfo, ) - - placeable.placeRelative(0, placeableY) + placeable.placeRelative(offset) } } } @@ -198,6 +179,37 @@ private fun resolveCollapsedHeight(placeables: List): Int { return regularCollapseMinHeight } +private fun processPlaceable( + placeable: Placeable, + layoutInfo: CollapsingTopBarLayoutInfo, +): IntOffset { + val parentData = placeable.topBarParentData + + var placeableY = 0 + + parentData?.parallaxRatio?.let { parallaxRatio -> + placeableY -= (layoutInfo.collapseHeightDelta * parallaxRatio).roundToInt() + } + + val placeableCollapsibleDistance = + (placeable.height - layoutInfo.collapsedHeight).coerceAtLeast(0) + val placeableProgress = if (placeableCollapsibleDistance == 0) { + 1f + } else { + val placeableYetToCollapseHeight = (placeableCollapsibleDistance + placeableY) + .coerceIn(0, layoutInfo.expandHeightDelta.toInt()) + + placeableYetToCollapseHeight.toFloat() / placeableCollapsibleDistance + } + + parentData?.progressListener?.onProgressUpdate( + totalProgress = layoutInfo.collapseProgress, + itemProgress = placeableProgress, + ) + + return IntOffset(0, placeableY) +} + /** * Scope for the children of [CollapsingTopBar]. */ From 2b3438fef8fe918d94010eacccdf967f907160bd Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Mon, 6 Apr 2026 10:59:46 +0300 Subject: [PATCH 05/11] Define align modifier for collapsing top bar --- .../api/ComposeCollapsingTopBar.klib.api | 1 + .../compose/topbar/CollapsingTopBar.kt | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/ComposeCollapsingTopBar/api/ComposeCollapsingTopBar.klib.api b/ComposeCollapsingTopBar/api/ComposeCollapsingTopBar.klib.api index e861ae6..147300a 100644 --- a/ComposeCollapsingTopBar/api/ComposeCollapsingTopBar.klib.api +++ b/ComposeCollapsingTopBar/api/ComposeCollapsingTopBar.klib.api @@ -56,6 +56,7 @@ abstract interface com.flaringapp.compose.topbar/CollapsingTopBarControls { // c } abstract interface com.flaringapp.compose.topbar/CollapsingTopBarScope { // com.flaringapp.compose.topbar/CollapsingTopBarScope|null[0] + abstract fun (androidx.compose.ui/Modifier).align(androidx.compose.ui/Alignment): androidx.compose.ui/Modifier // com.flaringapp.compose.topbar/CollapsingTopBarScope.align|align@androidx.compose.ui.Modifier(androidx.compose.ui.Alignment){}[0] 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] diff --git a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt index 6375003..d866a91 100644 --- a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt +++ b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.LayoutScopeMarker import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.toRect @@ -217,6 +218,14 @@ private fun processPlaceable( @Immutable public interface CollapsingTopBarScope { + /** + * Align the element within the bounds of [CollapsingTopBar]. + * Only the last modifier in chain takes effect. + * + * @param alignment the alignment of the element inside the top bar. + */ + public fun Modifier.align(alignment: Alignment): Modifier + /** * Position the element dynamically while collapsing by offsetting up by [ratio] as a fraction * of collapsible height. Value 0f means there is no parallax and the element simply sits in @@ -254,6 +263,10 @@ public interface CollapsingTopBarScope { private object CollapsingTopBarScopeInstance : CollapsingTopBarScope { + override fun Modifier.align(alignment: Alignment): Modifier { + return then(AlignmentModifier(alignment)) + } + override fun Modifier.parallax(ratio: Float): Modifier { return then(ParallaxModifier(ratio)) } @@ -273,6 +286,14 @@ private object CollapsingTopBarScopeInstance : CollapsingTopBarScope { } } +private class AlignmentModifier( + private val alignment: Alignment, +) : CollapsingTopBarParentDataModifier() { + override fun modifyParentData(parentData: CollapsingTopBarParentData) { + parentData.alignment = alignment + } +} + private class ParallaxModifier( private val ratio: Float, ) : CollapsingTopBarParentDataModifier() { @@ -315,6 +336,7 @@ private abstract class CollapsingTopBarParentDataModifier : ParentDataModifier { } private data class CollapsingTopBarParentData( + var alignment: Alignment? = null, var parallaxRatio: Float? = null, var isFloating: Boolean = false, var progressListener: CollapsingTopBarProgressListener? = null, From 2bc13954a6604956f3257a9a959d8853b5401a3d Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Mon, 6 Apr 2026 12:06:55 +0300 Subject: [PATCH 06/11] Update placeable math to account for alignment --- COLLAPSING_TOP_BAR_ITEM_PROGRESS.md | 36 ++++++++++++---- .../compose/topbar/CollapsingTopBar.kt | 42 +++++++++++++------ 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md b/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md index a769309..9389d8b 100644 --- a/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md +++ b/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md @@ -12,27 +12,36 @@ This note defines how `CollapsingTopBarProgressListener.itemProgress` should beh ## What counts as collapsible portion -The child's collapsible portion is the part of the child outside the collapsed-height baseline area. +The child's collapsible portion is the part of the child outside the collapsed-height baseline area, +measured from its aligned bounds before parallax translation is applied. -The portion of the child that already lies within the top bar's collapsed-height area is treated as non-collapsible baseline content and is excluded from `itemProgress`. +The portion of the child that already lies within the top bar's collapsed-height area is treated as +non-collapsible baseline content and is excluded from `itemProgress`. Because of that: - a child may still be partially visible when `itemProgress == 0f` -- a child whose height is less than or equal to the collapsed height has no collapsible portion, so its `itemProgress` is always `1f` +- a child whose height is less than or equal to the collapsed height may still have a collapsible + portion if alignment places part of it outside the baseline area +- zero collapsible portion returns `1f` ## Layout-aware measurement -`itemProgress` must be measured from final placed bounds, after layout placement effects are applied: +`itemProgress` must be measured from layout-aware bounds: - alignment - parallax Visibility is measured against the current visible top bar viewport. +The total collapsible portion is defined from aligned bounds and stays stable as parallax changes. +Parallax affects how much of that portion remains visible, but does not redefine how much of the +child is collapsible. + This means: -- alignment changes when collapse starts affecting the child +- alignment changes what part of the child is considered collapsible and when collapse starts + affecting it - parallax can reduce `itemProgress` by moving the collapsible portion out of view, even before full top bar collapse ## Examples @@ -46,7 +55,7 @@ This means: The child's lower `50dp` is the collapsible portion. `itemProgress` stays `1f` until top bar height goes below `100dp`, then decreases to `0f` at `50dp`. -### Child height equals collapsed height +### Top-aligned child height equals collapsed height - Child height: `50` - Collapsed height: `50` @@ -54,6 +63,15 @@ The child's lower `50dp` is the collapsible portion. The child has no collapsible portion. `itemProgress` is always `1f`. +### Child height equals collapsed height, center-aligned + +- Top bar height: `200 -> 50` +- Child height: `50` +- Collapsed height: `50` + +If alignment places part of the child below the baseline area, that part is collapsible even though +the child's height equals the collapsed height. + ### Center-aligned child If a center-aligned child already overlaps the collapsed-height area in expanded state, that overlapping area is excluded from the metric. Only the remaining area participates in `itemProgress`. @@ -62,10 +80,14 @@ If a center-aligned child already overlaps the collapsed-height area in expanded If the child's collapsible portion slides upward and leaves the viewport due to parallax, `itemProgress` should decrease accordingly and may reach `0f` before the top bar is fully collapsed. +This requires the total collapsible portion to remain stable while parallax changes only the visible +part of that portion. + ## Invariants for future implementation - `itemProgress` must remain within `0f..1f` - `itemProgress` direction must match `CollapsingTopBarColumn`: larger value means more visible and less collapsed - baseline collapsed-height content is excluded from the metric -- `align` and `parallax` affect measurement through final placed bounds +- `align` defines the collapsible portion through aligned bounds +- `parallax` affects visibility of that portion without changing its total size - zero collapsible portion returns `1f` diff --git a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt index d866a91..d24515a 100644 --- a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt +++ b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.layout.Placeable import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth @@ -133,15 +134,19 @@ private class CollapsingTopBarMeasurePolicy( val width = placeables.maxOf { it.width } .let { constraints.constrainWidth(it) } - return layout(width, measuredLayoutInfo.expandedHeight) { + val topBarSize = IntSize(width, measuredLayoutInfo.expandedHeight) + + return layout(topBarSize.width, topBarSize.height) { val layoutInfo = state.layoutInfo placeables.forEach { placeable -> val offset = processPlaceable( placeable = placeable, layoutInfo = layoutInfo, + topBarSize = topBarSize, + layoutDirection = layoutDirection, ) - placeable.placeRelative(offset) + placeable.place(offset) } } } @@ -183,24 +188,37 @@ private fun resolveCollapsedHeight(placeables: List): Int { private fun processPlaceable( placeable: Placeable, layoutInfo: CollapsingTopBarLayoutInfo, + topBarSize: IntSize, + layoutDirection: LayoutDirection, ): IntOffset { val parentData = placeable.topBarParentData - var placeableY = 0 + val alignmentOffset = (parentData?.alignment ?: Alignment.TopStart).align( + size = IntSize(placeable.width, placeable.height), + space = topBarSize, + layoutDirection = layoutDirection, + ) - parentData?.parallaxRatio?.let { parallaxRatio -> - placeableY -= (layoutInfo.collapseHeightDelta * parallaxRatio).roundToInt() - } + val parallaxY = parentData?.parallaxRatio?.let { parallaxRatio -> + -(layoutInfo.collapseHeightDelta * parallaxRatio).roundToInt() + } ?: 0 + val collapsibleSegmentStart = alignmentOffset.y.coerceAtLeast(layoutInfo.collapsedHeight) + val collapsibleSegmentEnd = alignmentOffset.y + placeable.height val placeableCollapsibleDistance = - (placeable.height - layoutInfo.collapsedHeight).coerceAtLeast(0) + (collapsibleSegmentEnd - collapsibleSegmentStart).coerceAtLeast(0) + val placeableProgress = if (placeableCollapsibleDistance == 0) { 1f } else { - val placeableYetToCollapseHeight = (placeableCollapsibleDistance + placeableY) - .coerceIn(0, layoutInfo.expandHeightDelta.toInt()) - - placeableYetToCollapseHeight.toFloat() / placeableCollapsibleDistance + val visibleCollapsibleSegmentStart = (collapsibleSegmentStart + parallaxY) + .coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.toInt()) + val visibleCollapsibleSegmentEnd = (collapsibleSegmentEnd + parallaxY) + .coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.toInt()) + val visibleCollapsibleDistance = + (visibleCollapsibleSegmentEnd - visibleCollapsibleSegmentStart).coerceAtLeast(0) + + visibleCollapsibleDistance.toFloat() / placeableCollapsibleDistance } parentData?.progressListener?.onProgressUpdate( @@ -208,7 +226,7 @@ private fun processPlaceable( itemProgress = placeableProgress, ) - return IntOffset(0, placeableY) + return IntOffset(alignmentOffset.x, alignmentOffset.y + parallaxY) } /** From 1e1a19f969dc8279d752a7d1d4f376e90ee047b3 Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Mon, 6 Apr 2026 13:29:38 +0300 Subject: [PATCH 07/11] Remove spec --- COLLAPSING_TOP_BAR_ITEM_PROGRESS.md | 93 ----------------------------- 1 file changed, 93 deletions(-) delete mode 100644 COLLAPSING_TOP_BAR_ITEM_PROGRESS.md diff --git a/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md b/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md deleted file mode 100644 index 9389d8b..0000000 --- a/COLLAPSING_TOP_BAR_ITEM_PROGRESS.md +++ /dev/null @@ -1,93 +0,0 @@ -# CollapsingTopBar itemProgress semantics - -This note defines how `CollapsingTopBarProgressListener.itemProgress` should behave in `CollapsingTopBar`. - -## Contract - -`itemProgress` is a `0f..1f` value describing how much of the child's collapsible portion is still visible. - -- `1f` means the collapsible portion is fully visible. -- `0f` means the collapsible portion is no longer visible. -- This matches the existing listener direction used by `CollapsingTopBarColumn`: higher means more visible and less collapsed. - -## What counts as collapsible portion - -The child's collapsible portion is the part of the child outside the collapsed-height baseline area, -measured from its aligned bounds before parallax translation is applied. - -The portion of the child that already lies within the top bar's collapsed-height area is treated as -non-collapsible baseline content and is excluded from `itemProgress`. - -Because of that: - -- a child may still be partially visible when `itemProgress == 0f` -- a child whose height is less than or equal to the collapsed height may still have a collapsible - portion if alignment places part of it outside the baseline area -- zero collapsible portion returns `1f` - -## Layout-aware measurement - -`itemProgress` must be measured from layout-aware bounds: - -- alignment -- parallax - -Visibility is measured against the current visible top bar viewport. - -The total collapsible portion is defined from aligned bounds and stays stable as parallax changes. -Parallax affects how much of that portion remains visible, but does not redefine how much of the -child is collapsible. - -This means: - -- alignment changes what part of the child is considered collapsible and when collapse starts - affecting it -- parallax can reduce `itemProgress` by moving the collapsible portion out of view, even before full top bar collapse - -## Examples - -### Top-aligned child, no parallax - -- Top bar height: `200 -> 50` -- Child height: `100` -- Collapsed height: `50` - -The child's lower `50dp` is the collapsible portion. -`itemProgress` stays `1f` until top bar height goes below `100dp`, then decreases to `0f` at `50dp`. - -### Top-aligned child height equals collapsed height - -- Child height: `50` -- Collapsed height: `50` - -The child has no collapsible portion. -`itemProgress` is always `1f`. - -### Child height equals collapsed height, center-aligned - -- Top bar height: `200 -> 50` -- Child height: `50` -- Collapsed height: `50` - -If alignment places part of the child below the baseline area, that part is collapsible even though -the child's height equals the collapsed height. - -### Center-aligned child - -If a center-aligned child already overlaps the collapsed-height area in expanded state, that overlapping area is excluded from the metric. Only the remaining area participates in `itemProgress`. - -### 100% parallax - -If the child's collapsible portion slides upward and leaves the viewport due to parallax, `itemProgress` should decrease accordingly and may reach `0f` before the top bar is fully collapsed. - -This requires the total collapsible portion to remain stable while parallax changes only the visible -part of that portion. - -## Invariants for future implementation - -- `itemProgress` must remain within `0f..1f` -- `itemProgress` direction must match `CollapsingTopBarColumn`: larger value means more visible and less collapsed -- baseline collapsed-height content is excluded from the metric -- `align` defines the collapsible portion through aligned bounds -- `parallax` affects visibility of that portion without changing its total size -- zero collapsible portion returns `1f` From e5f801e5bb70da5bf3da89e953997994a858375e Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Mon, 6 Apr 2026 13:44:57 +0300 Subject: [PATCH 08/11] Add alignment sample --- .../samples/CollapsingTopBarSampleGroups.kt | 2 + .../ui/samples/advanced/AlignmentSample.kt | 158 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/advanced/AlignmentSample.kt diff --git a/sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/CollapsingTopBarSampleGroups.kt b/sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/CollapsingTopBarSampleGroups.kt index e78476e..e8ba593 100644 --- a/sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/CollapsingTopBarSampleGroups.kt +++ b/sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/CollapsingTopBarSampleGroups.kt @@ -16,6 +16,7 @@ package com.flaringapp.compose.topbar.sample.shared.ui.samples +import com.flaringapp.compose.topbar.sample.shared.ui.samples.advanced.AlignmentSample 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 @@ -63,6 +64,7 @@ object CollapsingTopBarSampleGroups { val Advanced: List get() = listOf( + AlignmentSample, ParallaxCollapsingSample, SnapCollapsingSample, AppBarShadowSample, diff --git a/sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/advanced/AlignmentSample.kt b/sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/advanced/AlignmentSample.kt new file mode 100644 index 0000000..bed8b37 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/flaringapp/compose/topbar/sample/shared/ui/samples/advanced/AlignmentSample.kt @@ -0,0 +1,158 @@ +/* + * 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.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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 AlignmentSample : CollapsingTopBarSample { + + override val name: String = "Alignment" + + @Composable + override fun Content(onBack: () -> Unit) { + AlignmentSampleContent(onBack = onBack) + } +} + +@Composable +fun AlignmentSampleContent( + 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, + ) + + AlignmentElement( + modifier = Modifier.align(Alignment.TopStart), + text = "Top Start", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.TopCenter), + text = "Top Center", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.TopEnd), + text = "Top End", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.CenterStart), + text = "Center Start", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.Center), + text = "Center", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.CenterEnd), + text = "Center End", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.BottomStart), + text = "Bottom Start", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.BottomCenter), + text = "Bottom Center", + ) + AlignmentElement( + modifier = Modifier.align(Alignment.BottomEnd), + text = "Bottom End", + ) + + SampleTopAppBar( + title = "Alignment", + onBack = onBack, + containerColor = Color.Transparent, + ) + }, + body = { + SampleContent() + }, + ) +} + +@Composable +fun AlignmentElement( + text: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .alpha(0.5f) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = MaterialTheme.shapes.small, + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Preview +@Composable +private fun Preview() { + ComposeCollapsingTopBarTheme { + AlignmentSampleContent( + onBack = {}, + ) + } +} From 509abca90ab16747a5bdecb164a265ec3e90ef14 Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Mon, 6 Apr 2026 14:07:40 +0300 Subject: [PATCH 09/11] Add alignment documentation to readme --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index d38863d..73c6a82 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,37 @@ block. It offers a few predefined Modifiers to customize element placement and m See all supported placement customization Modifiers: +
+Align + +#### Align + +```kotlin +Modifier.align(Alignment) +``` + +Aligns an element within the bounds of `CollapsingTopBar`. + +```kotlin +CollapsingTopBarScaffold( + scrollMode = CollapsingTopBarScaffoldScrollMode.collapse(expandAlways = false), + topBar = { + SampleTopBarImage() + AlignmentElement( + modifier = Modifier.align(Alignment.BottomEnd), + text = "Nebula", + ) + }, + body = { + SampleContent() + }, +) +``` + +> In this example the "Nebula" element is aligned to the bottom end of the top bar. + +
+
Parallax From 189a8ad4daee8f5c63c1338b54651e431067bcd7 Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Mon, 6 Apr 2026 14:49:21 +0300 Subject: [PATCH 10/11] Fix rounding error --- .../kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt index d24515a..f7c6203 100644 --- a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt +++ b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt @@ -212,9 +212,9 @@ private fun processPlaceable( 1f } else { val visibleCollapsibleSegmentStart = (collapsibleSegmentStart + parallaxY) - .coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.toInt()) + .coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.roundToInt()) val visibleCollapsibleSegmentEnd = (collapsibleSegmentEnd + parallaxY) - .coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.toInt()) + .coerceIn(layoutInfo.collapsedHeight, layoutInfo.height.roundToInt()) val visibleCollapsibleDistance = (visibleCollapsibleSegmentEnd - visibleCollapsibleSegmentStart).coerceAtLeast(0) From 1da2b4777907f0f47b74f38607cace373abd58a3 Mon Sep 17 00:00:00 2001 From: Flaringapp Date: Mon, 6 Apr 2026 15:25:55 +0300 Subject: [PATCH 11/11] Update documentation --- .../com/flaringapp/compose/topbar/CollapsingTopBar.kt | 3 +++ README.md | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt index f7c6203..7655dbb 100644 --- a/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt +++ b/ComposeCollapsingTopBar/src/commonMain/kotlin/com/flaringapp/compose/topbar/CollapsingTopBar.kt @@ -238,6 +238,9 @@ public interface CollapsingTopBarScope { /** * Align the element within the bounds of [CollapsingTopBar]. + * Aligned elements still contribute to resolving minimum (collapsed) height among all + * elements. To exclude an aligned element from minimum height resolution and keep overlay-like + * behavior, also apply [floating]. * Only the last modifier in chain takes effect. * * @param alignment the alignment of the element inside the top bar. diff --git a/README.md b/README.md index 73c6a82..abb7ad4 100644 --- a/README.md +++ b/README.md @@ -195,13 +195,19 @@ Modifier.align(Alignment) Aligns an element within the bounds of `CollapsingTopBar`. +Aligned elements still contribute to minimum height resolution. If you want an aligned element to +behave like an overlay and not affect the collapsed height, combine `align(...)` with +`floating()`. + ```kotlin CollapsingTopBarScaffold( scrollMode = CollapsingTopBarScaffoldScrollMode.collapse(expandAlways = false), topBar = { SampleTopBarImage() AlignmentElement( - modifier = Modifier.align(Alignment.BottomEnd), + modifier = Modifier + .align(Alignment.BottomEnd) + .floating(), text = "Nebula", ) }, @@ -211,7 +217,8 @@ CollapsingTopBarScaffold( ) ``` -> In this example the "Nebula" element is aligned to the bottom end of the top bar. +> In this example the "Nebula" element is aligned to the bottom end of the top bar and kept out of +> minimum height resolution, so it behaves like an overlay.