From cbf91c7dc88bc61816a047b9dc9dc395fdd13727 Mon Sep 17 00:00:00 2001 From: Sergey Velesko Date: Tue, 16 Dec 2025 10:16:07 +0300 Subject: [PATCH 1/3] initial --- .../collapsing/CollapsingNavBarPreview.kt | 23 + .../sdds/compose/uikit/CollapsingNavBar.kt | 1021 +++++++++++++++++ 2 files changed, 1044 insertions(+) create mode 100644 playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt create mode 100644 sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt diff --git a/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt b/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt new file mode 100644 index 000000000..d17896214 --- /dev/null +++ b/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt @@ -0,0 +1,23 @@ +package com.sdds.playground.sandbox.navigationbar.collapsing + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.sdds.compose.uikit.MediumTopAppBar +import com.sdds.compose.uikit.Text + +@Composable +@Preview(showBackground = true) +fun CollapsingNavBarPreview() { + Column { + MediumTopAppBar( + title = { Text(text = "Title")} + ) + LazyColumn { + items(20) { + Text(text = "Label text $it") + } + } + } +} \ No newline at end of file diff --git a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt new file mode 100644 index 000000000..a33691dd7 --- /dev/null +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt @@ -0,0 +1,1021 @@ +package com.sdds.compose.uikit + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDecay +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirst +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt + +@Composable +fun MediumTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.mediumTopAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null +) { + TwoRowsTopAppBar( + modifier = modifier, + title = title, + titleTextStyle = TextStyle(), //todo + smallTitleTextStyle = TextStyle(), //todo + titleBottomPadding = MediumTitleBottomPadding, + smallTitle = title, + navigationIcon = navigationIcon, + actions = actions, + colors = colors, + windowInsets = windowInsets, + maxHeight = 500.dp, + pinnedHeight = 64.dp, + scrollBehavior = scrollBehavior + ) +} + +/** + * A TopAppBarScrollBehavior defines how an app bar should behave when the content under it is + * scrolled. + * + * @see [TopAppBarDefaults.pinnedScrollBehavior] + * @see [TopAppBarDefaults.enterAlwaysScrollBehavior] + * @see [TopAppBarDefaults.exitUntilCollapsedScrollBehavior] + */ +@Stable +interface TopAppBarScrollBehavior { + + /** + * A [TopAppBarState] that is attached to this behavior and is read and updated when scrolling + * happens. + */ + val state: TopAppBarState + + /** + * Indicates whether the top app bar is pinned. + * + * A pinned app bar will stay fixed in place when content is scrolled and will not react to any + * drag gestures. + */ + val isPinned: Boolean + + /** + * An optional [AnimationSpec] that defines how the top app bar snaps to either fully collapsed + * or fully extended state when a fling or a drag scrolled it into an intermediate position. + */ + val snapAnimationSpec: AnimationSpec? + + /** + * An optional [DecayAnimationSpec] that defined how to fling the top app bar when the user + * flings the app bar itself, or the content below it. + */ + val flingAnimationSpec: DecayAnimationSpec? + + /** + * A [NestedScrollConnection] that should be attached to a [Modifier.nestedScroll] in order to + * keep track of the scroll events. + */ + val nestedScrollConnection: NestedScrollConnection +} + +/** Contains default values used for the top app bar implementations. */ + +object TopAppBarDefaults { + + /** + * Default insets to be used and consumed by the top app bars + */ + val windowInsets: WindowInsets + @Composable + get() = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + + /** + * Creates a [TopAppBarColors] for [MediumTopAppBar]s. The default implementation interpolates + * between the provided colors as the top app bar scrolls according to the Material Design + * specification. + */ + @Composable + fun mediumTopAppBarColors() = TopAppBarColors( + containerColor = Color.DarkGray, + scrolledContainerColor = Color.LightGray, + navigationIconContentColor = Color.Black, + titleContentColor = Color.Black, + actionIconContentColor = Color.Black, + ) + + /** + * Returns a pinned [TopAppBarScrollBehavior] that tracks nested-scroll callbacks and + * updates its [TopAppBarState.contentOffset] accordingly. + * + * @param state the state object to be used to control or observe the top app bar's scroll + * state. See [rememberTopAppBarState] for a state that is remembered across compositions. + * @param canScroll a callback used to determine whether scroll events are to be handled by this + * pinned [TopAppBarScrollBehavior] + */ + @Composable + fun pinnedScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true } + ): TopAppBarScrollBehavior = PinnedScrollBehavior(state = state, canScroll = canScroll) + + /** + * Returns a [TopAppBarScrollBehavior]. A top app bar that is set up with this + * [TopAppBarScrollBehavior] will immediately collapse when the content is pulled up, and will + * immediately appear when the content is pulled down. + * + * @param state the state object to be used to control or observe the top app bar's scroll + * state. See [rememberTopAppBarState] for a state that is remembered across compositions. + * @param canScroll a callback used to determine whether scroll events are to be + * handled by this [EnterAlwaysScrollBehavior] + * @param snapAnimationSpec an optional [AnimationSpec] that defines how the top app bar snaps + * to either fully collapsed or fully extended state when a fling or a drag scrolled it into an + * intermediate position + * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top + * app bar when the user flings the app bar itself, or the content below it + */ + + @Composable + fun enterAlwaysScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true }, + snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay() + ): TopAppBarScrollBehavior = + EnterAlwaysScrollBehavior( + state = state, + snapAnimationSpec = snapAnimationSpec, + flingAnimationSpec = flingAnimationSpec, + canScroll = canScroll + ) + + /** + * Returns a [TopAppBarScrollBehavior] that adjusts its properties to affect the colors and + * height of the top app bar. + * + * A top app bar that is set up with this [TopAppBarScrollBehavior] will immediately collapse + * when the nested content is pulled up, and will expand back the collapsed area when the + * content is pulled all the way down. + * + * @param state the state object to be used to control or observe the top app bar's scroll + * state. See [rememberTopAppBarState] for a state that is remembered across compositions. + * @param canScroll a callback used to determine whether scroll events are to be + * handled by this [ExitUntilCollapsedScrollBehavior] + * @param snapAnimationSpec an optional [AnimationSpec] that defines how the top app bar snaps + * to either fully collapsed or fully extended state when a fling or a drag scrolled it into an + * intermediate position + * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top + * app bar when the user flings the app bar itself, or the content below it + */ + + @Composable + fun exitUntilCollapsedScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true }, + snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay() + ): TopAppBarScrollBehavior = + ExitUntilCollapsedScrollBehavior( + state = state, + snapAnimationSpec = snapAnimationSpec, + flingAnimationSpec = flingAnimationSpec, + canScroll = canScroll + ) +} + +/** + * Creates a [TopAppBarState] that is remembered across compositions. + * + * @param initialHeightOffsetLimit the initial value for [TopAppBarState.heightOffsetLimit], + * which represents the pixel limit that a top app bar is allowed to collapse when the scrollable + * content is scrolled + * @param initialHeightOffset the initial value for [TopAppBarState.heightOffset]. The initial + * offset height offset should be between zero and [initialHeightOffsetLimit]. + * @param initialContentOffset the initial value for [TopAppBarState.contentOffset] + */ + +@Composable +fun rememberTopAppBarState( + initialHeightOffsetLimit: Float = -Float.MAX_VALUE, + initialHeightOffset: Float = 0f, + initialContentOffset: Float = 0f +): TopAppBarState { + return rememberSaveable(saver = TopAppBarState.Saver) { + TopAppBarState( + initialHeightOffsetLimit, + initialHeightOffset, + initialContentOffset + ) + } +} + +/** + * A state object that can be hoisted to control and observe the top app bar state. The state is + * read and updated by a [TopAppBarScrollBehavior] implementation. + * + * In most cases, this state will be created via [rememberTopAppBarState]. + * + * @param initialHeightOffsetLimit the initial value for [TopAppBarState.heightOffsetLimit] + * @param initialHeightOffset the initial value for [TopAppBarState.heightOffset] + * @param initialContentOffset the initial value for [TopAppBarState.contentOffset] + */ + +@Stable +class TopAppBarState( + initialHeightOffsetLimit: Float, + initialHeightOffset: Float, + initialContentOffset: Float +) { + + /** + * The top app bar's height offset limit in pixels, which represents the limit that a top app + * bar is allowed to collapse to. + * + * Use this limit to coerce the [heightOffset] value when it's updated. + */ + var heightOffsetLimit by mutableFloatStateOf(initialHeightOffsetLimit) + + /** + * The top app bar's current height offset in pixels. This height offset is applied to the fixed + * height of the app bar to control the displayed height when content is being scrolled. + * + * Updates to the [heightOffset] value are coerced between zero and [heightOffsetLimit]. + */ + var heightOffset: Float + get() = _heightOffset.floatValue + set(newOffset) { + _heightOffset.floatValue = newOffset.coerceIn( + minimumValue = heightOffsetLimit, + maximumValue = 0f + ) + } + + /** + * The total offset of the content scrolled under the top app bar. + * + * The content offset is used to compute the [overlappedFraction], which can later be read + * by an implementation. + * + * This value is updated by a [TopAppBarScrollBehavior] whenever a nested scroll connection + * consumes scroll events. A common implementation would update the value to be the sum of all + * [NestedScrollConnection.onPostScroll] `consumed.y` values. + */ + var contentOffset by mutableFloatStateOf(initialContentOffset) + + /** + * A value that represents the collapsed height percentage of the app bar. + * + * A `0.0` represents a fully expanded bar, and `1.0` represents a fully collapsed bar (computed + * as [heightOffset] / [heightOffsetLimit]). + */ + val collapsedFraction: Float + get() = if (heightOffsetLimit != 0f) { + heightOffset / heightOffsetLimit + } else { + 0f + } + + /** + * A value that represents the percentage of the app bar area that is overlapping with the + * content scrolled behind it. + * + * A `0.0` indicates that the app bar does not overlap any content, while `1.0` indicates that + * the entire visible app bar area overlaps the scrolled content. + */ + val overlappedFraction: Float + get() = if (heightOffsetLimit != 0f) { + 1 - ((heightOffsetLimit - contentOffset).coerceIn( + minimumValue = heightOffsetLimit, + maximumValue = 0f + ) / heightOffsetLimit) + } else { + 0f + } + + companion object { + /** + * The default [Saver] implementation for [TopAppBarState]. + */ + val Saver: Saver = listSaver( + save = { listOf(it.heightOffsetLimit, it.heightOffset, it.contentOffset) }, + restore = { + TopAppBarState( + initialHeightOffsetLimit = it[0], + initialHeightOffset = it[1], + initialContentOffset = it[2] + ) + } + ) + } + + private var _heightOffset = mutableFloatStateOf(initialHeightOffset) +} + +/** + * Represents the colors used by a top app bar in different states. + * This implementation animates the container color according to the top app bar scroll state. It + * does not animate the leading, headline, or trailing colors. + * + * @constructor create an instance with arbitrary colors, see [TopAppBarColors] for a + * factory method using the default material3 spec + * @param containerColor the color used for the background of this BottomAppBar. Use + * [Color.Transparent] to have no color. + * @param scrolledContainerColor the container color when content is scrolled behind it + * @param navigationIconContentColor the content color used for the navigation icon + * @param titleContentColor the content color used for the title + * @param actionIconContentColor the content color used for actions + */ + +@Stable +class TopAppBarColors constructor( + val containerColor: Color, + val scrolledContainerColor: Color, + val navigationIconContentColor: Color, + val titleContentColor: Color, + val actionIconContentColor: Color, +) { + /** + * Returns a copy of this TopAppBarColors, optionally overriding some of the values. + * This uses the Color.Unspecified to mean “use the value from the source” + */ + fun copy( + containerColor: Color = this.containerColor, + scrolledContainerColor: Color = this.scrolledContainerColor, + navigationIconContentColor: Color = this.navigationIconContentColor, + titleContentColor: Color = this.titleContentColor, + actionIconContentColor: Color = this.actionIconContentColor, + ) = TopAppBarColors( + containerColor.takeOrElse { this.containerColor }, + scrolledContainerColor.takeOrElse { this.scrolledContainerColor }, + navigationIconContentColor.takeOrElse { this.navigationIconContentColor }, + titleContentColor.takeOrElse { this.titleContentColor }, + actionIconContentColor.takeOrElse { this.actionIconContentColor }, + ) + + /** + * Represents the container color used for the top app bar. + * + * A [colorTransitionFraction] provides a percentage value that can be used to generate a color. + * Usually, an app bar implementation will pass in a [colorTransitionFraction] read from + * the [TopAppBarState.collapsedFraction] or the [TopAppBarState.overlappedFraction]. + * + * @param colorTransitionFraction a `0.0` to `1.0` value that represents a color transition + * percentage + */ + @Stable + internal fun containerColor(colorTransitionFraction: Float): Color { + return lerp( + containerColor, + scrolledContainerColor, + FastOutLinearInEasing.transform(colorTransitionFraction) + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || other !is TopAppBarColors) return false + + if (containerColor != other.containerColor) return false + if (scrolledContainerColor != other.scrolledContainerColor) return false + if (navigationIconContentColor != other.navigationIconContentColor) return false + if (titleContentColor != other.titleContentColor) return false + if (actionIconContentColor != other.actionIconContentColor) return false + + return true + } + + override fun hashCode(): Int { + var result = containerColor.hashCode() + result = 31 * result + scrolledContainerColor.hashCode() + result = 31 * result + navigationIconContentColor.hashCode() + result = 31 * result + titleContentColor.hashCode() + result = 31 * result + actionIconContentColor.hashCode() + + return result + } +} + +// Padding minus IconButton's min touch target expansion +private val BottomAppBarHorizontalPadding = 16.dp - 12.dp +internal val BottomAppBarVerticalPadding = 16.dp - 12.dp + +// Padding minus content padding +private val FABHorizontalPadding = 16.dp - BottomAppBarHorizontalPadding +private val FABVerticalPadding = 12.dp - BottomAppBarVerticalPadding + +/** + * A two-rows top app bar that is designed to be called by the Large and Medium top app bar + * composables. + * + * @throws [IllegalArgumentException] if the given [maxHeight] is equal or smaller than the + * [pinnedHeight] + */ +@Composable +private fun TwoRowsTopAppBar( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + titleTextStyle: TextStyle, + titleBottomPadding: Dp, + smallTitle: @Composable () -> Unit, + smallTitleTextStyle: TextStyle, + navigationIcon: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, + windowInsets: WindowInsets, + colors: TopAppBarColors, + maxHeight: Dp, + pinnedHeight: Dp, + scrollBehavior: TopAppBarScrollBehavior? +) { + if (maxHeight <= pinnedHeight) { + throw IllegalArgumentException( + "A TwoRowsTopAppBar max height should be greater than its pinned height" + ) + } + val pinnedHeightPx: Float + val maxHeightPx: Float + val titleBottomPaddingPx: Int + LocalDensity.current.run { + pinnedHeightPx = pinnedHeight.toPx() + maxHeightPx = maxHeight.toPx() + titleBottomPaddingPx = titleBottomPadding.roundToPx() + } + + // Sets the app bar's height offset limit to hide just the bottom title area and keep top title + // visible when collapsed. + SideEffect { + if (scrollBehavior?.state?.heightOffsetLimit != pinnedHeightPx - maxHeightPx) { + scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx + } + } + + // Obtain the container Color from the TopAppBarColors using the `collapsedFraction`, as the + // bottom part of this TwoRowsTopAppBar changes color at the same rate the app bar expands or + // collapse. + // This will potentially animate or interpolate a transition between the container color and the + // container's scrolled color according to the app bar's scroll state. + val colorTransitionFraction = scrollBehavior?.state?.collapsedFraction ?: 0f + val appBarContainerColor = colors.containerColor(colorTransitionFraction) + + // Wrap the given actions in a Row. + val actionsRow = @Composable { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions + ) + } + val topTitleAlpha = TopTitleAlphaEasing.transform(colorTransitionFraction) + val bottomTitleAlpha = 1f - colorTransitionFraction + // Hide the top row title semantics when its alpha value goes below 0.5 threshold. + // Hide the bottom row title semantics when the top title semantics are active. + val hideTopRowSemantics = colorTransitionFraction < 0.5f + val hideBottomRowSemantics = !hideTopRowSemantics + + // Set up support for resizing the top app bar when vertically dragging the bar itself. + val appBarDragModifier = if (scrollBehavior != null && !scrollBehavior.isPinned) { + Modifier.draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + scrollBehavior.state.heightOffset = scrollBehavior.state.heightOffset + delta + }, + onDragStopped = { velocity -> + settleAppBar( + scrollBehavior.state, + velocity, + scrollBehavior.flingAnimationSpec, + scrollBehavior.snapAnimationSpec + ) + } + ) + } else { + Modifier + } + + Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) { //todo + Column { + TopAppBarLayout( + modifier = Modifier + .windowInsetsPadding(windowInsets) + // clip after padding so we don't show the title over the inset area + .clipToBounds(), + heightPx = pinnedHeightPx, + navigationIconContentColor = + colors.navigationIconContentColor, + titleContentColor = colors.titleContentColor, + actionIconContentColor = + colors.actionIconContentColor, + title = smallTitle, + titleTextStyle = smallTitleTextStyle, + titleAlpha = topTitleAlpha, + titleVerticalArrangement = Arrangement.Center, + titleHorizontalArrangement = Arrangement.Start, + titleBottomPadding = 0, + hideTitleSemantics = hideTopRowSemantics, + navigationIcon = navigationIcon, + actions = actionsRow, + ) + TopAppBarLayout( + modifier = Modifier + // only apply the horizontal sides of the window insets padding, since the top + // padding will always be applied by the layout above + .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal)) + .clipToBounds(), + heightPx = maxHeightPx - pinnedHeightPx + (scrollBehavior?.state?.heightOffset + ?: 0f), + navigationIconContentColor = + colors.navigationIconContentColor, + titleContentColor = colors.titleContentColor, + actionIconContentColor = + colors.actionIconContentColor, + title = title, + titleTextStyle = titleTextStyle, + titleAlpha = bottomTitleAlpha, + titleVerticalArrangement = Arrangement.Bottom, + titleHorizontalArrangement = Arrangement.Start, + titleBottomPadding = titleBottomPaddingPx, + hideTitleSemantics = hideBottomRowSemantics, + navigationIcon = {}, + actions = {} + ) + } + } +} + +/** + * The base [Layout] for all top app bars. This function lays out a top app bar navigation icon + * (leading icon), a title (header), and action icons (trailing icons). Note that the navigation and + * the actions are optional. + * + * @param heightPx the total height this layout is capped to + * @param navigationIconContentColor the content color that will be applied via a + * [LocalContentColor] when composing the navigation icon + * @param titleContentColor the color that will be applied via a [LocalContentColor] when composing + * the title + * @param actionIconContentColor the content color that will be applied via a [LocalContentColor] + * when composing the action icons + * @param title the top app bar title (header) + * @param titleTextStyle the title's text style + * @param modifier a [Modifier] + * @param titleAlpha the title's alpha + * @param titleVerticalArrangement the title's vertical arrangement + * @param titleHorizontalArrangement the title's horizontal arrangement + * @param titleBottomPadding the title's bottom padding + * @param hideTitleSemantics hides the title node from the semantic tree. Apply this + * boolean when this layout is part of a [TwoRowsTopAppBar] to hide the title's semantics + * from accessibility services. This is needed to avoid having multiple titles visible to + * accessibility services at the same time, when animating between collapsed / expanded states. + * @param navigationIcon a navigation icon [Composable] + * @param actions actions [Composable] + */ +@Composable +private fun TopAppBarLayout( + modifier: Modifier, + heightPx: Float, + navigationIconContentColor: Color, + titleContentColor: Color, + actionIconContentColor: Color, + title: @Composable () -> Unit, + titleTextStyle: TextStyle, + titleAlpha: Float, + titleVerticalArrangement: Arrangement.Vertical, + titleHorizontalArrangement: Arrangement.Horizontal, + titleBottomPadding: Int, + hideTitleSemantics: Boolean, + navigationIcon: @Composable () -> Unit, + actions: @Composable () -> Unit, +) { + Layout( + { + Box( + Modifier + .layoutId("navigationIcon") + .padding(start = TopAppBarHorizontalPadding) + ) { + CompositionLocalProvider( + LocalTint provides navigationIconContentColor, + content = navigationIcon + ) + } + Box( + Modifier + .layoutId("title") + .padding(horizontal = TopAppBarHorizontalPadding) + .then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier) + .graphicsLayer(alpha = titleAlpha) + ) { + ProvideTextStyle( + color = { titleContentColor }, + value = titleTextStyle, + content = title + ) + } + Box( + Modifier + .layoutId("actionIcons") + .padding(end = TopAppBarHorizontalPadding) + ) { + CompositionLocalProvider( + LocalTint provides actionIconContentColor, + content = actions + ) + } + }, + modifier = modifier + ) { measurables, constraints -> + val navigationIconPlaceable = + measurables.fastFirst { it.layoutId == "navigationIcon" } + .measure(constraints.copy(minWidth = 0)) + val actionIconsPlaceable = + measurables.fastFirst { it.layoutId == "actionIcons" } + .measure(constraints.copy(minWidth = 0)) + + val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) { + constraints.maxWidth + } else { + (constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width) + .coerceAtLeast(0) + } + val titlePlaceable = + measurables.fastFirst { it.layoutId == "title" } + .measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth)) + + // Locate the title's baseline. + val titleBaseline = + if (titlePlaceable[LastBaseline] != AlignmentLine.Unspecified) { + titlePlaceable[LastBaseline] + } else { + 0 + } + + val layoutHeight = if (heightPx.isNaN()) 0 else heightPx.roundToInt() + + layout(constraints.maxWidth, layoutHeight) { + // Navigation icon + navigationIconPlaceable.placeRelative( + x = 0, + y = (layoutHeight - navigationIconPlaceable.height) / 2 + ) + + // Title + titlePlaceable.placeRelative( + x = when (titleHorizontalArrangement) { + Arrangement.Center -> { + var baseX = (constraints.maxWidth - titlePlaceable.width) / 2 + if (baseX < navigationIconPlaceable.width) { + // May happen if the navigation is wider than the actions and the + // title is long. In this case, prioritize showing more of the title by + // offsetting it to the right. + baseX += (navigationIconPlaceable.width - baseX) + } else if (baseX + titlePlaceable.width > + constraints.maxWidth - actionIconsPlaceable.width + ) { + // May happen if the actions are wider than the navigation and the title + // is long. In this case, offset to the left. + baseX += ((constraints.maxWidth - actionIconsPlaceable.width) - + (baseX + titlePlaceable.width)) + } + baseX + } + + Arrangement.End -> + constraints.maxWidth - titlePlaceable.width - actionIconsPlaceable.width + // Arrangement.Start. + // An TopAppBarTitleInset will make sure the title is offset in case the + // navigation icon is missing. + else -> max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width) + }, + y = when (titleVerticalArrangement) { + Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2 + // Apply bottom padding from the title's baseline only when the Arrangement is + // "Bottom". + Arrangement.Bottom -> + if (titleBottomPadding == 0) layoutHeight - titlePlaceable.height + else layoutHeight - titlePlaceable.height - max( + 0, + titleBottomPadding - titlePlaceable.height + titleBaseline + ) + // Arrangement.Top + else -> 0 + } + ) + + // Action icons + actionIconsPlaceable.placeRelative( + x = constraints.maxWidth - actionIconsPlaceable.width, + y = (layoutHeight - actionIconsPlaceable.height) / 2 + ) + } + } +} + +/** + * Returns a [TopAppBarScrollBehavior] that only adjusts its content offset, without adjusting any + * properties that affect the height of a top app bar. + * + * @param state a [TopAppBarState] + * @param canScroll a callback used to determine whether scroll events are to be + * handled by this [PinnedScrollBehavior] + */ + +private class PinnedScrollBehavior( + override val state: TopAppBarState, + val canScroll: () -> Boolean = { true } +) : TopAppBarScrollBehavior { + override val isPinned: Boolean = true + override val snapAnimationSpec: AnimationSpec? = null + override val flingAnimationSpec: DecayAnimationSpec? = null + override var nestedScrollConnection = + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (!canScroll()) return Offset.Zero + if (consumed.y == 0f && available.y > 0f) { + // Reset the total content offset to zero when scrolling all the way down. + // This will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } else { + state.contentOffset += consumed.y + } + return Offset.Zero + } + } +} + +/** + * A [TopAppBarScrollBehavior] that adjusts its properties to affect the colors and height of a top + * app bar. + * + * A top app bar that is set up with this [TopAppBarScrollBehavior] will immediately collapse when + * the nested content is pulled up, and will immediately appear when the content is pulled down. + * + * @param state a [TopAppBarState] + * @param snapAnimationSpec an optional [AnimationSpec] that defines how the top app bar snaps to + * either fully collapsed or fully extended state when a fling or a drag scrolled it into an + * intermediate position + * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top app + * bar when the user flings the app bar itself, or the content below it + * @param canScroll a callback used to determine whether scroll events are to be + * handled by this [EnterAlwaysScrollBehavior] + */ +private class EnterAlwaysScrollBehavior( + override val state: TopAppBarState, + override val snapAnimationSpec: AnimationSpec?, + override val flingAnimationSpec: DecayAnimationSpec?, + val canScroll: () -> Boolean = { true } +) : TopAppBarScrollBehavior { + override val isPinned: Boolean = false + override var nestedScrollConnection = + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!canScroll()) return Offset.Zero + val prevHeightOffset = state.heightOffset + state.heightOffset = state.heightOffset + available.y + return if (prevHeightOffset != state.heightOffset) { + // We're in the middle of top app bar collapse or expand. + // Consume only the scroll on the Y axis. + available.copy(x = 0f) + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { + if (consumed.y == 0f && available.y > 0f) { + // Reset the total content offset to zero when scrolling all the way down. + // This will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } + } + state.heightOffset = state.heightOffset + consumed.y + return Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val superConsumed = super.onPostFling(consumed, available) + return superConsumed + settleAppBar( + state, + available.y, + flingAnimationSpec, + snapAnimationSpec + ) + } + } +} + +/** + * A [TopAppBarScrollBehavior] that adjusts its properties to affect the colors and height of a top + * app bar. + * + * A top app bar that is set up with this [TopAppBarScrollBehavior] will immediately collapse when + * the nested content is pulled up, and will expand back the collapsed area when the content is + * pulled all the way down. + * + * @param state a [TopAppBarState] + * @param snapAnimationSpec an optional [AnimationSpec] that defines how the top app bar snaps to + * either fully collapsed or fully extended state when a fling or a drag scrolled it into an + * intermediate position + * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top app + * bar when the user flings the app bar itself, or the content below it + * @param canScroll a callback used to determine whether scroll events are to be + * handled by this [ExitUntilCollapsedScrollBehavior] + */ + +private class ExitUntilCollapsedScrollBehavior( + override val state: TopAppBarState, + override val snapAnimationSpec: AnimationSpec?, + override val flingAnimationSpec: DecayAnimationSpec?, + val canScroll: () -> Boolean = { true } +) : TopAppBarScrollBehavior { + override val isPinned: Boolean = false + override var nestedScrollConnection = + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Don't intercept if scrolling down. + if (!canScroll() || available.y > 0f) return Offset.Zero + + val prevHeightOffset = state.heightOffset + state.heightOffset = state.heightOffset + available.y + return if (prevHeightOffset != state.heightOffset) { + // We're in the middle of top app bar collapse or expand. + // Consume only the scroll on the Y axis. + available.copy(x = 0f) + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + + if (available.y < 0f || consumed.y < 0f) { + // When scrolling up, just update the state's height offset. + val oldHeightOffset = state.heightOffset + state.heightOffset = state.heightOffset + consumed.y + return Offset(0f, state.heightOffset - oldHeightOffset) + } + + if (consumed.y == 0f && available.y > 0) { + // Reset the total content offset to zero when scrolling all the way down. This + // will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } + + if (available.y > 0f) { + // Adjust the height offset in case the consumed delta Y is less than what was + // recorded as available delta Y in the pre-scroll. + val oldHeightOffset = state.heightOffset + state.heightOffset = state.heightOffset + available.y + return Offset(0f, state.heightOffset - oldHeightOffset) + } + return Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val superConsumed = super.onPostFling(consumed, available) + return superConsumed + settleAppBar( + state, + available.y, + flingAnimationSpec, + snapAnimationSpec + ) + } + } +} + +/** + * Settles the app bar by flinging, in case the given velocity is greater than zero, and snapping + * after the fling settles. + */ + +private suspend fun settleAppBar( + state: TopAppBarState, + velocity: Float, + flingAnimationSpec: DecayAnimationSpec?, + snapAnimationSpec: AnimationSpec? +): Velocity { + // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar, + // and just return Zero Velocity. + // Note that we don't check for 0f due to float precision with the collapsedFraction + // calculation. + if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) { + return Velocity.Zero + } + var remainingVelocity = velocity + // In case there is an initial velocity that was left after a previous user fling, animate to + // continue the motion to expand or collapse the app bar. + if (flingAnimationSpec != null && abs(velocity) > 1f) { + var lastValue = 0f + AnimationState( + initialValue = 0f, + initialVelocity = velocity, + ) + .animateDecay(flingAnimationSpec) { + val delta = value - lastValue + val initialHeightOffset = state.heightOffset + state.heightOffset = initialHeightOffset + delta + val consumed = abs(initialHeightOffset - state.heightOffset) + lastValue = value + remainingVelocity = this.velocity + // avoid rounding errors and stop if anything is unconsumed + if (abs(delta - consumed) > 0.5f) this.cancelAnimation() + } + } + // Snap if animation specs were provided. + if (snapAnimationSpec != null) { + if (state.heightOffset < 0 && + state.heightOffset > state.heightOffsetLimit + ) { + AnimationState(initialValue = state.heightOffset).animateTo( + if (state.collapsedFraction < 0.5f) { + 0f + } else { + state.heightOffsetLimit + }, + animationSpec = snapAnimationSpec + ) { state.heightOffset = value } + } + } + + return Velocity(0f, remainingVelocity) +} + +// An easing function used to compute the alpha value that is applied to the top title part of a +// Medium or Large app bar. +/*@VisibleForTesting*/ +internal val TopTitleAlphaEasing = CubicBezierEasing(.8f, 0f, .8f, .15f) + +private val MediumTitleBottomPadding = 24.dp +private val TopAppBarHorizontalPadding = 4.dp + +// A title inset when the App-Bar is a Medium or Large one. Also used to size a spacer when the +// navigation icon is missing. +private val TopAppBarTitleInset = 16.dp - TopAppBarHorizontalPadding \ No newline at end of file From bc5a0752de4abf501d2419b023f2d694ad55b103 Mon Sep 17 00:00:00 2001 From: raininforest Date: Tue, 16 Dec 2025 16:52:29 +0300 Subject: [PATCH 2/3] create collapsingnavbar core --- .../collapsing/CollapsingNavBarPreview.kt | 44 +- .../sdds/compose/uikit/CollapsingNavBar.kt | 350 +++++------- .../compose/uikit/CollapsingNavBarStyle.kt | 525 ++++++++++++++++++ 3 files changed, 699 insertions(+), 220 deletions(-) create mode 100644 sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt diff --git a/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt b/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt index d17896214..034b7ad53 100644 --- a/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt +++ b/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt @@ -1,22 +1,52 @@ package com.sdds.playground.sandbox.navigationbar.collapsing import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview -import com.sdds.compose.uikit.MediumTopAppBar +import androidx.compose.ui.unit.dp +import com.sdds.compose.uikit.CollapsingNavBar +import com.sdds.compose.uikit.Icon import com.sdds.compose.uikit.Text +import com.sdds.compose.uikit.CollapsingNavBarDefaults +import com.sdds.compose.uikit.rememberTopAppBarState @Composable @Preview(showBackground = true) -fun CollapsingNavBarPreview() { - Column { - MediumTopAppBar( - title = { Text(text = "Title")} +fun CollapsingNavNavBarPreview() { + val scrollBehavior = + CollapsingNavBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Column( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + CollapsingNavBar( + title = { Text(text = "Title") }, + description = { Text(text = "Description") }, + navigationIcon = { + Icon( + painter = painterResource(com.sdds.icons.R.drawable.ic_arrow_left_24), + contentDescription = "" + ) + }, + scrollBehavior = scrollBehavior, + actions = { + Icon( + painter = painterResource(com.sdds.icons.R.drawable.ic_search_24), + contentDescription = "" + ) + Icon( + painter = painterResource(com.sdds.icons.R.drawable.ic_menu_24), + contentDescription = "" + ) + } ) LazyColumn { - items(20) { - Text(text = "Label text $it") + items(100) { + Text(modifier = Modifier.padding(32.dp), text = "Label text $it") } } } diff --git a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt index a33691dd7..67165b575 100644 --- a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt @@ -13,6 +13,8 @@ import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,6 +33,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable @@ -42,49 +45,52 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.layout.AlignmentLine -import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFirst +import com.sdds.compose.uikit.TopAppBarState.Companion.Saver +import com.sdds.compose.uikit.interactions.ValueState +import com.sdds.compose.uikit.interactions.getValue import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt +enum class CollapsingNavBarState : ValueState { + Collapsed +} + @Composable -fun MediumTopAppBar( +fun CollapsingNavBar( title: @Composable () -> Unit, modifier: Modifier = Modifier, + style: CollapsingNavBarStyle = LocalCollapsingNavBarStyle.current, + description: @Composable () -> Unit = {}, navigationIcon: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, - windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, - colors: TopAppBarColors = TopAppBarDefaults.mediumTopAppBarColors(), - scrollBehavior: TopAppBarScrollBehavior? = null + windowInsets: WindowInsets = CollapsingNavBarDefaults.windowInsets, + scrollBehavior: TopAppBarScrollBehavior? = null, + interactionSource: InteractionSource = remember { MutableInteractionSource() }, ) { - TwoRowsTopAppBar( + BaseCollapsingNavBar( modifier = modifier, + style = style, title = title, - titleTextStyle = TextStyle(), //todo - smallTitleTextStyle = TextStyle(), //todo - titleBottomPadding = MediumTitleBottomPadding, smallTitle = title, + description = description, + smallDescription = description, navigationIcon = navigationIcon, actions = actions, - colors = colors, windowInsets = windowInsets, - maxHeight = 500.dp, - pinnedHeight = 64.dp, - scrollBehavior = scrollBehavior + scrollBehavior = scrollBehavior, + interactionSource = interactionSource, ) } @@ -92,9 +98,9 @@ fun MediumTopAppBar( * A TopAppBarScrollBehavior defines how an app bar should behave when the content under it is * scrolled. * - * @see [TopAppBarDefaults.pinnedScrollBehavior] - * @see [TopAppBarDefaults.enterAlwaysScrollBehavior] - * @see [TopAppBarDefaults.exitUntilCollapsedScrollBehavior] + * @see [CollapsingNavBarDefaults.pinnedScrollBehavior] + * @see [CollapsingNavBarDefaults.enterAlwaysScrollBehavior] + * @see [CollapsingNavBarDefaults.exitUntilCollapsedScrollBehavior] */ @Stable interface TopAppBarScrollBehavior { @@ -134,7 +140,7 @@ interface TopAppBarScrollBehavior { /** Contains default values used for the top app bar implementations. */ -object TopAppBarDefaults { +object CollapsingNavBarDefaults { /** * Default insets to be used and consumed by the top app bars @@ -144,20 +150,6 @@ object TopAppBarDefaults { get() = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - /** - * Creates a [TopAppBarColors] for [MediumTopAppBar]s. The default implementation interpolates - * between the provided colors as the top app bar scrolls according to the Material Design - * specification. - */ - @Composable - fun mediumTopAppBarColors() = TopAppBarColors( - containerColor = Color.DarkGray, - scrolledContainerColor = Color.LightGray, - navigationIconContentColor = Color.Black, - titleContentColor = Color.Black, - actionIconContentColor = Color.Black, - ) - /** * Returns a pinned [TopAppBarScrollBehavior] that tracks nested-scroll callbacks and * updates its [TopAppBarState.contentOffset] accordingly. @@ -188,7 +180,7 @@ object TopAppBarDefaults { * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top * app bar when the user flings the app bar itself, or the content below it */ - + @Composable fun enterAlwaysScrollBehavior( state: TopAppBarState = rememberTopAppBarState(), @@ -221,7 +213,6 @@ object TopAppBarDefaults { * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top * app bar when the user flings the app bar itself, or the content below it */ - @Composable fun exitUntilCollapsedScrollBehavior( state: TopAppBarState = rememberTopAppBarState(), @@ -247,14 +238,13 @@ object TopAppBarDefaults { * offset height offset should be between zero and [initialHeightOffsetLimit]. * @param initialContentOffset the initial value for [TopAppBarState.contentOffset] */ - @Composable fun rememberTopAppBarState( initialHeightOffsetLimit: Float = -Float.MAX_VALUE, initialHeightOffset: Float = 0f, initialContentOffset: Float = 0f ): TopAppBarState { - return rememberSaveable(saver = TopAppBarState.Saver) { + return rememberSaveable(saver = Saver) { TopAppBarState( initialHeightOffsetLimit, initialHeightOffset, @@ -365,133 +355,49 @@ class TopAppBarState( private var _heightOffset = mutableFloatStateOf(initialHeightOffset) } -/** - * Represents the colors used by a top app bar in different states. - * This implementation animates the container color according to the top app bar scroll state. It - * does not animate the leading, headline, or trailing colors. - * - * @constructor create an instance with arbitrary colors, see [TopAppBarColors] for a - * factory method using the default material3 spec - * @param containerColor the color used for the background of this BottomAppBar. Use - * [Color.Transparent] to have no color. - * @param scrolledContainerColor the container color when content is scrolled behind it - * @param navigationIconContentColor the content color used for the navigation icon - * @param titleContentColor the content color used for the title - * @param actionIconContentColor the content color used for actions - */ - @Stable -class TopAppBarColors constructor( - val containerColor: Color, - val scrolledContainerColor: Color, - val navigationIconContentColor: Color, - val titleContentColor: Color, - val actionIconContentColor: Color, -) { - /** - * Returns a copy of this TopAppBarColors, optionally overriding some of the values. - * This uses the Color.Unspecified to mean “use the value from the source” - */ - fun copy( - containerColor: Color = this.containerColor, - scrolledContainerColor: Color = this.scrolledContainerColor, - navigationIconContentColor: Color = this.navigationIconContentColor, - titleContentColor: Color = this.titleContentColor, - actionIconContentColor: Color = this.actionIconContentColor, - ) = TopAppBarColors( - containerColor.takeOrElse { this.containerColor }, - scrolledContainerColor.takeOrElse { this.scrolledContainerColor }, - navigationIconContentColor.takeOrElse { this.navigationIconContentColor }, - titleContentColor.takeOrElse { this.titleContentColor }, - actionIconContentColor.takeOrElse { this.actionIconContentColor }, +internal fun containerColor( + colorTransitionFraction: Float, + containerColor: Color, + scrolledContainerColor: Color, +): Color { + return lerp( + containerColor, + scrolledContainerColor, + FastOutLinearInEasing.transform(colorTransitionFraction) ) - - /** - * Represents the container color used for the top app bar. - * - * A [colorTransitionFraction] provides a percentage value that can be used to generate a color. - * Usually, an app bar implementation will pass in a [colorTransitionFraction] read from - * the [TopAppBarState.collapsedFraction] or the [TopAppBarState.overlappedFraction]. - * - * @param colorTransitionFraction a `0.0` to `1.0` value that represents a color transition - * percentage - */ - @Stable - internal fun containerColor(colorTransitionFraction: Float): Color { - return lerp( - containerColor, - scrolledContainerColor, - FastOutLinearInEasing.transform(colorTransitionFraction) - ) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || other !is TopAppBarColors) return false - - if (containerColor != other.containerColor) return false - if (scrolledContainerColor != other.scrolledContainerColor) return false - if (navigationIconContentColor != other.navigationIconContentColor) return false - if (titleContentColor != other.titleContentColor) return false - if (actionIconContentColor != other.actionIconContentColor) return false - - return true - } - - override fun hashCode(): Int { - var result = containerColor.hashCode() - result = 31 * result + scrolledContainerColor.hashCode() - result = 31 * result + navigationIconContentColor.hashCode() - result = 31 * result + titleContentColor.hashCode() - result = 31 * result + actionIconContentColor.hashCode() - - return result - } } -// Padding minus IconButton's min touch target expansion -private val BottomAppBarHorizontalPadding = 16.dp - 12.dp -internal val BottomAppBarVerticalPadding = 16.dp - 12.dp - -// Padding minus content padding -private val FABHorizontalPadding = 16.dp - BottomAppBarHorizontalPadding -private val FABVerticalPadding = 12.dp - BottomAppBarVerticalPadding - -/** - * A two-rows top app bar that is designed to be called by the Large and Medium top app bar - * composables. - * - * @throws [IllegalArgumentException] if the given [maxHeight] is equal or smaller than the - * [pinnedHeight] - */ @Composable -private fun TwoRowsTopAppBar( +private fun BaseCollapsingNavBar( + style: CollapsingNavBarStyle, modifier: Modifier = Modifier, title: @Composable () -> Unit, - titleTextStyle: TextStyle, - titleBottomPadding: Dp, smallTitle: @Composable () -> Unit, - smallTitleTextStyle: TextStyle, + description: @Composable (() -> Unit), + smallDescription: @Composable (() -> Unit), navigationIcon: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit, windowInsets: WindowInsets, - colors: TopAppBarColors, - maxHeight: Dp, - pinnedHeight: Dp, - scrollBehavior: TopAppBarScrollBehavior? + scrollBehavior: TopAppBarScrollBehavior?, + interactionSource: InteractionSource, ) { + val collapsedStateSet = remember { setOf(CollapsingNavBarState.Collapsed) } + val pinnedHeight = style.dimensions.height.getValue( + interactionSource = interactionSource, + stateSet = collapsedStateSet + ) + val maxHeight = style.dimensions.height.getValue(interactionSource) if (maxHeight <= pinnedHeight) { throw IllegalArgumentException( - "A TwoRowsTopAppBar max height should be greater than its pinned height" + "A BaseCollapsingNavBar max height should be greater than its pinned height" ) } val pinnedHeightPx: Float val maxHeightPx: Float - val titleBottomPaddingPx: Int LocalDensity.current.run { pinnedHeightPx = pinnedHeight.toPx() maxHeightPx = maxHeight.toPx() - titleBottomPaddingPx = titleBottomPadding.roundToPx() } // Sets the app bar's height offset limit to hide just the bottom title area and keep top title @@ -508,7 +414,11 @@ private fun TwoRowsTopAppBar( // This will potentially animate or interpolate a transition between the container color and the // container's scrolled color according to the app bar's scroll state. val colorTransitionFraction = scrollBehavior?.state?.collapsedFraction ?: 0f - val appBarContainerColor = colors.containerColor(colorTransitionFraction) + val background = style.colors.backgroundColor.getValue(interactionSource, emptySet()) + val scrolledBackground = + style.colors.backgroundColor.getValue(interactionSource, collapsedStateSet) + val appBarContainerColor = + containerColor(colorTransitionFraction, background, scrolledBackground) // Wrap the given actions in a Row. val actionsRow = @Composable { @@ -518,8 +428,8 @@ private fun TwoRowsTopAppBar( content = actions ) } - val topTitleAlpha = TopTitleAlphaEasing.transform(colorTransitionFraction) - val bottomTitleAlpha = 1f - colorTransitionFraction + val collapsedAlpha = TopTitleAlphaEasing.transform(colorTransitionFraction) + val expandedAlpha = 1f - colorTransitionFraction // Hide the top row title semantics when its alpha value goes below 0.5 threshold. // Hide the bottom row title semantics when the top title semantics are active. val hideTopRowSemantics = colorTransitionFraction < 0.5f @@ -547,49 +457,64 @@ private fun TwoRowsTopAppBar( Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) { //todo Column { - TopAppBarLayout( - modifier = Modifier + NavBarLayout( + modifier = Modifier //todo применить отступы в закрытом положении .windowInsetsPadding(windowInsets) // clip after padding so we don't show the title over the inset area .clipToBounds(), heightPx = pinnedHeightPx, - navigationIconContentColor = - colors.navigationIconContentColor, - titleContentColor = colors.titleContentColor, - actionIconContentColor = - colors.actionIconContentColor, + navigationIconContentColor = style.colors.backIconColor.getValue( + interactionSource, + collapsedStateSet + ), + titleContentColor = style.colors.titleColor.getValue( + interactionSource, + collapsedStateSet + ), + actionIconContentColor = style.colors.actionStartColor.getValue( + interactionSource, + collapsedStateSet + ), title = smallTitle, - titleTextStyle = smallTitleTextStyle, - titleAlpha = topTitleAlpha, - titleVerticalArrangement = Arrangement.Center, - titleHorizontalArrangement = Arrangement.Start, - titleBottomPadding = 0, + description = smallDescription, + titleTextStyle = style.titleStyle.getValue(interactionSource, collapsedStateSet), + titleAlpha = collapsedAlpha, + textVerticalArrangement = Arrangement.Center, + textHorizontalArrangement = Arrangement.Start,//todo вынести в публичное апи или брать из стиля hideTitleSemantics = hideTopRowSemantics, navigationIcon = navigationIcon, actions = actionsRow, + descriptionContentColor = style.colors.descriptionColor.getValue( + interactionSource, + collapsedStateSet + ), + descriptionTextStyle = style.descriptionStyle.getValue( + interactionSource, + collapsedStateSet + ) ) - TopAppBarLayout( - modifier = Modifier + NavBarLayout( + modifier = Modifier //todo применить отступы в открытом положении // only apply the horizontal sides of the window insets padding, since the top // padding will always be applied by the layout above .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal)) .clipToBounds(), heightPx = maxHeightPx - pinnedHeightPx + (scrollBehavior?.state?.heightOffset ?: 0f), - navigationIconContentColor = - colors.navigationIconContentColor, - titleContentColor = colors.titleContentColor, - actionIconContentColor = - colors.actionIconContentColor, + navigationIconContentColor = style.colors.backIconColor.getValue(interactionSource), + titleContentColor = style.colors.titleColor.getValue(interactionSource), + actionIconContentColor = style.colors.actionStartColor.getValue(interactionSource), title = title, - titleTextStyle = titleTextStyle, - titleAlpha = bottomTitleAlpha, - titleVerticalArrangement = Arrangement.Bottom, - titleHorizontalArrangement = Arrangement.Start, - titleBottomPadding = titleBottomPaddingPx, + description = description, + titleTextStyle = style.titleStyle.getValue(interactionSource), + titleAlpha = expandedAlpha, + textVerticalArrangement = Arrangement.Bottom, + textHorizontalArrangement = Arrangement.Center, //todo hideTitleSemantics = hideBottomRowSemantics, navigationIcon = {}, - actions = {} + actions = {}, + descriptionContentColor = style.colors.descriptionColor.getValue(interactionSource), + descriptionTextStyle = style.descriptionStyle.getValue(interactionSource), ) } } @@ -615,25 +540,27 @@ private fun TwoRowsTopAppBar( * @param titleHorizontalArrangement the title's horizontal arrangement * @param titleBottomPadding the title's bottom padding * @param hideTitleSemantics hides the title node from the semantic tree. Apply this - * boolean when this layout is part of a [TwoRowsTopAppBar] to hide the title's semantics + * boolean when this layout is part of a [BaseCollapsingNavBar] to hide the title's semantics * from accessibility services. This is needed to avoid having multiple titles visible to * accessibility services at the same time, when animating between collapsed / expanded states. * @param navigationIcon a navigation icon [Composable] * @param actions actions [Composable] */ @Composable -private fun TopAppBarLayout( +private fun NavBarLayout( modifier: Modifier, heightPx: Float, navigationIconContentColor: Color, titleContentColor: Color, + descriptionContentColor: Color, actionIconContentColor: Color, title: @Composable () -> Unit, + description: @Composable () -> Unit, titleTextStyle: TextStyle, + descriptionTextStyle: TextStyle, titleAlpha: Float, - titleVerticalArrangement: Arrangement.Vertical, - titleHorizontalArrangement: Arrangement.Horizontal, - titleBottomPadding: Int, + textVerticalArrangement: Arrangement.Vertical, + textHorizontalArrangement: Arrangement.Horizontal, hideTitleSemantics: Boolean, navigationIcon: @Composable () -> Unit, actions: @Composable () -> Unit, @@ -643,17 +570,23 @@ private fun TopAppBarLayout( Box( Modifier .layoutId("navigationIcon") - .padding(start = TopAppBarHorizontalPadding) + .padding(start = TopAppBarHorizontalPadding) //todo ) { CompositionLocalProvider( LocalTint provides navigationIconContentColor, content = navigationIcon ) } - Box( - Modifier - .layoutId("title") - .padding(horizontal = TopAppBarHorizontalPadding) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), //todo + horizontalAlignment = when (textHorizontalArrangement) { + Arrangement.Center -> Alignment.CenterHorizontally + Arrangement.End -> Alignment.End + else -> Alignment.Start + }, + modifier = Modifier + .layoutId("text") + .padding(horizontal = TopAppBarHorizontalPadding) //todo .then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier) .graphicsLayer(alpha = titleAlpha) ) { @@ -662,11 +595,16 @@ private fun TopAppBarLayout( value = titleTextStyle, content = title ) + ProvideTextStyle( + color = { descriptionContentColor }, + value = descriptionTextStyle, + content = description + ) } Box( Modifier .layoutId("actionIcons") - .padding(end = TopAppBarHorizontalPadding) + .padding(end = TopAppBarHorizontalPadding) //todo ) { CompositionLocalProvider( LocalTint provides actionIconContentColor, @@ -683,23 +621,15 @@ private fun TopAppBarLayout( measurables.fastFirst { it.layoutId == "actionIcons" } .measure(constraints.copy(minWidth = 0)) - val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) { + val maxTextWidth = if (constraints.maxWidth == Constraints.Infinity) { constraints.maxWidth } else { (constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width) .coerceAtLeast(0) } - val titlePlaceable = - measurables.fastFirst { it.layoutId == "title" } - .measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth)) - - // Locate the title's baseline. - val titleBaseline = - if (titlePlaceable[LastBaseline] != AlignmentLine.Unspecified) { - titlePlaceable[LastBaseline] - } else { - 0 - } + val textPlaceable = + measurables.fastFirst { it.layoutId == "text" } + .measure(constraints.copy(minWidth = 0, maxWidth = maxTextWidth)) val layoutHeight = if (heightPx.isNaN()) 0 else heightPx.roundToInt() @@ -710,44 +640,39 @@ private fun TopAppBarLayout( y = (layoutHeight - navigationIconPlaceable.height) / 2 ) - // Title - titlePlaceable.placeRelative( - x = when (titleHorizontalArrangement) { + // Title + description + textPlaceable.placeRelative( + x = when (textHorizontalArrangement) { Arrangement.Center -> { - var baseX = (constraints.maxWidth - titlePlaceable.width) / 2 + var baseX = (constraints.maxWidth - textPlaceable.width) / 2 if (baseX < navigationIconPlaceable.width) { // May happen if the navigation is wider than the actions and the // title is long. In this case, prioritize showing more of the title by // offsetting it to the right. baseX += (navigationIconPlaceable.width - baseX) - } else if (baseX + titlePlaceable.width > + } else if (baseX + textPlaceable.width > constraints.maxWidth - actionIconsPlaceable.width ) { // May happen if the actions are wider than the navigation and the title // is long. In this case, offset to the left. baseX += ((constraints.maxWidth - actionIconsPlaceable.width) - - (baseX + titlePlaceable.width)) + (baseX + textPlaceable.width)) } baseX } Arrangement.End -> - constraints.maxWidth - titlePlaceable.width - actionIconsPlaceable.width + constraints.maxWidth - textPlaceable.width - actionIconsPlaceable.width // Arrangement.Start. // An TopAppBarTitleInset will make sure the title is offset in case the // navigation icon is missing. else -> max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width) }, - y = when (titleVerticalArrangement) { - Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2 + y = when (textVerticalArrangement) { + Arrangement.Center -> (layoutHeight - textPlaceable.height) / 2 // Apply bottom padding from the title's baseline only when the Arrangement is // "Bottom". - Arrangement.Bottom -> - if (titleBottomPadding == 0) layoutHeight - titlePlaceable.height - else layoutHeight - titlePlaceable.height - max( - 0, - titleBottomPadding - titlePlaceable.height + titleBaseline - ) + Arrangement.Bottom -> layoutHeight - textPlaceable.height // Arrangement.Top else -> 0 } @@ -1013,9 +938,8 @@ private suspend fun settleAppBar( /*@VisibleForTesting*/ internal val TopTitleAlphaEasing = CubicBezierEasing(.8f, 0f, .8f, .15f) -private val MediumTitleBottomPadding = 24.dp -private val TopAppBarHorizontalPadding = 4.dp +private val TopAppBarHorizontalPadding = 4.dp //todo remove // A title inset when the App-Bar is a Medium or Large one. Also used to size a spacer when the // navigation icon is missing. -private val TopAppBarTitleInset = 16.dp - TopAppBarHorizontalPadding \ No newline at end of file +private val TopAppBarTitleInset = 16.dp - TopAppBarHorizontalPadding //todo remove \ No newline at end of file diff --git a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt new file mode 100644 index 000000000..4ea33f955 --- /dev/null +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt @@ -0,0 +1,525 @@ +package com.sdds.compose.uikit + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.sdds.compose.uikit.interactions.StatefulValue +import com.sdds.compose.uikit.interactions.asStatefulValue +import com.sdds.compose.uikit.shadow.ShadowAppearance +import com.sdds.compose.uikit.style.Style +import com.sdds.compose.uikit.style.StyleBuilder + +/** + * CompositionLocal c [CollapsingNavBarStyle] для компонента [CollapsingNavBar] + */ +val LocalCollapsingNavBarStyle = compositionLocalOf { CollapsingNavBarStyle.builder().style() } + +/** + * Стиль компонента CollapsingNavBar + */ +@Immutable +interface CollapsingNavBarStyle : Style { + + /** + * Иконка кнопки назад + */ + @get:DrawableRes + val backIcon: Int? + + /** + * Форма нижних углов компонента + */ + val bottomShape: CornerBasedShape + + /** + * Стиль текста по умолчанию + */ + val titleStyle: StatefulValue + val descriptionStyle: StatefulValue + + /** + * Тень компонента + */ + val shadow: ShadowAppearance + + /** + * Размеры и отступы компонента + */ + val dimensions: CollapsingNavBarDimensions + + /** + * Цвета компонента + */ + val colors: CollapsingNavBarColors + + /** + * Стиль кнопки действия + */ + val actionButtonStyle: ButtonStyle? + + companion object { + /** + * Возвращает экземпляр [CollapsingNavBarStyleBuilder] + */ + fun builder(receiver: Any? = null): CollapsingNavBarStyleBuilder = + DefaultCollapsingNavBarStyle.Builder() + } +} + +/** + * Билдер стиля [CollapsingNavBarStyle] + */ +interface CollapsingNavBarStyleBuilder : StyleBuilder { + + fun titleStyle(titleStyle: StatefulValue): CollapsingNavBarStyleBuilder + + fun descriptionStyle(descriptionStyle: StatefulValue): CollapsingNavBarStyleBuilder + + /** + * Устанавливает иконку кнопки назад [backIcon] + */ + fun backIcon(backIcon: Int?): CollapsingNavBarStyleBuilder + + /** + * Устанавливает форму нижних углов [bottomShape] + */ + fun bottomShape(bottomShape: CornerBasedShape): CollapsingNavBarStyleBuilder + + /** + * Устанавливает тень [shadow] компонента + */ + fun shadow(shadow: ShadowAppearance): CollapsingNavBarStyleBuilder + + /** + * Устанавливает цвета компонента при помощи [builder]. + */ + @Composable + fun colors(builder: @Composable CollapsingNavBarColorsBuilder.() -> Unit): CollapsingNavBarStyleBuilder + + /** + * Устанавливает размеры и отступы компонента при помощи [builder]. + */ + @Composable + fun dimensions(builder: @Composable CollapsingNavBarDimensionsBuilder.() -> Unit): CollapsingNavBarStyleBuilder + + /** + * Устанавливает стиль кнопок действия + */ + fun actionButtonStyle(actionButtonStyle: ButtonStyle): CollapsingNavBarStyleBuilder +} + +private class DefaultCollapsingNavBarStyle( + override val backIcon: Int?, + override val shadow: ShadowAppearance, + override val dimensions: CollapsingNavBarDimensions, + override val colors: CollapsingNavBarColors, + override val bottomShape: CornerBasedShape, + override val actionButtonStyle: ButtonStyle?, + override val titleStyle: StatefulValue, + override val descriptionStyle: StatefulValue, +) : CollapsingNavBarStyle { + + class Builder : CollapsingNavBarStyleBuilder { + + private var backIcon: Int? = null + private var bottomShape: CornerBasedShape? = null + private var shadow: ShadowAppearance? = null + private val colorsBuilder = CollapsingNavBarColors.builder() + private val dimensionsBuilder = CollapsingNavBarDimensions.builder() + private var titleStyle: StatefulValue? = null + private var descriptionStyle: StatefulValue? = null + private var actionButtonStyle: ButtonStyle? = null + + + override fun titleStyle(titleStyle: StatefulValue) = apply { + this.titleStyle = titleStyle + } + + override fun descriptionStyle(descriptionStyle: StatefulValue) = apply { + this.descriptionStyle = descriptionStyle + } + + override fun backIcon(backIcon: Int?) = apply { + this.backIcon = backIcon + } + + override fun bottomShape(bottomShape: CornerBasedShape) = apply { + this.bottomShape = bottomShape + } + + override fun shadow(shadow: ShadowAppearance) = apply { + this.shadow = shadow + } + + @Composable + override fun colors(builder: @Composable (CollapsingNavBarColorsBuilder.() -> Unit)) = apply { + this.colorsBuilder.builder() + } + + @Composable + override fun dimensions(builder: @Composable (CollapsingNavBarDimensionsBuilder.() -> Unit)) = + apply { + this.dimensionsBuilder.builder() + } + + override fun actionButtonStyle(actionButtonStyle: ButtonStyle) = apply { + this.actionButtonStyle = actionButtonStyle + } + + override fun style(): CollapsingNavBarStyle { + return DefaultCollapsingNavBarStyle( + backIcon = backIcon, + colors = colorsBuilder.build(), + dimensions = dimensionsBuilder.build(), + shadow = shadow ?: ShadowAppearance(), + bottomShape = bottomShape ?: RoundedCornerShape(ZeroCornerSize), + actionButtonStyle = actionButtonStyle, + titleStyle = titleStyle ?: TextStyle().asStatefulValue(), + descriptionStyle = descriptionStyle ?: TextStyle().asStatefulValue(), + ) + } + } +} + +/** + * Цвета компонента CollapsingNavBar + */ +@Immutable +interface CollapsingNavBarColors { + + /** + * Цвет фона + */ + val backgroundColor: StatefulValue + + /** + * Цвет кнопки закрытия + */ + val backIconColor: StatefulValue + + /** + * Цвет экшена в начале + */ + val actionStartColor: StatefulValue + + /** + * Цвет экшена в конце + */ + val actionEndColor: StatefulValue + + /** + * Цвет текста по умолчанию + */ + val titleColor: StatefulValue + val descriptionColor: StatefulValue + + companion object { + + /** + * Создает экземпляр [CollapsingNavBarColorsBuilder] + */ + fun builder(): CollapsingNavBarColorsBuilder = DefaultCollapsingNavBarColors.Builder() + } +} + +/** + * Builder для [CollapsingNavBarColors] + */ +interface CollapsingNavBarColorsBuilder { + /** + * Устанавливает фон [backgroundColor] компонента. + */ + fun backgroundColor(backgroundColor: Color): CollapsingNavBarColorsBuilder = + backgroundColor(backgroundColor.asStatefulValue()) + + /** + * Устанавливает фон [backgroundColor] компонента. + */ + fun backgroundColor(backgroundColor: StatefulValue): CollapsingNavBarColorsBuilder + + /** + * Устанавливает цвет кнопки закрытия [backIconColor]. + */ + fun backIconColor(backIconColor: Color): CollapsingNavBarColorsBuilder = + backIconColor(backIconColor.asStatefulValue()) + + /** + * Устанавливает цвет кнопки закрытия [backIconColor]. + */ + fun backIconColor(backIconColor: StatefulValue): CollapsingNavBarColorsBuilder + + /** + * Устанавливает цвет экшена в начале [actionStartColor]. + */ + fun actionStartColor(actionStartColor: Color): CollapsingNavBarColorsBuilder = + actionStartColor(actionStartColor.asStatefulValue()) + + /** + * Устанавливает цвет экшена в начале [actionStartColor]. + */ + fun actionStartColor(actionStartColor: StatefulValue): CollapsingNavBarColorsBuilder + + /** + * Устанавливает цвет экшена в конце [actionEndColor]. + */ + fun actionEndColor(actionEndColor: Color): CollapsingNavBarColorsBuilder = + actionEndColor(actionEndColor.asStatefulValue()) + + /** + * Устанавливает цвет экшена в конце [actionEndColor]. + */ + fun actionEndColor(actionEndColor: StatefulValue): CollapsingNavBarColorsBuilder + + /** + * Устанавливает цвет текста по умолчанию [titleColor]. + */ + fun titleColor(titleColor: Color): CollapsingNavBarColorsBuilder = + titleColor(titleColor.asStatefulValue()) + + /** + * Устанавливает цвет текста по умолчанию [titleColor]. + */ + fun titleColor(titleColor: StatefulValue): CollapsingNavBarColorsBuilder + + /** + * Устанавливает цвет текста по умолчанию [descriptionColor]. + */ + fun descriptionColor(descriptionColor: Color): CollapsingNavBarColorsBuilder = + descriptionColor(descriptionColor.asStatefulValue()) + + /** + * Устанавливает цвет текста по умолчанию [descriptionColor]. + */ + fun descriptionColor(descriptionColor: StatefulValue): CollapsingNavBarColorsBuilder + + /** + * Создает экземпляр [CollapsingNavBarColors] + */ + fun build(): CollapsingNavBarColors +} + +@Immutable +private data class DefaultCollapsingNavBarColors( + override val backgroundColor: StatefulValue, + override val backIconColor: StatefulValue, + override val actionStartColor: StatefulValue, + override val actionEndColor: StatefulValue, + override val titleColor: StatefulValue, + override val descriptionColor: StatefulValue, +) : CollapsingNavBarColors { + + class Builder : CollapsingNavBarColorsBuilder { + private var backgroundColor: StatefulValue? = null + private var backIconColor: StatefulValue? = null + private var actionStartColor: StatefulValue? = null + private var actionEndColor: StatefulValue? = null + private var titleColor: StatefulValue? = null + private var descriptionColor: StatefulValue? = null + + override fun backgroundColor(backgroundColor: StatefulValue) = apply { + this.backgroundColor = backgroundColor + } + + override fun backIconColor(backIconColor: StatefulValue) = apply { + this.backIconColor = backIconColor + } + + override fun actionStartColor(actionStartColor: StatefulValue) = apply { + this.actionStartColor = actionStartColor + } + + override fun actionEndColor(actionEndColor: StatefulValue) = apply { + this.actionEndColor = actionEndColor + } + + override fun titleColor(titleColor: StatefulValue) = apply { + this.titleColor = titleColor + } + + override fun descriptionColor(descriptionColor: StatefulValue) = apply { + this.descriptionColor = descriptionColor + } + + override fun build(): CollapsingNavBarColors { + return DefaultCollapsingNavBarColors( + backgroundColor = backgroundColor ?: Color.LightGray.asStatefulValue( + setOf(CollapsingNavBarState.Collapsed) to Color.Gray.copy(0.6f) + ), + backIconColor = backIconColor ?: Color.Black.asStatefulValue(), + actionStartColor = actionStartColor ?: Color.Black.asStatefulValue(), + actionEndColor = actionEndColor ?: Color.Black.asStatefulValue(), + titleColor = titleColor ?: Color.Black.asStatefulValue(), + descriptionColor = descriptionColor ?: Color.DarkGray.asStatefulValue(), + ) + } + } +} + +/** + * Размеры и отступы компонента + */ +@Immutable +interface CollapsingNavBarDimensions { + + val height: StatefulValue + + /** + * Отступ иконки назад + */ + val backIconMargin: Dp + + /** + * Горизонтальный отступ между элементами основного блока + */ + val horizontalSpacing: Dp + + /** + * Отступ в начале + */ + val paddingStart: StatefulValue + + /** + * Отступ в конце + */ + val paddingEnd: StatefulValue + + /** + * Отступ сверху + */ + val paddingTop: StatefulValue + + /** + * Отступ снизу + */ + val paddingBottom: StatefulValue + + companion object { + /** + * Создает экземпляр [CollapsingNavBarDimensionsBuilder] + */ + fun builder(): CollapsingNavBarDimensionsBuilder = DefaultCollapsingNavBarDimensions.Builder() + } +} + +/** + * Builder для [CollapsingNavBarDimensions] + */ +interface CollapsingNavBarDimensionsBuilder { + /** + * Устанавливает отступ иконки назад [backIconMargin] + */ + fun backIconMargin(backIconMargin: Dp): CollapsingNavBarDimensionsBuilder + + /** + * Устанавливает отступ сверху от внешнего (не встроенного) текстового блока [textBlockTopMargin] + */ + fun textBlockTopMargin(textBlockTopMargin: Dp): CollapsingNavBarDimensionsBuilder + + /** + * Устанавливает горизонтальный отступ между элементами основного блока [horizontalSpacing] + */ + fun horizontalSpacing(horizontalSpacing: Dp): CollapsingNavBarDimensionsBuilder + + /** + * Устанавливает отступ в начале [paddingStart] + */ + fun paddingStart(paddingStart: StatefulValue): CollapsingNavBarDimensionsBuilder + + /** + * Устанавливает отступ в конце [paddingEnd] + */ + fun paddingEnd(paddingEnd: StatefulValue): CollapsingNavBarDimensionsBuilder + + /** + * Устанавливает отступ сверху [paddingTop] + */ + fun paddingTop(paddingTop: StatefulValue): CollapsingNavBarDimensionsBuilder + + /** + * Устанавливает отступ снизу [paddingBottom] + */ + fun paddingBottom(paddingBottom: StatefulValue): CollapsingNavBarDimensionsBuilder + fun height(height: StatefulValue): CollapsingNavBarDimensionsBuilder + + /** + * Создает экземпляр [CollapsingNavBarDimensions] + */ + fun build(): CollapsingNavBarDimensions +} + +private class DefaultCollapsingNavBarDimensions( + override val paddingStart: StatefulValue, + override val paddingEnd: StatefulValue, + override val paddingTop: StatefulValue, + override val paddingBottom: StatefulValue, + override val backIconMargin: Dp, + override val horizontalSpacing: Dp, + override val height: StatefulValue, +) : CollapsingNavBarDimensions { + + class Builder : CollapsingNavBarDimensionsBuilder { + + private var backIconMargin: Dp? = null + private var height: StatefulValue? = null + private var horizontalSpacing: Dp? = null + private var textBlockTopMargin: Dp? = null + private var paddingStart: StatefulValue? = null + private var paddingEnd: StatefulValue? = null + private var paddingTop: StatefulValue? = null + private var paddingBottom: StatefulValue? = null + + override fun backIconMargin(backIconMargin: Dp) = apply { + this.backIconMargin = backIconMargin + } + + override fun textBlockTopMargin(textBlockTopMargin: Dp) = apply { + this.textBlockTopMargin = textBlockTopMargin + } + + override fun horizontalSpacing(horizontalSpacing: Dp) = apply { + this.horizontalSpacing = horizontalSpacing + } + + override fun paddingStart(paddingStart: StatefulValue) = apply { + this.paddingStart = paddingStart + } + + override fun paddingEnd(paddingEnd: StatefulValue) = apply { + this.paddingEnd = paddingEnd + } + + override fun paddingTop(paddingTop: StatefulValue) = apply { + this.paddingTop = paddingTop + } + + override fun paddingBottom(paddingBottom: StatefulValue) = apply { + this.paddingBottom = paddingBottom + } + + override fun height(height: StatefulValue) = apply { + this.height = height + } + + override fun build(): CollapsingNavBarDimensions { + return DefaultCollapsingNavBarDimensions( + paddingStart = paddingStart ?: 16.dp.asStatefulValue(), + paddingEnd = paddingEnd ?: 16.dp.asStatefulValue(), + paddingTop = paddingTop ?: 16.dp.asStatefulValue(), + paddingBottom = paddingBottom ?: 16.dp.asStatefulValue(), + backIconMargin = backIconMargin ?: 4.dp, + horizontalSpacing = horizontalSpacing ?: 16.dp, + height = height ?: 256.dp.asStatefulValue( + setOf(CollapsingNavBarState.Collapsed) to 64.dp + ), + ) + } + } +} From c05e9813077892cbe88e4f1e4442de647e6ab847 Mon Sep 17 00:00:00 2001 From: raininforest Date: Wed, 17 Dec 2025 10:02:38 +0300 Subject: [PATCH 3/3] add expanded content and clip shape --- .../collapsing/CollapsingNavBarPreview.kt | 14 ++ .../sdds/compose/uikit/CollapsingNavBar.kt | 162 +++++++++++------- .../compose/uikit/CollapsingNavBarStyle.kt | 14 +- .../com/sdds/compose/uikit/NavigationBar.kt | 4 +- 4 files changed, 121 insertions(+), 73 deletions(-) diff --git a/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt b/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt index 034b7ad53..695308ee2 100644 --- a/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt +++ b/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt @@ -5,10 +5,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.sdds.compose.uikit.CollapsingNavBar import com.sdds.compose.uikit.Icon import com.sdds.compose.uikit.Text @@ -42,6 +45,17 @@ fun CollapsingNavNavBarPreview() { painter = painterResource(com.sdds.icons.R.drawable.ic_menu_24), contentDescription = "" ) + }, + expandedContent = { + AsyncImage( + modifier = Modifier + .matchParentSize() + .graphicsLayer(alpha = 0.6f) + , + contentScale = ContentScale.Crop, + model = "https://cdn.costumewall.com/wp-content/uploads/2018/09/michael-scott.jpg", + contentDescription = "AsyncAvatar", + ) } ) LazyColumn { diff --git a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt index 67165b575..ad77e7b1d 100644 --- a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.spring import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -17,6 +18,7 @@ import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -26,7 +28,6 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect @@ -40,6 +41,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -75,6 +77,7 @@ fun CollapsingNavBar( description: @Composable () -> Unit = {}, navigationIcon: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, + expandedContent: @Composable BoxScope.() -> Unit = {}, //todo поддержать фоновый контент в раскрытом состоянии windowInsets: WindowInsets = CollapsingNavBarDefaults.windowInsets, scrollBehavior: TopAppBarScrollBehavior? = null, interactionSource: InteractionSource = remember { MutableInteractionSource() }, @@ -86,6 +89,7 @@ fun CollapsingNavBar( smallTitle = title, description = description, smallDescription = description, + expandedContent = expandedContent, navigationIcon = navigationIcon, actions = actions, windowInsets = windowInsets, @@ -378,6 +382,7 @@ private fun BaseCollapsingNavBar( smallDescription: @Composable (() -> Unit), navigationIcon: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit, + expandedContent: @Composable (BoxScope.() -> Unit), windowInsets: WindowInsets, scrollBehavior: TopAppBarScrollBehavior?, interactionSource: InteractionSource, @@ -455,68 +460,73 @@ private fun BaseCollapsingNavBar( Modifier } - Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) { //todo - Column { - NavBarLayout( - modifier = Modifier //todo применить отступы в закрытом положении - .windowInsetsPadding(windowInsets) - // clip after padding so we don't show the title over the inset area - .clipToBounds(), - heightPx = pinnedHeightPx, - navigationIconContentColor = style.colors.backIconColor.getValue( - interactionSource, - collapsedStateSet - ), - titleContentColor = style.colors.titleColor.getValue( - interactionSource, - collapsedStateSet - ), - actionIconContentColor = style.colors.actionStartColor.getValue( - interactionSource, - collapsedStateSet - ), - title = smallTitle, - description = smallDescription, - titleTextStyle = style.titleStyle.getValue(interactionSource, collapsedStateSet), - titleAlpha = collapsedAlpha, - textVerticalArrangement = Arrangement.Center, - textHorizontalArrangement = Arrangement.Start,//todo вынести в публичное апи или брать из стиля - hideTitleSemantics = hideTopRowSemantics, - navigationIcon = navigationIcon, - actions = actionsRow, - descriptionContentColor = style.colors.descriptionColor.getValue( - interactionSource, - collapsedStateSet - ), - descriptionTextStyle = style.descriptionStyle.getValue( - interactionSource, - collapsedStateSet - ) - ) - NavBarLayout( - modifier = Modifier //todo применить отступы в открытом положении - // only apply the horizontal sides of the window insets padding, since the top - // padding will always be applied by the layout above - .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal)) - .clipToBounds(), - heightPx = maxHeightPx - pinnedHeightPx + (scrollBehavior?.state?.heightOffset - ?: 0f), - navigationIconContentColor = style.colors.backIconColor.getValue(interactionSource), - titleContentColor = style.colors.titleColor.getValue(interactionSource), - actionIconContentColor = style.colors.actionStartColor.getValue(interactionSource), - title = title, - description = description, - titleTextStyle = style.titleStyle.getValue(interactionSource), - titleAlpha = expandedAlpha, - textVerticalArrangement = Arrangement.Bottom, - textHorizontalArrangement = Arrangement.Center, //todo - hideTitleSemantics = hideBottomRowSemantics, - navigationIcon = {}, - actions = {}, - descriptionContentColor = style.colors.descriptionColor.getValue(interactionSource), - descriptionTextStyle = style.descriptionStyle.getValue(interactionSource), - ) - } + Box( + modifier = modifier + .then(appBarDragModifier) + .clip(rememberNavBarShape(style.bottomShape)) + .background(appBarContainerColor), + contentAlignment = Alignment.TopCenter, + ) { + NavBarLayout( + modifier = Modifier //todo применить отступы в закрытом положении + .windowInsetsPadding(windowInsets) + // clip after padding so we don't show the title over the inset area + .clipToBounds(), + heightPx = pinnedHeightPx, + navigationIconContentColor = style.colors.backIconColor.getValue( + interactionSource, + collapsedStateSet + ), + titleContentColor = style.colors.titleColor.getValue( + interactionSource, + collapsedStateSet + ), + actionIconContentColor = style.colors.actionStartColor.getValue( + interactionSource, + collapsedStateSet + ), + title = smallTitle, + description = smallDescription, + titleTextStyle = style.titleStyle.getValue(interactionSource, collapsedStateSet), + collapsedAlpha = collapsedAlpha, + textVerticalArrangement = Arrangement.Center, + textHorizontalArrangement = Arrangement.Start,//todo вынести в публичное апи или брать из стиля и сделать enum + hideTitleSemantics = hideTopRowSemantics, + navigationIcon = navigationIcon, + actions = actionsRow, + descriptionContentColor = style.colors.descriptionColor.getValue( + interactionSource, + collapsedStateSet + ), + descriptionTextStyle = style.descriptionStyle.getValue( + interactionSource, + collapsedStateSet + ), + ) + NavBarLayout( + modifier = Modifier //todo применить отступы в открытом положении + // only apply the horizontal sides of the window insets padding, since the top + // padding will always be applied by the layout above + .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal)) + .clipToBounds(), + heightPx = maxHeightPx + (scrollBehavior?.state?.heightOffset + ?: 0f), + navigationIconContentColor = style.colors.backIconColor.getValue(interactionSource), + titleContentColor = style.colors.titleColor.getValue(interactionSource), + actionIconContentColor = style.colors.actionStartColor.getValue(interactionSource), + title = title, + description = description, + titleTextStyle = style.titleStyle.getValue(interactionSource), + collapsedAlpha = expandedAlpha, + textVerticalArrangement = Arrangement.Bottom, + textHorizontalArrangement = Arrangement.Start, //todo + hideTitleSemantics = hideBottomRowSemantics, + navigationIcon = {}, + actions = {}, + descriptionContentColor = style.colors.descriptionColor.getValue(interactionSource), + descriptionTextStyle = style.descriptionStyle.getValue(interactionSource), + expandedContent = expandedContent, + ) } } @@ -535,7 +545,7 @@ private fun BaseCollapsingNavBar( * @param title the top app bar title (header) * @param titleTextStyle the title's text style * @param modifier a [Modifier] - * @param titleAlpha the title's alpha + * @param collapsedAlpha the title's alpha * @param titleVerticalArrangement the title's vertical arrangement * @param titleHorizontalArrangement the title's horizontal arrangement * @param titleBottomPadding the title's bottom padding @@ -558,12 +568,13 @@ private fun NavBarLayout( description: @Composable () -> Unit, titleTextStyle: TextStyle, descriptionTextStyle: TextStyle, - titleAlpha: Float, + collapsedAlpha: Float, textVerticalArrangement: Arrangement.Vertical, textHorizontalArrangement: Arrangement.Horizontal, hideTitleSemantics: Boolean, navigationIcon: @Composable () -> Unit, actions: @Composable () -> Unit, + expandedContent: @Composable BoxScope.() -> Unit = {}, ) { Layout( { @@ -588,7 +599,7 @@ private fun NavBarLayout( .layoutId("text") .padding(horizontal = TopAppBarHorizontalPadding) //todo .then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier) - .graphicsLayer(alpha = titleAlpha) + .graphicsLayer(alpha = collapsedAlpha) ) { ProvideTextStyle( color = { titleContentColor }, @@ -611,6 +622,13 @@ private fun NavBarLayout( content = actions ) } + Box( + modifier = Modifier + .layoutId("expandedContent") + .graphicsLayer(alpha = collapsedAlpha) + , + content = expandedContent + ) }, modifier = modifier ) { measurables, constraints -> @@ -632,8 +650,20 @@ private fun NavBarLayout( .measure(constraints.copy(minWidth = 0, maxWidth = maxTextWidth)) val layoutHeight = if (heightPx.isNaN()) 0 else heightPx.roundToInt() + val expandedContentPlaceable = measurables.fastFirst { it.layoutId == "expandedContent" } + .measure( + constraints.copy( + minHeight = layoutHeight, + maxHeight = layoutHeight, + minWidth = constraints.maxWidth, + maxWidth = constraints.maxWidth, + ) + ) layout(constraints.maxWidth, layoutHeight) { + // Expanded content + expandedContentPlaceable.placeRelative(0, 0) + // Navigation icon navigationIconPlaceable.placeRelative( x = 0, diff --git a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt index 4ea33f955..7edb3867f 100644 --- a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt @@ -180,7 +180,7 @@ private class DefaultCollapsingNavBarStyle( colors = colorsBuilder.build(), dimensions = dimensionsBuilder.build(), shadow = shadow ?: ShadowAppearance(), - bottomShape = bottomShape ?: RoundedCornerShape(ZeroCornerSize), + bottomShape = bottomShape ?: RoundedCornerShape(16.dp), actionButtonStyle = actionButtonStyle, titleStyle = titleStyle ?: TextStyle().asStatefulValue(), descriptionStyle = descriptionStyle ?: TextStyle().asStatefulValue(), @@ -350,14 +350,18 @@ private data class DefaultCollapsingNavBarColors( override fun build(): CollapsingNavBarColors { return DefaultCollapsingNavBarColors( - backgroundColor = backgroundColor ?: Color.LightGray.asStatefulValue( - setOf(CollapsingNavBarState.Collapsed) to Color.Gray.copy(0.6f) + backgroundColor = backgroundColor ?: Color.Red.copy(0.2f).asStatefulValue( + setOf(CollapsingNavBarState.Collapsed) to Color.Blue.copy(0.2f) ), backIconColor = backIconColor ?: Color.Black.asStatefulValue(), actionStartColor = actionStartColor ?: Color.Black.asStatefulValue(), actionEndColor = actionEndColor ?: Color.Black.asStatefulValue(), - titleColor = titleColor ?: Color.Black.asStatefulValue(), - descriptionColor = descriptionColor ?: Color.DarkGray.asStatefulValue(), + titleColor = titleColor ?: Color.White.asStatefulValue( + setOf(CollapsingNavBarState.Collapsed) to Color.Black + ), + descriptionColor = descriptionColor ?: Color.LightGray.asStatefulValue( + setOf(CollapsingNavBarState.Collapsed) to Color.DarkGray + ), ) } } diff --git a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/NavigationBar.kt b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/NavigationBar.kt index 91045d85f..461673fef 100644 --- a/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/NavigationBar.kt +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/NavigationBar.kt @@ -57,7 +57,7 @@ fun NavigationBar( Column( modifier = modifier .shadow(style.shadow) - .clip(rememberBarShape(style.bottomShape)) + .clip(rememberNavBarShape(style.bottomShape)) .background(style.colors.backgroundColor.colorForInteraction(interactionSource)), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -165,7 +165,7 @@ enum class NavigationBarContentPlacement { } @Composable -private fun rememberBarShape(bottomShape: CornerBasedShape): RoundedCornerShape { +internal fun rememberNavBarShape(bottomShape: CornerBasedShape): RoundedCornerShape { return remember(bottomShape) { RoundedCornerShape( topStart = ZeroCornerSize,