Skip to content

Instantly share code, notes, and snippets.

  • Save tasjapr/79bfe752fbd78612d555b2a8e26984ad to your computer and use it in GitHub Desktop.
Save tasjapr/79bfe752fbd78612d555b2a8e26984ad to your computer and use it in GitHub Desktop.
CustomBottomNavigation
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.VectorizedAnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.*
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.math.roundToInt
/**
* <a href="https://material.io/components/bottom-navigation" class="external" target="_blank">Material Design bottom navigation</a>.
*
* Bottom navigation bars allow movement between primary destinations in an app.
*
* ![Bottom navigation image](https://developer.android.com/images/reference/androidx/compose/material/bottom-navigation.png)
*
* BottomNavigation should contain multiple [BottomNavigationItem]s, each representing a singular
* destination.
*
* A simple example looks like:
*
* @sample androidx.compose.material.samples.BottomNavigationSample
*
* See [BottomNavigationItem] for configuration specific to each item, and not the overall
* BottomNavigation component.
*
* For more information, see [Bottom Navigation](https://material.io/components/bottom-navigation/)
*
* This CustomBottomNavigation is a copy-paste of the [BottomNavigation] file from the androidx.compose.material package with some changes
* There are no internal paddings between [BottomNavigationItem], paddings can be set via icon or label modifiers.
* It is also possible to set horizontal paddings for the first and last element of [CustomBottomNavigation] by using the innerHorizontalPaddings parameter
*
* @param modifier optional [Modifier] for this BottomNavigation
* @param innerHorizontalPaddings The horizontal padding for for this CustomBottomNavigation, by default 0 dp
* @param backgroundColor The background color for this CustomBottomNavigation
* @param contentColor The preferred content color provided by this CustomBottomNavigation to its
* children. Defaults to either the matching content color for [backgroundColor], or if
* [backgroundColor] is not a color from the theme, this will keep the same value set above this
* CustomBottomNavigation.
* @param elevation elevation for this CustomBottomNavigation
* @param content destinations inside this CustomBottomNavigation, this should contain multiple
* [BottomNavigationItem]s
*/
@Composable
fun CustomBottomNavigation(
modifier: Modifier = Modifier,
innerHorizontalPaddings: Dp = 0.dp,
backgroundColor: Color = MaterialTheme.colors.primarySurface,
contentColor: Color = contentColorFor(backgroundColor),
elevation: Dp = BottomNavigationDefaults.Elevation,
content: @Composable RowScope.() -> Unit
) {
Surface(
color = backgroundColor,
contentColor = contentColor,
elevation = elevation,
modifier = modifier
) {
Row(
Modifier
.padding(horizontal = innerHorizontalPaddings)
.fillMaxWidth()
.height(BottomNavigationHeight)
.selectableGroup(),
horizontalArrangement = Arrangement.SpaceBetween,
content = content
)
}
}
/**
* <a href="https://material.io/components/bottom-navigation" class="external" target="_blank">Material Design bottom navigation</a> item.
*
* The recommended configuration for a BottomNavigationItem depends on how many items there are
* inside a [BottomNavigation]:
*
* - Three destinations: Display icons and text labels for all destinations.
* - Four destinations: Active destinations display an icon and text label. Inactive destinations
* display icons, and text labels are recommended.
* - Five destinations: Active destinations display an icon and text label. Inactive destinations
* use icons, and use text labels if space permits.
*
* A BottomNavigationItem always shows text labels (if it exists) when selected. Showing text
* labels if not selected is controlled by [alwaysShowLabel].
*
* @param selected whether this item is selected
* @param onClick the callback to be invoked when this item is selected
* @param icon icon for this item, typically this will be an [Icon]
* @param modifier optional [Modifier] for this item
* @param enabled controls the enabled state of this item. When `false`, this item will not
* be clickable and will appear disabled to accessibility services.
* @param label optional text label for this item
* @param alwaysShowLabel whether to always show the label for this item. If false, the label will
* only be shown when this item is selected.
* @param interactionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for this BottomNavigationItem. You can create and pass in your own remembered
* [MutableInteractionSource] if you want to observe [Interaction]s and customize the
* appearance / behavior of this BottomNavigationItem in different [Interaction]s.
* @param selectedContentColor the color of the text label and icon when this item is selected,
* and the color of the ripple.
* @param unselectedContentColor the color of the text label and icon when this item is not selected
*/
@Composable
fun RowScope.BottomNavigationItem(
selected: Boolean,
onClick: () -> Unit,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
alwaysShowLabel: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
selectedContentColor: Color = LocalContentColor.current,
unselectedContentColor: Color = selectedContentColor.copy(alpha = ContentAlpha.medium)
) {
// The color of the Ripple should always the selected color, as we want to show the color
// before the item is considered selected, and hence before the new contentColor is
// provided by BottomNavigationTransition.
val ripple = rememberRipple(bounded = false, color = selectedContentColor)
Box(
modifier
.selectable(
selected = selected,
onClick = onClick,
enabled = enabled,
role = Role.Tab,
interactionSource = interactionSource,
indication = ripple
)
.weight(1f),
contentAlignment = Alignment.Center
) {
BottomNavigationTransition(
selectedContentColor,
unselectedContentColor,
selected
) { progress ->
val animationProgress = if (alwaysShowLabel) 1f else progress
BottomNavigationItemBaselineLayout(
icon = icon,
label = label,
iconPositionAnimationProgress = animationProgress
)
}
}
}
/**
* Contains default values used for [BottomNavigation].
*/
object BottomNavigationDefaults {
/**
* Default elevation used for [BottomNavigation].
*/
val Elevation = 8.dp
}
/**
* Transition that animates [LocalContentColor] between [inactiveColor] and [activeColor], depending
* on [selected]. This component also provides the animation fraction as a parameter to [content],
* to allow animating the position of the icon and the scale of the label alongside this color
* animation.
*
* @param activeColor [LocalContentColor] when this item is [selected]
* @param inactiveColor [LocalContentColor] when this item is not [selected]
* @param selected whether this item is selected
* @param content the content of the [BottomNavigationItem] to animate [LocalContentColor] for,
* where the animationProgress is the current progress of the animation from 0f to 1f.
*/
@Composable
private fun BottomNavigationTransition(
activeColor: Color,
inactiveColor: Color,
selected: Boolean,
content: @Composable (animationProgress: Float) -> Unit
) {
val animationProgress by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = BottomNavigationAnimationSpec
)
val color = lerp(inactiveColor, activeColor, animationProgress)
CompositionLocalProvider(
LocalContentColor provides color.copy(alpha = 1f),
LocalContentAlpha provides color.alpha
) {
content(animationProgress)
}
}
/**
* Base layout for a [BottomNavigationItem]
*
* @param icon icon for this item
* @param label text label for this item
* @param iconPositionAnimationProgress progress of the animation that controls icon position,
* where 0 represents its unselected position and 1 represents its selected position. If both the
* [icon] and [label] should be shown at all times, this will always be 1, as the icon position
* should remain constant.
*/
@Composable
private fun BottomNavigationItemBaselineLayout(
icon: @Composable () -> Unit,
label: @Composable (() -> Unit)?,
/*@FloatRange(from = 0.0, to = 1.0)*/
iconPositionAnimationProgress: Float
) {
Layout(
{
Box(Modifier.layoutId("icon")) { icon() }
if (label != null) {
Box(
Modifier
.layoutId("label")
.alpha(iconPositionAnimationProgress)
) { label() }
}
}
) { measurables, constraints ->
val iconPlaceable = measurables.first { it.layoutId == "icon" }.measure(constraints)
val labelPlaceable = label?.let {
measurables.first { it.layoutId == "label" }.measure(
// Measure with loose constraints for height as we don't want the label to take up more
// space than it needs
constraints.copy(minHeight = 0)
)
}
// If there is no label, just place the icon.
if (label == null) {
placeIcon(iconPlaceable, constraints)
} else {
placeLabelAndIcon(
labelPlaceable!!,
iconPlaceable,
constraints,
iconPositionAnimationProgress
)
}
}
}
/**
* Places the provided [iconPlaceable] in the vertical center of the provided [constraints]
*/
private fun MeasureScope.placeIcon(
iconPlaceable: Placeable,
constraints: Constraints
): MeasureResult {
val height = constraints.maxHeight
val iconY = (height - iconPlaceable.height) / 2
return layout(iconPlaceable.width, height) {
iconPlaceable.placeRelative(0, iconY)
}
}
/**
* Places the provided [labelPlaceable] and [iconPlaceable] in the correct position, depending on
* [iconPositionAnimationProgress].
*
* When [iconPositionAnimationProgress] is 0, [iconPlaceable] will be placed in the center, as with
* [placeIcon], and [labelPlaceable] will not be shown.
*
* When [iconPositionAnimationProgress] is 1, [iconPlaceable] will be placed near the top of item,
* and [labelPlaceable] will be placed at the bottom of the item, according to the spec.
*
* When [iconPositionAnimationProgress] is animating between these values, [iconPlaceable] will be
* placed at an interpolated position between its centered position and final resting position.
*
* @param labelPlaceable text label placeable inside this item
* @param iconPlaceable icon placeable inside this item
* @param constraints constraints of the item
* @param iconPositionAnimationProgress the progress of the icon position animation, where 0
* represents centered icon and no label, and 1 represents top aligned icon with label.
* Values between 0 and 1 interpolate the icon position so we can smoothly move the icon.
*/
private fun MeasureScope.placeLabelAndIcon(
labelPlaceable: Placeable,
iconPlaceable: Placeable,
constraints: Constraints,
/*@FloatRange(from = 0.0, to = 1.0)*/
iconPositionAnimationProgress: Float
): MeasureResult {
val height = constraints.maxHeight
// TODO: consider multiple lines of text here, not really supported by spec but we should
// have a better strategy than overlapping the icon and label
val baseline = labelPlaceable[LastBaseline]
val baselineOffset = CombinedItemTextBaseline.roundToPx()
// Label should be [baselineOffset] from the bottom
val labelY = height - baseline - baselineOffset
val unselectedIconY = (height - iconPlaceable.height) / 2
// Icon should be [baselineOffset] from the text baseline, which is itself
// [baselineOffset] from the bottom
val selectedIconY = height - (baselineOffset * 2) - iconPlaceable.height
val containerWidth = max(labelPlaceable.width, iconPlaceable.width)
val labelX = (containerWidth - labelPlaceable.width) / 2
val iconX = (containerWidth - iconPlaceable.width) / 2
// How far the icon needs to move between unselected and selected states
val iconDistance = unselectedIconY - selectedIconY
// When selected the icon is above the unselected position, so we will animate moving
// downwards from the selected state, so when progress is 1, the total distance is 0, and we
// are at the selected state.
val offset = (iconDistance * (1 - iconPositionAnimationProgress)).roundToInt()
return layout(containerWidth, height) {
if (iconPositionAnimationProgress != 0f) {
labelPlaceable.placeRelative(labelX, labelY + offset)
}
iconPlaceable.placeRelative(iconX, selectedIconY + offset)
}
}
/**
* [VectorizedAnimationSpec] controlling the transition between unselected and selected
* [BottomNavigationItem]s.
*/
private val BottomNavigationAnimationSpec = TweenSpec<Float>(
durationMillis = 300,
easing = FastOutSlowInEasing
)
/**
* Height of a [BottomNavigation] component
*/
private val BottomNavigationHeight = 56.dp
/**
* Padding at the start and end of a [BottomNavigationItem]
*/
private val BottomNavigationItemHorizontalPadding = 12.dp
/**
* The space between the text baseline and the bottom of the [BottomNavigationItem], and between
* the text baseline and the bottom of the icon placed above it.
*/
private val CombinedItemTextBaseline = 12.dp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment