[go: nahoru, domu]

blob: aaccde8eda53123c50c3f0dd4978eeb56cc36d56 [file] [log] [blame]
/*
* Copyright 2019 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.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.transitionDefinition
import androidx.compose.animation.core.tween
import androidx.compose.Composable
import androidx.compose.Immutable
import androidx.compose.Providers
import androidx.compose.emptyContent
import androidx.compose.getValue
import androidx.compose.remember
import androidx.compose.setValue
import androidx.compose.state
import androidx.compose.animation.ColorPropKey
import androidx.compose.animation.DpPropKey
import androidx.compose.animation.transition
import androidx.ui.core.Alignment
import androidx.ui.core.Constraints
import androidx.ui.core.DensityAmbient
import androidx.ui.core.Layout
import androidx.ui.core.Modifier
import androidx.ui.core.Placeable
import androidx.ui.core.WithConstraints
import androidx.ui.core.id
import androidx.ui.core.layoutId
import androidx.compose.foundation.Box
import androidx.compose.foundation.ContentColorAmbient
import androidx.compose.foundation.ContentGravity
import androidx.compose.foundation.ProvideTextStyle
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.ScrollableRow
import androidx.compose.foundation.background
import androidx.compose.foundation.contentColor
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Stack
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.preferredHeight
import androidx.compose.foundation.layout.preferredWidth
import androidx.ui.material.TabRow.IndicatorTransition
import androidx.ui.material.TabRow.TabPosition
import androidx.compose.foundation.text.FirstBaseline
import androidx.compose.foundation.text.LastBaseline
import androidx.compose.ui.text.style.TextAlign
import androidx.ui.unit.Density
import androidx.ui.unit.Dp
import androidx.ui.unit.dp
import androidx.ui.unit.sp
import androidx.ui.util.fastFirstOrNull
import androidx.ui.util.fastForEach
import androidx.ui.util.fastForEachIndexed
import kotlin.math.max
/**
* A TabRow contains a row of [Tab]s, and displays an indicator underneath the currently
* selected tab.
*
* A simple example with text tabs looks like:
*
* @sample androidx.ui.material.samples.TextTabs
*
* You can also provide your own custom tab, such as:
*
* @sample androidx.ui.material.samples.FancyTabs
*
* Where the custom tab itself could look like:
*
* @sample androidx.ui.material.samples.FancyTab
*
* As well as customizing the tab, you can also provide a custom [indicatorContainer], to customize
* the indicator displayed for a tab. [indicatorContainer] is responsible for positioning an
* indicator and for animating its position when [selectedIndex] changes.
*
* For example, given an indicator that draws a rounded rectangle near the edges of the [Tab]:
*
* @sample androidx.ui.material.samples.FancyIndicator
*
* We can reuse [TabRow.IndicatorContainer] and just provide this indicator, as we aren't changing
* the transition:
*
* @sample androidx.ui.material.samples.FancyIndicatorTabs
*
* You may also want to provide a custom transition, to allow you to dynamically change the
* appearance of the indicator as it animates between tabs, such as changing its color or size.
* [indicatorContainer] is stacked on top of the entire TabRow, so you just need to provide a custom
* container that animates the offset of the indicator from the start of the TabRow and place your
* custom indicator inside of it. For example, take the following custom container that animates
* position of the indicator, the color of the indicator, and also adds a physics based 'spring'
* effect to the indicator in the direction of motion:
*
* @sample androidx.ui.material.samples.FancyIndicatorContainer
*
* This container will fill up the entire width of the TabRow, and when a new tab is selected,
* the transition will be called with a new value for [selectedIndex], which will animate the
* indicator to the position of the new tab.
*
* We can use this custom container similarly to before:
*
* @sample androidx.ui.material.samples.FancyIndicatorContainerTabs
*
* @param T the type of the item provided that will map to a [Tab]
* @param items the list containing the items used to build this TabRow
* @param selectedIndex the index of the currently selected tab
* @param modifier optional [Modifier] for this TabRow
* @param backgroundColor The background color for the TabRow. Use [Color.Transparent] to have
* no color.
* @param contentColor The preferred content color provided by this TabRow to its children.
* Defaults to either the matching `onFoo` color for [backgroundColor], or if [backgroundColor] is
* not a color from the theme, this will keep the same value set above this TabRow.
* @param scrollable if the tabs should be scrollable. If `false` the tabs will take up an equal
* amount of the space given to TabRow. If `true` the tabs will take up only as much space as they
* need, with any excess tabs positioned off screen and able to be scrolled to.
* @param indicatorContainer the container responsible for positioning and animating the position of
* the indicator between tabs. By default this will be [TabRow.IndicatorContainer], which animates a
* [TabRow.Indicator] between tabs.
* @param divider the divider displayed at the bottom of the TabRow. This provides a layer of
* separation between the TabRow and the content displayed underneath.
* @param tab the [Tab] to be emitted for the given index and element of type [T] in [items]
*/
@Composable
fun <T> TabRow(
items: List<T>,
selectedIndex: Int,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colors.primarySurface,
contentColor: Color = contentColorFor(backgroundColor),
scrollable: Boolean = false,
indicatorContainer: @Composable (tabPositions: List<TabPosition>) -> Unit = { tabPositions ->
TabRow.IndicatorContainer(tabPositions, selectedIndex) {
TabRow.Indicator()
}
},
divider: @Composable () -> Unit = {
Divider(thickness = 1.dp, color = contentColor().copy(alpha = DividerOpacity))
},
tab: @Composable (Int, T) -> Unit
) {
Surface(modifier = modifier, color = backgroundColor, contentColor = contentColor) {
// TODO: force scrollable for tabs that will be too small if they take up equal space?
if (scrollable) {
val tabs = @Composable {
items.fastForEachIndexed { index, item ->
tab(index, item)
}
}
ScrollableTabRow(selectedIndex, tabs, indicatorContainer, divider)
} else {
// TODO: b/150138067 remove modifier here and use the global LayoutWeight
// modifier when it exists
val tabs = @Composable { modifier: Modifier ->
items.fastForEachIndexed { index, item ->
Box(modifier, gravity = ContentGravity.Center) {
tab(index, item)
}
}
}
FixedTabRow(items.size, tabs, indicatorContainer, divider)
}
}
}
@Composable
private fun FixedTabRow(
tabCount: Int,
tabs: @Composable (Modifier) -> Unit,
indicatorContainer: @Composable (tabPositions: List<TabPosition>) -> Unit,
divider: @Composable () -> Unit
) {
Stack(Modifier.fillMaxWidth()) {
Row {
tabs(Modifier.weight(1f))
}
Box(Modifier.gravity(Alignment.BottomCenter).fillMaxWidth(), children = divider)
WithConstraints(Modifier.matchParentSize()) {
val width = constraints.maxWidth
val tabWidth = width / tabCount
val density = DensityAmbient.current
val tabPositions = remember(tabCount, tabWidth) {
with(density) {
(0 until tabCount).map { index ->
val left = (tabWidth * index)
TabPosition(left.toDp(), tabWidth.toDp())
}
}
}
indicatorContainer(tabPositions)
}
}
}
@Composable
private fun ScrollableTabRow(
selectedIndex: Int,
tabs: @Composable () -> Unit,
indicatorContainer: @Composable (tabPositions: List<TabPosition>) -> Unit,
divider: @Composable () -> Unit
) {
val scrollState = rememberScrollState()
val scrollableTabData = remember(scrollState) {
ScrollableTabData(
scrollState = scrollState,
selectedTab = selectedIndex
)
}
// TODO: unfortunate 1f lag as we need to first calculate tab positions before drawing the
// indicator container
var tabPositions by state { listOf<TabPosition>() }
val indicator = @Composable {
// Delay indicator composition until we know tab positions
if (tabPositions.isNotEmpty()) {
indicatorContainer(tabPositions)
}
}
val indicatorTag = "indicator"
val dividerTag = "divider"
ScrollableRow(modifier = Modifier.fillMaxWidth(), scrollState = scrollState) {
Layout(
children = {
tabs()
Box(Modifier.layoutId(indicatorTag), children = indicator)
Box(Modifier.layoutId(dividerTag), children = divider)
}
) { measurables, constraints ->
val tabPlaceables = mutableListOf<Pair<Placeable, Int>>()
val minTabWidth = ScrollableTabRowMinimumTabWidth.toIntPx()
val edgeOffset = ScrollableTabRowEdgeOffset.toIntPx()
var layoutHeight = 0
val tabConstraints = constraints.copy(minWidth = minTabWidth)
val newTabPositions = mutableListOf<TabPosition>()
val layoutWidth = measurables
// to avoid wrapping each tab with the Box only to pass the LayoutTag
.filter { it.id == null }
.fold(edgeOffset) { sum, measurable ->
val placeable = measurable.measure(tabConstraints)
if (placeable.height > layoutHeight) {
layoutHeight = placeable.height
}
// Position each tab at the end of the previous one
tabPlaceables.add(placeable to sum)
newTabPositions.add(
TabPosition(
left = sum.toDp(),
width = placeable.width.toDp()
)
)
sum + placeable.width
} + edgeOffset
if (tabPositions != newTabPositions) {
tabPositions = newTabPositions
}
// Position the children.
layout(layoutWidth, layoutHeight) {
// Place the tabs
tabPlaceables.fastForEach { (placeable, left) ->
placeable.place(left, 0)
}
// The divider is measured with its own height, and width equal to the total width
// of the tab row, and then placed on top of the tabs.
measurables.fastFirstOrNull { it.id == dividerTag }
?.measure(constraints.copy(minWidth = layoutWidth, maxWidth = layoutWidth))
?.run { place(0, layoutHeight - height) }
// The indicator container is measured to fill the entire space occupied by the tab
// row, and then placed on top of the divider.
measurables.fastFirstOrNull { it.id == indicatorTag }
?.measure(Constraints.fixed(layoutWidth, layoutHeight))
?.place(0, 0)
scrollableTabData.onLaidOut(
density = this@Layout,
edgeOffset = edgeOffset,
tabPositions = tabPositions,
selectedTab = selectedIndex
)
}
}
}
}
/**
* Class holding onto state needed for [ScrollableTabRow]
*/
private class ScrollableTabData(
private val scrollState: ScrollState,
private var selectedTab: Int
) {
fun onLaidOut(
density: Density,
edgeOffset: Int,
tabPositions: List<TabPosition>,
selectedTab: Int
) {
if (this.selectedTab != selectedTab) {
this.selectedTab = selectedTab
tabPositions.getOrNull(selectedTab)?.let {
// Scrolls to the tab with [tabPosition], trying to place it in the center of the
// screen or as close to the center as possible.
val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
scrollState.smoothScrollTo(calculatedOffset)
}
}
}
/**
* @return the offset required to horizontally center the tab inside this TabRow.
* If the tab is at the start / end, and there is not enough space to fully centre the tab, this
* will just clamp to the min / max position given the max width.
*/
private fun TabPosition.calculateTabOffset(
density: Density,
edgeOffset: Int,
tabPositions: List<TabPosition>
): Float = with(density) {
val totalTabRowWidth = tabPositions.last().right.toIntPx() + edgeOffset
val visibleWidth = totalTabRowWidth - scrollState.maxValue.toInt()
val tabOffset = left.toIntPx()
val scrollerCenter = visibleWidth / 2
val tabWidth = width.toIntPx()
val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2)
// How much space we have to scroll. If the visible width is <= to the total width, then
// we have no space to scroll as everything is always visible.
val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0)
return centeredTabOffset.coerceIn(0, availableSpace).toFloat()
}
}
object TabRow {
private val IndicatorOffset = DpPropKey()
/**
* Data class that contains information about a tab's position on screen
*
* @property left the left edge's x position from the start of the [TabRow]
* @property right the right edge's x position from the start of the [TabRow]
* @property width the width of this tab
*/
@Immutable
data class TabPosition internal constructor(val left: Dp, val width: Dp) {
val right: Dp get() = left + width
}
/**
* Positions and animates the given [indicator] between tabs when [selectedIndex] changes.
*/
@Composable
fun IndicatorContainer(
tabPositions: List<TabPosition>,
selectedIndex: Int,
indicator: @Composable () -> Unit
) {
// TODO: should we animate the width of the indicator as it moves between tabs of different
// sizes inside a scrollable tab row?
val currentTabWidth = tabPositions[selectedIndex].width
Box(Modifier.fillMaxSize(), gravity = ContentGravity.BottomStart) {
IndicatorTransition(tabPositions, selectedIndex) { indicatorOffset ->
Box(
Modifier.padding(start = indicatorOffset).preferredWidth(currentTabWidth),
children = indicator
)
}
}
}
/**
* Default indicator, which will be positioned at the bottom of the [TabRow], on top of the
* divider.
*
* @param modifier modifier for the indicator's layout
* @param color color of the indicator
*/
@Composable
fun Indicator(modifier: Modifier = Modifier, color: Color = contentColor()) {
Box(modifier.fillMaxWidth().preferredHeight(IndicatorHeight).background(color = color))
}
/**
* [transition] that animates the indicator offset between a given list of [TabPosition]s.
*/
@Composable
internal fun IndicatorTransition(
tabPositions: List<TabPosition>,
selectedIndex: Int,
indicator: @Composable (indicatorOffset: Dp) -> Unit
) {
val transitionDefinition = remember(tabPositions) {
transitionDefinition {
// TODO: currently the first state set is the 'default' state, so we want to define the
// state that is initially selected first, so we don't have any initial animations.
// When this is supported by transitionDefinition, we should fix this to just set a
// default or similar.
state(selectedIndex) {
this[IndicatorOffset] = tabPositions[selectedIndex].left
}
tabPositions.forEachIndexed { index, position ->
if (index != selectedIndex) {
state(index) {
this[IndicatorOffset] = position.left
}
}
}
transition {
IndicatorOffset using tween(
durationMillis = 250,
easing = FastOutSlowInEasing
)
}
}
}
val state = transition(transitionDefinition, selectedIndex)
indicator(state[IndicatorOffset])
}
}
/**
* A Tab represents a single page of content using a text label and/or icon. It represents its
* selected state by tinting the text label and/or image with [activeColor].
*
* This should typically be used inside of a [TabRow], see the corresponding documentation for
* example usage.
*
* This Tab has slots for [text] and/or [icon] - see the other Tab overload for a generic Tab
* that is not opinionated about its content.
*
* @param text the text label displayed in this tab
* @param icon the icon displayed in this tab
* @param selected whether this tab is selected or not
* @param onSelected the callback to be invoked when this tab is selected
* @param modifier optional [Modifier] for this tab
* @param activeColor the color for the content of this tab when selected
* @param inactiveColor the color for the content of this tab when not selected
*/
@Composable
fun Tab(
text: @Composable () -> Unit = emptyContent(),
icon: @Composable () -> Unit = emptyContent(),
selected: Boolean,
onSelected: () -> Unit,
modifier: Modifier = Modifier,
activeColor: Color = contentColor(),
inactiveColor: Color = EmphasisAmbient.current.medium.applyEmphasis(activeColor)
) {
val styledText = @Composable {
val style = MaterialTheme.typography.button.copy(textAlign = TextAlign.Center)
ProvideTextStyle(style, children = text)
}
Tab(selected, onSelected, modifier) {
TabTransition(activeColor, inactiveColor, selected) {
TabBaselineLayout(icon = icon, text = styledText)
}
}
}
/**
* Generic [Tab] overload that is not opinionated about content / color. See the other overload
* for a Tab that has specific slots for text and / or an icon, as well as providing the correct
* colors for selected / unselected states.
*
* A custom tab using this API may look like:
*
* @sample androidx.ui.material.samples.FancyTab
*
* @param selected whether this tab is selected or not
* @param onSelected the callback to be invoked when this tab is selected
* @param modifier optional [Modifier] for this tab
* @param content the content of this tab
*/
@Composable
fun Tab(
selected: Boolean,
onSelected: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.selectable(selected = selected, onClick = onSelected)
.fillMaxWidth(),
gravity = ContentGravity.Center,
children = content
)
}
private val TabTintColor = ColorPropKey()
/**
* [transition] defining how the tint color for a tab animates, when a new tab is selected. This
* component uses [ContentColorAmbient] to provide an interpolated value between [activeColor]
* and [inactiveColor] depending on the animation status.
*/
@Composable
private fun TabTransition(
activeColor: Color,
inactiveColor: Color,
selected: Boolean,
content: @Composable () -> Unit
) {
val transitionDefinition = remember(activeColor, inactiveColor) {
transitionDefinition {
state(true) {
this[TabTintColor] = activeColor
}
state(false) {
this[TabTintColor] = inactiveColor
}
transition(toState = false, fromState = true) {
TabTintColor using tween(
durationMillis = TabFadeInAnimationDuration,
delayMillis = TabFadeInAnimationDelay,
easing = LinearEasing
)
}
transition(fromState = true, toState = false) {
TabTintColor using tween(
durationMillis = TabFadeOutAnimationDuration,
easing = LinearEasing
)
}
}
}
val state = transition(transitionDefinition, selected)
Providers(ContentColorAmbient provides state[TabTintColor], children = content)
}
/**
* A [Layout] that positions [text] and an optional [icon] with the correct baseline distances. This
* Layout will either be [SmallTabHeight] or [LargeTabHeight] depending on its content, and then
* place the text and/or icon inside with the correct baseline alignment.
*/
@Composable
private fun TabBaselineLayout(
text: @Composable () -> Unit,
icon: @Composable () -> Unit
) {
Layout(
{
Box(
Modifier.layoutId("text"),
paddingStart = HorizontalTextPadding,
paddingEnd = HorizontalTextPadding,
children = text
)
Box(Modifier.layoutId("icon"), children = icon)
}
) { measurables, constraints ->
val textPlaceable = measurables.first { it.id == "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 = 0)
)
val iconPlaceable = measurables.first { it.id == "icon" }.measure(constraints)
val hasTextPlaceable =
textPlaceable.width != 0 && textPlaceable.height != 0
val hasIconPlaceable =
iconPlaceable.width != 0 && iconPlaceable.height != 0
val tabWidth = max(textPlaceable.width, iconPlaceable.width)
val tabHeight =
(if (hasTextPlaceable && hasIconPlaceable) LargeTabHeight else SmallTabHeight).toIntPx()
val firstBaseline = textPlaceable[FirstBaseline]
val lastBaseline = textPlaceable[LastBaseline]
layout(tabWidth, tabHeight) {
when {
hasTextPlaceable && hasIconPlaceable -> placeTextAndIcon(
density = this@Layout,
textPlaceable = textPlaceable,
iconPlaceable = iconPlaceable,
tabWidth = tabWidth,
tabHeight = tabHeight,
firstBaseline = requireNotNull(firstBaseline) { "No text baselines found" },
lastBaseline = requireNotNull(lastBaseline) { "No text baselines found" }
)
hasTextPlaceable -> placeText(
density = this@Layout,
textPlaceable = textPlaceable,
tabHeight = tabHeight,
firstBaseline = requireNotNull(firstBaseline) { "No text baselines found" },
lastBaseline = requireNotNull(lastBaseline) { "No text baselines found" }
)
hasIconPlaceable -> placeIcon(iconPlaceable, tabHeight)
else -> {
}
}
}
}
}
/**
* Places the provided [iconPlaceable] in the vertical center of the provided [tabHeight].
*/
private fun Placeable.PlacementScope.placeIcon(
iconPlaceable: Placeable,
tabHeight: Int
) {
val iconY = (tabHeight - iconPlaceable.height) / 2
iconPlaceable.place(0, iconY)
}
/**
* Places the provided [textPlaceable] offset from the bottom of the tab using the correct
* baseline offset.
*/
private fun Placeable.PlacementScope.placeText(
density: Density,
textPlaceable: Placeable,
tabHeight: Int,
firstBaseline: Int,
lastBaseline: Int
) {
val baselineOffset = if (firstBaseline == lastBaseline) {
SingleLineTextBaseline
} else {
DoubleLineTextBaseline
}
// Total offset between the last text baseline and the bottom of the Tab layout
val totalOffset = with(density) { baselineOffset.toIntPx() + IndicatorHeight.toIntPx() }
val textPlaceableY = tabHeight - lastBaseline - totalOffset
textPlaceable.place(0, textPlaceableY)
}
/**
* Places the provided [textPlaceable] offset from the bottom of the tab using the correct
* baseline offset, with the provided [iconPlaceable] placed above the text using the correct
* baseline offset.
*/
private fun Placeable.PlacementScope.placeTextAndIcon(
density: Density,
textPlaceable: Placeable,
iconPlaceable: Placeable,
tabWidth: Int,
tabHeight: Int,
firstBaseline: Int,
lastBaseline: Int
) {
val baselineOffset = if (firstBaseline == lastBaseline) {
SingleLineTextBaselineWithIcon
} else {
DoubleLineTextBaselineWithIcon
}
// Total offset between the last text baseline and the bottom of the Tab layout
val textOffset = with(density) { baselineOffset.toIntPx() + IndicatorHeight.toIntPx() }
// How much space there is between the top of the icon (essentially the top of this layout)
// and the top of the text layout's bounding box (not baseline)
val iconOffset = with(density) {
iconPlaceable.height + IconDistanceFromBaseline.toIntPx() - firstBaseline
}
val textPlaceableX = (tabWidth - textPlaceable.width) / 2
val textPlaceableY = tabHeight - lastBaseline - textOffset
textPlaceable.place(textPlaceableX, textPlaceableY)
val iconPlaceableX = (tabWidth - iconPlaceable.width) / 2
val iconPlaceableY = textPlaceableY - iconOffset
iconPlaceable.place(iconPlaceableX, iconPlaceableY)
}
// TabRow specifications
private val IndicatorHeight = 2.dp
private const val DividerOpacity = 0.12f
// How far from the start and end of a scrollable TabRow should the first Tab be displayed
private val ScrollableTabRowEdgeOffset = 52.dp
private val ScrollableTabRowMinimumTabWidth = 90.dp
// Tab specifications
private val SmallTabHeight = 48.dp
private val LargeTabHeight = 72.dp
// Tab transition specifications
private const val TabFadeInAnimationDuration = 150
private const val TabFadeInAnimationDelay = 100
private const val TabFadeOutAnimationDuration = 100
// The horizontal padding on the left and right of text
private val HorizontalTextPadding = 16.dp
// Distance from the top of the indicator to the text baseline when there is one line of text
private val SingleLineTextBaseline = 18.dp
// Distance from the top of the indicator to the text baseline when there is one line of text and an
// icon
private val SingleLineTextBaselineWithIcon = 14.dp
// Distance from the top of the indicator to the last text baseline when there are two lines of text
private val DoubleLineTextBaseline = 10.dp
// Distance from the top of the indicator to the last text baseline when there are two lines of text
// and an icon
private val DoubleLineTextBaselineWithIcon = 6.dp
// Distance from the first text baseline to the bottom of the icon in a combined tab
private val IconDistanceFromBaseline = 20.sp