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..695308ee2 --- /dev/null +++ b/playground/sandbox-compose/src/main/kotlin/com/sdds/playground/sandbox/navigationbar/collapsing/CollapsingNavBarPreview.kt @@ -0,0 +1,67 @@ +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.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 +import com.sdds.compose.uikit.CollapsingNavBarDefaults +import com.sdds.compose.uikit.rememberTopAppBarState + +@Composable +@Preview(showBackground = true) +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 = "" + ) + }, + 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 { + items(100) { + Text(modifier = Modifier.padding(32.dp), 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..ad77e7b1d --- /dev/null +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBar.kt @@ -0,0 +1,975 @@ +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.background +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.BoxScope +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.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.remember +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.clip +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.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +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.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 CollapsingNavBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + style: CollapsingNavBarStyle = LocalCollapsingNavBarStyle.current, + 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() }, +) { + BaseCollapsingNavBar( + modifier = modifier, + style = style, + title = title, + smallTitle = title, + description = description, + smallDescription = description, + expandedContent = expandedContent, + navigationIcon = navigationIcon, + actions = actions, + windowInsets = windowInsets, + scrollBehavior = scrollBehavior, + interactionSource = interactionSource, + ) +} + +/** + * A TopAppBarScrollBehavior defines how an app bar should behave when the content under it is + * scrolled. + * + * @see [CollapsingNavBarDefaults.pinnedScrollBehavior] + * @see [CollapsingNavBarDefaults.enterAlwaysScrollBehavior] + * @see [CollapsingNavBarDefaults.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 CollapsingNavBarDefaults { + + /** + * Default insets to be used and consumed by the top app bars + */ + val windowInsets: WindowInsets + @Composable + get() = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + + /** + * 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 = 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) +} + +@Stable +internal fun containerColor( + colorTransitionFraction: Float, + containerColor: Color, + scrolledContainerColor: Color, +): Color { + return lerp( + containerColor, + scrolledContainerColor, + FastOutLinearInEasing.transform(colorTransitionFraction) + ) +} + +@Composable +private fun BaseCollapsingNavBar( + style: CollapsingNavBarStyle, + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + smallTitle: @Composable () -> Unit, + description: @Composable (() -> Unit), + smallDescription: @Composable (() -> Unit), + navigationIcon: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, + expandedContent: @Composable (BoxScope.() -> Unit), + windowInsets: WindowInsets, + 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 BaseCollapsingNavBar max height should be greater than its pinned height" + ) + } + val pinnedHeightPx: Float + val maxHeightPx: Float + LocalDensity.current.run { + pinnedHeightPx = pinnedHeight.toPx() + maxHeightPx = maxHeight.toPx() + } + + // 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 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 { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions + ) + } + 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 + 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 + } + + 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, + ) + } +} + +/** + * 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 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 + * @param hideTitleSemantics hides the title node from the semantic tree. Apply this + * 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 NavBarLayout( + modifier: Modifier, + heightPx: Float, + navigationIconContentColor: Color, + titleContentColor: Color, + descriptionContentColor: Color, + actionIconContentColor: Color, + title: @Composable () -> Unit, + description: @Composable () -> Unit, + titleTextStyle: TextStyle, + descriptionTextStyle: TextStyle, + collapsedAlpha: Float, + textVerticalArrangement: Arrangement.Vertical, + textHorizontalArrangement: Arrangement.Horizontal, + hideTitleSemantics: Boolean, + navigationIcon: @Composable () -> Unit, + actions: @Composable () -> Unit, + expandedContent: @Composable BoxScope.() -> Unit = {}, +) { + Layout( + { + Box( + Modifier + .layoutId("navigationIcon") + .padding(start = TopAppBarHorizontalPadding) //todo + ) { + CompositionLocalProvider( + LocalTint provides navigationIconContentColor, + content = navigationIcon + ) + } + 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 = collapsedAlpha) + ) { + ProvideTextStyle( + color = { titleContentColor }, + value = titleTextStyle, + content = title + ) + ProvideTextStyle( + color = { descriptionContentColor }, + value = descriptionTextStyle, + content = description + ) + } + Box( + Modifier + .layoutId("actionIcons") + .padding(end = TopAppBarHorizontalPadding) //todo + ) { + CompositionLocalProvider( + LocalTint provides actionIconContentColor, + content = actions + ) + } + Box( + modifier = Modifier + .layoutId("expandedContent") + .graphicsLayer(alpha = collapsedAlpha) + , + content = expandedContent + ) + }, + 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 maxTextWidth = if (constraints.maxWidth == Constraints.Infinity) { + constraints.maxWidth + } else { + (constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width) + .coerceAtLeast(0) + } + val textPlaceable = + measurables.fastFirst { it.layoutId == "text" } + .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, + y = (layoutHeight - navigationIconPlaceable.height) / 2 + ) + + // Title + description + textPlaceable.placeRelative( + x = when (textHorizontalArrangement) { + Arrangement.Center -> { + 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 + 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 + textPlaceable.width)) + } + baseX + } + + Arrangement.End -> + 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 (textVerticalArrangement) { + Arrangement.Center -> (layoutHeight - textPlaceable.height) / 2 + // Apply bottom padding from the title's baseline only when the Arrangement is + // "Bottom". + Arrangement.Bottom -> layoutHeight - textPlaceable.height + // 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 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 //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..7edb3867f --- /dev/null +++ b/sdds-core/uikit-compose/src/main/kotlin/com/sdds/compose/uikit/CollapsingNavBarStyle.kt @@ -0,0 +1,529 @@ +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(16.dp), + 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.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.White.asStatefulValue( + setOf(CollapsingNavBarState.Collapsed) to Color.Black + ), + descriptionColor = descriptionColor ?: Color.LightGray.asStatefulValue( + setOf(CollapsingNavBarState.Collapsed) to Color.DarkGray + ), + ) + } + } +} + +/** + * Размеры и отступы компонента + */ +@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 + ), + ) + } + } +} 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,