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 056564f..7655dbb 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 @@ -35,6 +36,8 @@ 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.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth @@ -131,39 +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 - val progress = layoutInfo.collapseProgress - val collapsibleDistance = layoutInfo.collapsibleDistance placeables.forEach { placeable -> - val parentData = placeable.topBarParentData - - 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 - } - - parentData?.progressListener?.onProgressUpdate( - totalProgress = progress, - itemProgress = placeableProgress, + val offset = processPlaceable( + placeable = placeable, + layoutInfo = layoutInfo, + topBarSize = topBarSize, + layoutDirection = layoutDirection, ) - - parentData?.parallaxRatio?.let { parallaxRatio -> - placeable.placeRelative( - x = 0, - y = -(collapsibleDistance * (1 - progress) * parallaxRatio).roundToInt(), - ) - return@forEach - } - - placeable.placeRelative(0, 0) + placeable.place(offset) } } } @@ -202,6 +185,50 @@ private fun resolveCollapsedHeight(placeables: List): Int { return regularCollapseMinHeight } +private fun processPlaceable( + placeable: Placeable, + layoutInfo: CollapsingTopBarLayoutInfo, + topBarSize: IntSize, + layoutDirection: LayoutDirection, +): IntOffset { + val parentData = placeable.topBarParentData + + val alignmentOffset = (parentData?.alignment ?: Alignment.TopStart).align( + size = IntSize(placeable.width, placeable.height), + space = topBarSize, + layoutDirection = layoutDirection, + ) + + 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 = + (collapsibleSegmentEnd - collapsibleSegmentStart).coerceAtLeast(0) + + 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 visibleCollapsibleDistance = + (visibleCollapsibleSegmentEnd - visibleCollapsibleSegmentStart).coerceAtLeast(0) + + visibleCollapsibleDistance.toFloat() / placeableCollapsibleDistance + } + + parentData?.progressListener?.onProgressUpdate( + totalProgress = layoutInfo.collapseProgress, + itemProgress = placeableProgress, + ) + + return IntOffset(alignmentOffset.x, alignmentOffset.y + parallaxY) +} + /** * Scope for the children of [CollapsingTopBar]. */ @@ -210,14 +237,15 @@ private fun resolveCollapsedHeight(placeables: List): Int { public interface CollapsingTopBarScope { /** - * Registers a progress listener to be notified every time top bar collapse height changes. + * 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 listener The listener that gets notified of every collapse progress update. - * - * @see CollapsingTopBarProgressListener + * @param alignment the alignment of the element inside the top bar. */ - public fun Modifier.progress(listener: CollapsingTopBarProgressListener): Modifier + public fun Modifier.align(alignment: Alignment): Modifier /** * Position the element dynamically while collapsing by offsetting up by [ratio] as a fraction @@ -233,6 +261,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,8 +284,8 @@ public interface CollapsingTopBarScope { private object CollapsingTopBarScopeInstance : CollapsingTopBarScope { - override fun Modifier.progress(listener: CollapsingTopBarProgressListener): Modifier { - return then(ProgressListenerModifier(listener)) + override fun Modifier.align(alignment: Alignment): Modifier { + return then(AlignmentModifier(alignment)) } override fun Modifier.parallax(ratio: Float): Modifier { @@ -258,6 +296,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,11 +307,11 @@ private object CollapsingTopBarScopeInstance : CollapsingTopBarScope { } } -private class ProgressListenerModifier( - private val listener: CollapsingTopBarProgressListener, +private class AlignmentModifier( + private val alignment: Alignment, ) : CollapsingTopBarParentDataModifier() { override fun modifyParentData(parentData: CollapsingTopBarParentData) { - parentData.progressListener = listener + parentData.alignment = alignment } } @@ -287,6 +329,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 +357,10 @@ private abstract class CollapsingTopBarParentDataModifier : ParentDataModifier { } private data class CollapsingTopBarParentData( - var progressListener: CollapsingTopBarProgressListener? = null, + var alignment: Alignment? = null, var parallaxRatio: Float? = null, var isFloating: Boolean = false, + var progressListener: CollapsingTopBarProgressListener? = null, var nestedCollapseElement: CollapsingTopBarNestedCollapseElement? = null, ) diff --git a/README.md b/README.md index d38863d..abb7ad4 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,44 @@ 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`. + +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) + .floating(), + text = "Nebula", + ) + }, + body = { + SampleContent() + }, +) +``` + +> 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. + +
+
Parallax 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 = {}, + ) + } +}