[go: nahoru, domu]

blob: af34e83084dba59626024ba69e03a9d411be7680 [file] [log] [blame]
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.ui.material
import androidx.animation.FastOutSlowInEasing
import androidx.animation.TweenBuilder
import androidx.annotation.FloatRange
import androidx.compose.Composable
import androidx.compose.emptyContent
import androidx.ui.animation.animate
import androidx.ui.core.Constraints
import androidx.ui.core.CurrentTextStyleProvider
import androidx.ui.core.DrawModifier
import androidx.ui.core.LastBaseline
import androidx.ui.core.Layout
import androidx.ui.core.LayoutTag
import androidx.ui.core.MeasureScope
import androidx.ui.core.Modifier
import androidx.ui.core.Placeable
import androidx.ui.core.tag
import androidx.ui.foundation.Box
import androidx.ui.foundation.ContentGravity
import androidx.ui.foundation.ProvideContentColor
import androidx.ui.foundation.contentColor
import androidx.ui.foundation.selection.MutuallyExclusiveSetItem
import androidx.ui.graphics.Canvas
import androidx.ui.graphics.Color
import androidx.ui.graphics.lerp
import androidx.ui.layout.Arrangement
import androidx.ui.layout.LayoutHeight
import androidx.ui.layout.LayoutWidth
import androidx.ui.layout.Row
import androidx.ui.layout.RowScope
import androidx.ui.material.ripple.Ripple
import androidx.ui.material.surface.Surface
import androidx.ui.material.surface.primarySurface
import androidx.ui.text.style.TextAlign
import androidx.ui.unit.Density
import androidx.ui.unit.Dp
import androidx.ui.unit.IntPx
import androidx.ui.unit.PxSize
import androidx.ui.unit.dp
import androidx.ui.unit.max
// TODO: b/149825331 add documentation references to Scaffold here and samples for using
// BottomNavigation inside a Scaffold
/**
* BottomNavigation is a component placed at the bottom of the screen that represents primary
* destinations in your application.
*
* BottomNavigation should contain multiple [BottomNavigationItem]s, each representing a singular
* destination.
*
* A simple example looks like:
*
* @sample androidx.ui.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/)
*
* @param modifier optional [Modifier] for this BottomNavigation
* @param color The background color for this BottomNavigation
* @param contentColor The preferred content color provided by this BottomNavigation to its
* children. Defaults to either the matching `onFoo` color for [color], or if [color] is not a
* color from the theme, this will keep the same value set above this BottomNavigation.
* @param elevation elevation for this BottomNavigation
* @param children destinations inside this BottomNavigation, this should contain multiple
* [BottomNavigationItem]s
*/
@Composable
fun BottomNavigation(
modifier: Modifier = Modifier.None,
color: Color = MaterialTheme.colors().primarySurface,
contentColor: Color = contentColorFor(color),
elevation: Dp = BottomNavigationElevation,
children: @Composable() RowScope.() -> Unit
) {
Surface(
color = color,
contentColor = contentColor,
elevation = elevation,
modifier = modifier
) {
Row(
LayoutWidth.Fill + LayoutHeight(BottomNavigationHeight),
arrangement = Arrangement.SpaceBetween,
children = children
)
}
}
// TODO: b/150138067 remove RowScope receiver here when we can access LayoutFlexible from a
// global scope
/**
* A BottomNavigationItem represents a singular primary destination in your application.
*
* 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 [alwaysShowLabels].
*
* @param icon icon for this item, typically this will be a [androidx.ui.foundation.Icon]
* @param text optional text for this item
* @param selected whether this item is selected
* @param onSelected the callback to be invoked when this item is selected
* @param modifier optional [Modifier] for this item
* @param alwaysShowLabels whether to always show labels for this item. If false, labels will
* only be shown when this item is selected.
* @param activeColor the color of the text and icon when this item is selected
* @param inactiveColor the color of the text and icon when this item is not selected
*/
@Composable
fun RowScope.BottomNavigationItem(
icon: @Composable() () -> Unit,
text: @Composable() () -> Unit = emptyContent(),
selected: Boolean,
onSelected: () -> Unit,
modifier: Modifier = Modifier.None,
alwaysShowLabels: Boolean = true,
activeColor: Color = contentColor(),
inactiveColor: Color = MaterialTheme.emphasisLevels().medium.emphasize(activeColor)
) {
val styledText = @Composable {
val style = MaterialTheme.typography().caption.copy(textAlign = TextAlign.Center)
CurrentTextStyleProvider(style, children = text)
}
Ripple(bounded = false) {
MutuallyExclusiveSetItem(selected = selected, onClick = onSelected) {
Box(modifier + LayoutFlexible(1f), gravity = ContentGravity.Center) {
BottomNavigationTransition(activeColor, inactiveColor, selected) { progress ->
val textSlot = if (alwaysShowLabels) {
styledText
} else {
@Composable {
Box(DrawScale(progress), children = styledText)
}
}
val animationProgress = if (alwaysShowLabels) 1f else progress
BottomNavigationItemBaselineLayout(
icon = icon,
text = textSlot,
iconPositionAnimationProgress = animationProgress
)
}
}
}
}
}
/**
* Transition that animates [contentColor] between [inactiveColor] and [activeColor], depending
* on [selected]. This component also provides the animation fraction as a parameter to [children],
* to allow animating the position of the icon and the scale of the text alongside this color
* animation.
*
* @param activeColor [contentColor] when this item is [selected]
* @param inactiveColor [contentColor] when this item is not [selected]
* @param selected whether this item is selected
* @param children the content of the [BottomNavigationItem] to animate [contentColor] 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,
children: @Composable() (animationProgress: Float) -> Unit
) {
val animationProgress = animate(
target = if (selected) 1f else 0f,
animBuilder = BottomNavigationAnimationBuilder
)
val color = lerp(inactiveColor, activeColor, animationProgress)
ProvideContentColor(color) {
children(animationProgress)
}
}
// TODO: b/147497445 generify this and extract to public API, this shouldn't be in this file
/**
* [DrawModifier] that scales the drawing of its content by [scalePercent] in both axes, from the
* center of its size.
*/
private class DrawScale(private val scalePercent: Float) : DrawModifier {
override fun draw(density: Density, drawContent: () -> Unit, canvas: Canvas, size: PxSize) {
canvas.apply {
if (scalePercent == 1f) {
drawContent()
} else {
val centerWidth = size.width.value / 2
val centerHeight = size.height.value / 2
val adjustedX = centerWidth - (centerWidth * scalePercent)
val adjustedY = centerHeight - (centerHeight * scalePercent)
save()
translate(adjustedX, adjustedY)
// TODO: b/147497445 use scale overload that accepts a pivot point if / when we
// expose it
scale(scalePercent)
drawContent()
restore()
}
}
}
}
/**
* Base layout for a [BottomNavigationItem]
*
* @param icon icon for this item
* @param text text for this item
* @param iconPositionAnimationProgress progress of the animation the controls icon position,
* where 0 represents its unselected position and 1 represents its selected position. If both the
* [icon] and [text] 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,
text: @Composable() () -> Unit,
@FloatRange(from = 0.0, to = 1.0) iconPositionAnimationProgress: Float
) {
Layout(
{
Box(LayoutTag("icon"), children = icon)
Box(
LayoutTag("text"),
paddingLeft = BottomNavigationItemHorizontalPadding,
paddingRight = BottomNavigationItemHorizontalPadding,
children = text
)
}
) { measurables, constraints, _ ->
val iconPlaceable = measurables.first { it.tag == "icon" }.measure(constraints)
val textPlaceable = measurables.first { it.tag == "text" }.measure(
// Measure with loose constraints for height as we don't want the text to take up more
// space than it needs
constraints.copy(minHeight = IntPx.Zero)
)
// If the text is empty, just place the icon.
if (textPlaceable.width == IntPx.Zero && textPlaceable.height == IntPx.Zero) {
placeIcon(iconPlaceable, constraints)
} else {
placeTextAndIcon(
textPlaceable,
iconPlaceable,
constraints,
iconPositionAnimationProgress
)
}
}
}
/**
* Places the provided [iconPlaceable] in the vertical center of the provided [constraints]
*/
private fun MeasureScope.placeIcon(
iconPlaceable: Placeable,
constraints: Constraints
): MeasureScope.LayoutResult {
val height = constraints.maxHeight
val iconY = (height - iconPlaceable.height) / 2
return layout(iconPlaceable.width, height) {
iconPlaceable.place(IntPx.Zero, iconY)
}
}
/**
* Places the provided [textPlaceable] and [iconPlaceable] in the correct position, depending on
* [iconPositionAnimationProgress].
*
* When [iconPositionAnimationProgress] is 0, [iconPlaceable] will be placed in the center, as with
* [placeIcon], and [textPlaceable] will not be shown.
*
* When [iconPositionAnimationProgress] is 1, [iconPlaceable] will be placed near the top of item,
* and [textPlaceable] 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 textPlaceable text 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 text, and 1 represents top aligned icon with text.
* Values between 0 and 1 interpolate the icon position so we can smoothly move the icon.
*/
private fun MeasureScope.placeTextAndIcon(
textPlaceable: Placeable,
iconPlaceable: Placeable,
constraints: Constraints,
@FloatRange(from = 0.0, to = 1.0) iconPositionAnimationProgress: Float
): MeasureScope.LayoutResult {
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 text
val baseline = requireNotNull(textPlaceable[LastBaseline]) { "No text baselines found" }
val baselineOffset = CombinedItemTextBaseline.toIntPx()
// Text should be [baselineOffset] from the bottom
val textY = 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(textPlaceable.width, iconPlaceable.width)
val textX = (containerWidth - textPlaceable.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)
return layout(containerWidth, height) {
if (iconPositionAnimationProgress != 0f) {
textPlaceable.place(textX, textY)
}
iconPlaceable.place(iconX, selectedIconY + offset)
}
}
/**
* [AnimationBuilder] controlling the transition between unselected and selected
* [BottomNavigationItem]s.
*/
private val BottomNavigationAnimationBuilder = TweenBuilder<Float>().apply {
duration = 115
easing = FastOutSlowInEasing
}
/**
* Height of a [BottomNavigation] component
*/
private val BottomNavigationHeight = 56.dp
/**
* Default elevation of a [BottomNavigation] component
*/
private val BottomNavigationElevation = 8.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