[go: nahoru, domu]

blob: c447e22f7098387c186d967ca860bbdc6a97d030 [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.animation.FastOutSlowInEasing
import androidx.animation.LinearEasing
import androidx.animation.transitionDefinition
import androidx.compose.Composable
import androidx.compose.memo
import androidx.compose.state
import androidx.compose.unaryPlus
import androidx.ui.animation.ColorPropKey
import androidx.ui.animation.PxPropKey
import androidx.ui.animation.Transition
import androidx.ui.core.Alignment
import androidx.ui.core.FirstBaseline
import androidx.ui.core.IntPx
import androidx.ui.core.LastBaseline
import androidx.ui.core.Layout
import androidx.ui.core.Placeable
import androidx.ui.core.Px
import androidx.ui.core.Text
import androidx.ui.core.ambientDensity
import androidx.ui.core.coerceIn
import androidx.ui.core.dp
import androidx.ui.core.max
import androidx.ui.core.sp
import androidx.ui.core.toPx
import androidx.ui.core.withDensity
import androidx.ui.core.withTight
import androidx.ui.foundation.ColoredRect
import androidx.ui.foundation.HorizontalScroller
import androidx.ui.foundation.ScrollerPosition
import androidx.ui.foundation.SimpleImage
import androidx.ui.foundation.selection.MutuallyExclusiveSetItem
import androidx.ui.graphics.Color
import androidx.ui.layout.Container
import androidx.ui.layout.FlexRow
import androidx.ui.layout.Padding
import androidx.ui.layout.Stack
import androidx.ui.material.TabRow.TabPosition
import androidx.ui.material.ripple.Ripple
import androidx.ui.material.surface.Surface
import androidx.ui.graphics.Image
import androidx.ui.text.ParagraphStyle
import androidx.ui.text.style.TextAlign
/**
* 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 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 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,
scrollable: Boolean = false,
indicatorContainer: @Composable() (tabPositions: List<TabPosition>) -> Unit = { tabPositions ->
TabRow.IndicatorContainer(tabPositions, selectedIndex) {
TabRow.Indicator()
}
},
tab: @Composable() (Int, T) -> Unit
) {
Surface(color = +themeColor { primary }) {
val divider = TabRow.Divider
val tabs = @Composable {
items.forEachIndexed { index, item ->
tab(index, item)
}
}
WithExpandedWidth { width ->
// TODO: force scrollable for tabs that will be too small if they take up equal space?
if (scrollable) {
ScrollableTabRow(width, selectedIndex, tabs, divider, indicatorContainer)
} else {
FixedTabRow(width, items.size, tabs, divider, indicatorContainer)
}
}
}
}
@Composable
private fun FixedTabRow(
width: IntPx,
tabCount: Int,
tabs: @Composable() () -> Unit,
divider: @Composable() () -> Unit,
indicatorContainer: @Composable() (tabPositions: List<TabPosition>) -> Unit
) {
val tabWidth = width / tabCount
val tabPositions = +memo(tabCount, tabWidth) {
(0 until tabCount).map { index ->
val left = (tabWidth * index)
TabPosition(left, tabWidth)
}
}
Stack {
aligned(Alignment.Center) {
FlexRow {
expanded(1f, tabs)
}
}
aligned(Alignment.BottomCenter, children = divider)
positioned(0.dp, 0.dp, 0.dp, 0.dp) {
indicatorContainer(tabPositions)
}
}
}
@Composable
private fun ScrollableTabRow(
width: IntPx,
selectedIndex: Int,
tabs: @Composable() () -> Unit,
divider: @Composable() () -> Unit,
indicatorContainer: @Composable() (tabPositions: List<TabPosition>) -> Unit
) {
val edgeOffset = withDensity(+ambientDensity()) { ScrollableTabRowEdgeOffset.toIntPx() }
// TODO: unfortunate 1f lag as we need to first calculate tab positions before drawing the
// indicator container
var tabPositions by +state { listOf<TabPosition>() }
val scrollableTabData = +memo {
ScrollableTabData(selectedIndex, tabPositions, width, edgeOffset)
}
scrollableTabData.selectedTab = selectedIndex
scrollableTabData.tabPositions = tabPositions
scrollableTabData.visibleWidth = width
val indicator = @Composable {
// Delay indicator composition until we know tab positions
if (tabPositions.isNotEmpty()) {
indicatorContainer(tabPositions)
}
}
HorizontalScroller(
scrollerPosition = scrollableTabData.position,
onScrollPositionChanged = { position, _ ->
scrollableTabData.position.value = position
scrollableTabData.currentScrollPosition = position
}
) {
Layout(tabs, indicator, divider) { measurables, constraints ->
val tabPlaceables = mutableListOf<Pair<Placeable, IntPx>>()
val minTabWidth = ScrollableTabRowMinimumTabWidth.toIntPx()
var layoutHeight = IntPx.Zero
val tabConstraints = constraints.copy(minWidth = minTabWidth)
val newTabPositions = mutableListOf<TabPosition>()
val layoutWidth = measurables[tabs].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, width = placeable.width))
sum + placeable.width
} + edgeOffset
if (tabPositions != newTabPositions) {
tabPositions = newTabPositions
}
// Position the children.
layout(layoutWidth, layoutHeight) {
// Place the tabs
tabPlaceables.forEach { (placeable, left) ->
placeable.place(left, IntPx.Zero)
}
// 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[divider].firstOrNull()
?.measure(constraints.withTight(width = layoutWidth))
?.run { place(IntPx.Zero, 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[indicator].firstOrNull()
?.measure(constraints.withTight(width = layoutWidth, height = layoutHeight))
?.place(IntPx.Zero, IntPx.Zero)
}
}
}
}
/**
* Class holding onto state needed for [ScrollableTabRow]
*/
private class ScrollableTabData(
initialSelectedTab: Int,
var tabPositions: List<TabPosition>,
var visibleWidth: IntPx,
val edgeOffset: IntPx
) {
var selectedTab: Int = initialSelectedTab
set(value) {
if (field != value && !isTabFullyVisible(value)) {
val calculatedOffset = calculateTabOffset(value)
position.smoothScrollTo(calculatedOffset)
}
field = value
}
val position = ScrollerPosition()
// Need to use a separate var here - directly consuming position.value will cause recompositions
// as it is an @Model class
var currentScrollPosition: Px = Px.Zero
private fun isTabFullyVisible(index: Int): Boolean {
val tabPosition = tabPositions[index]
val leftEdgeStart = currentScrollPosition
val leftEdgeVisible = leftEdgeStart <= tabPosition.left.toPx()
val rightEdgeEnd = leftEdgeStart + visibleWidth
val rightEdgeVisible = rightEdgeEnd >= tabPosition.right.toPx()
return leftEdgeVisible && rightEdgeVisible
}
// TODO: this currently always centers the tab (if possible), should we support only scrolling
// until the tab just becomes visible?
private fun calculateTabOffset(index: Int): Px {
val tabPosition = tabPositions[index]
val tabOffset = tabPosition.left
val scrollerCenter = visibleWidth / 2
val tabWidth = tabPosition.width
val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2)
val totalTabRowWidth = tabPositions.last().right + edgeOffset
return centeredTabOffset.coerceIn(IntPx.Zero, totalTabRowWidth - visibleWidth).toPx()
}
}
// TODO: cleanup when expanded width layouts are supported natively b/140408477
/**
* A layout that is sized according to its [child]'s height, and all the available width.
*/
@Composable
private fun WithExpandedWidth(child: @Composable() (width: IntPx) -> Unit) {
// TODO: unfortunate 1f lag as we need to first measure total width and then recompose so the
// tab row knows the correct width
var widthState by +state { IntPx.Zero }
Layout({ child(widthState) }) { measurables, constraints ->
val width = constraints.maxWidth
if (widthState != width) widthState = width
val placeable = measurables.first().measure(constraints)
val height = placeable.height
layout(width, height) {
placeable.place(IntPx.Zero, IntPx.Zero)
}
}
}
object TabRow {
private val IndicatorOffset = PxPropKey()
/**
* 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
*/
data class TabPosition internal constructor(val left: IntPx, val width: IntPx) {
val right: IntPx 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 = withDensity(+ambientDensity()) {
tabPositions[selectedIndex].width.toDp()
}
Container(expanded = true, alignment = Alignment.BottomLeft) {
IndicatorTransition(tabPositions, selectedIndex) { indicatorOffset ->
Padding(left = withDensity(+ambientDensity()) { indicatorOffset.toDp() }) {
Container(width = currentTabWidth, children = indicator)
}
}
}
}
/**
* Default indicator, which will be positioned at the bottom of the tab, on top of the divider.
*
* This is used as the default indicator inside [TabRow].
*/
@Composable
fun Indicator() {
ColoredRect(color = +themeColor { onPrimary }, height = IndicatorHeight)
}
/**
* [Transition] that animates the indicator offset between a given list of [TabPosition]s.
*/
@Composable
internal fun IndicatorTransition(
tabPositions: List<TabPosition>,
selectedIndex: Int,
children: @Composable() (indicatorOffset: Px) -> Unit
) {
val transitionDefinition = +memo(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.toPx()
}
tabPositions.forEachIndexed { index, position ->
if (index != selectedIndex) {
state(index) {
this[IndicatorOffset] = position.left.toPx()
}
}
}
transition {
IndicatorOffset using tween {
duration = 250
easing = FastOutSlowInEasing
}
}
}
}
Transition(transitionDefinition, selectedIndex) { state ->
children(state[IndicatorOffset])
}
}
internal val Divider = @Composable {
val onPrimary = +themeColor { onPrimary }
Divider(color = (onPrimary.copy(alpha = DividerOpacity)))
}
}
/**
* A Tab represents a single page of content using a text label and/or image. It represents its
* selected state by tinting the text label and/or image with [MaterialColors.onPrimary].
*
* This should typically be used inside of a [TabRow], see the corresponding documentation for
* example usage.
*
* @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
*/
@Composable
fun Tab(text: String? = null, icon: Image? = null, selected: Boolean, onSelected: () -> Unit) {
val tint = +themeColor { onPrimary }
when {
text != null && icon != null -> CombinedTab(text, icon, selected, onSelected, tint)
text != null -> TextTab(text, selected, onSelected, tint)
icon != null -> IconTab(icon, selected, onSelected, tint)
// Nothing provided here (?!), so let's just draw an empty tab that handles clicks
else -> BaseTab(selected, onSelected, {})
}
}
/**
* A base Tab that displays some content inside of a clickable ripple.
*
* Also handles setting the correct semantic properties for accessibility purposes.
*
* @param selected whether this tab is selected or not, this is used to set the correct semantics
* @param onSelected the callback to be invoked when this tab is selected
* @param children the composable content to be displayed inside of this Tab
*/
@Composable
private fun BaseTab(selected: Boolean, onSelected: () -> Unit, children: @Composable() () -> Unit) {
Ripple(bounded = true) {
MutuallyExclusiveSetItem(selected = selected, onClick = onSelected, children = children)
}
}
/**
* A Tab that contains a text label, and represents its selected state by tinting the text label
* with [tint].
*
* @param text the text label 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 tint the color that will be used to tint the text label
*/
@Composable
private fun TextTab(text: String, selected: Boolean, onSelected: () -> Unit, tint: Color) {
BaseTab(selected = selected, onSelected = onSelected) {
Container(height = SmallTabHeight) {
TabTransition(color = tint, selected = selected) { tabTintColor ->
TabTextBaselineLayout {
TabText(text, tabTintColor)
}
}
}
}
}
/**
* A Tab that contains an icon, and represents its selected state by tinting the icon with [tint].
*
* @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 tint the color that will be used to tint the icon
*/
@Composable
private fun IconTab(icon: Image, selected: Boolean, onSelected: () -> Unit, tint: Color) {
BaseTab(selected = selected, onSelected = onSelected) {
Container(height = SmallTabHeight) {
TabTransition(color = tint, selected = selected) { tabTintColor ->
TabIcon(icon, tabTintColor)
}
}
}
}
/**
* A Tab that contains a text label and an icon, and represents its selected state by tinting the
* text label and icon with [tint].
*
* @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 tint the color that will be used to tint the text label and icon
*/
@Composable
private fun CombinedTab(
text: String,
icon: Image,
selected: Boolean,
onSelected: () -> Unit,
tint: Color
) {
BaseTab(selected = selected, onSelected = onSelected) {
Container(height = LargeTabHeight) {
TabTransition(color = tint, selected = selected) { tabTintColor ->
TabTextBaselineLayout(
icon = { TabIcon(icon, tabTintColor) },
text = { TabText(text, tabTintColor) }
)
}
}
}
}
private val TabTintColor = ColorPropKey()
/**
* [Transition] defining how the tint color opacity for a tab animates, when a new tab
* is selected.
*/
@Composable
private fun TabTransition(
color: Color,
selected: Boolean,
children: @Composable() (color: Color) -> Unit
) {
val transitionDefinition = +memo(color) {
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(selected) {
this[TabTintColor] = if (selected) color else color.copy(alpha = InactiveTabOpacity)
}
state(!selected) {
this[TabTintColor] =
if (!selected) color else color.copy(alpha = InactiveTabOpacity)
}
transition(toState = false, fromState = true) {
TabTintColor using tween {
duration = TabFadeInAnimationDuration
delay = TabFadeInAnimationDelay
easing = LinearEasing
}
}
transition(fromState = true, toState = false) {
TabTintColor using tween {
duration = TabFadeOutAnimationDuration
easing = LinearEasing
}
}
}
}
Transition(transitionDefinition, selected) { state ->
children(state[TabTintColor])
}
}
@Composable
private fun TabText(text: String, color: Color) {
val buttonTextStyle = +themeTextStyle { button }
Padding(left = HorizontalTextPadding, right = HorizontalTextPadding) {
Text(
text = text,
style = buttonTextStyle.copy(color = color),
paragraphStyle = ParagraphStyle(TextAlign.Center),
maxLines = TextLabelMaxLines
)
}
}
/**
* A [Layout] that positions [text] and an optional [icon] with the correct baseline distances. This
* Layout will expand to fit the full height of the tab, and then place the text and icon positioned
* correctly from the bottom edge of the tab.
*/
@Composable
private fun TabTextBaselineLayout(
icon: @Composable() (() -> Unit) = {},
text: @Composable() () -> Unit
) {
Layout(text, icon) { measurables, constraints ->
require(measurables[text].isNotEmpty()) { "No text found" }
val textPlaceable = measurables[text].first().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)
)
val firstBaseline =
requireNotNull(textPlaceable[FirstBaseline]) { "No text baselines found" }
val lastBaseline =
requireNotNull(textPlaceable[LastBaseline]) { "No text baselines found" }
val iconPlaceable = measurables[icon].firstOrNull()?.measure(constraints)
// Total offset from the bottom of this layout to the last text baseline
val baselineOffset = if (firstBaseline == lastBaseline) {
if (iconPlaceable == null) {
SingleLineTextBaseline
} else {
SingleLineTextBaselineWithIcon
}
} else {
if (iconPlaceable == null) {
DoubleLineTextBaseline
} else {
DoubleLineTextBaselineWithIcon
}
}.toIntPx() + IndicatorHeight.toIntPx()
val textHeight = textPlaceable.height
// How much space there is between the bottom of the text layout's bounding box (not
// baseline) and the bottom of this layout.
val textOffsetBelow = baselineOffset - textHeight + lastBaseline
if (iconPlaceable == null) {
val containerWidth = textPlaceable.width
val contentHeight = textPlaceable.height + textOffsetBelow
val containerHeight = constraints.maxHeight
layout(containerWidth, containerHeight) {
val textPlaceableY = containerHeight - contentHeight
textPlaceable.place(IntPx.Zero, textPlaceableY)
}
} else {
// How much space there is between the top of the text layout's bounding box (not
// baseline) and the top of the icon (essentially the top of this layout).
val textOffsetAbove =
iconPlaceable.height + IconDistanceFromBaseline.toIntPx() - firstBaseline
// Calculate the size of the AlignmentLineOffset widget & define layout.
val containerWidth = max(textPlaceable.width, iconPlaceable.width)
val contentHeight = textOffsetAbove + textPlaceable.height + textOffsetBelow
val containerHeight = constraints.maxHeight
layout(containerWidth, containerHeight) {
val iconPlaceableX = (containerWidth - iconPlaceable.width) / 2
val iconPlaceableY = containerHeight - contentHeight
iconPlaceable.place(iconPlaceableX, iconPlaceableY)
val textPlaceableX = (containerWidth - textPlaceable.width) / 2
val textPlaceableY = iconPlaceableY + textOffsetAbove
textPlaceable.place(textPlaceableX, textPlaceableY)
}
}
}
}
@Composable
private fun TabIcon(icon: Image, tint: Color) {
Container(width = IconDiameter, height = IconDiameter) {
SimpleImage(icon, tint)
}
}
// TabRow specifications
private val IndicatorHeight = 2.dp
private 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
private val InactiveTabOpacity = 0.74f
private val TextLabelMaxLines = 2
// Tab transition specifications
private val TabFadeInAnimationDuration = 150
private val TabFadeInAnimationDelay = 100
private val TabFadeOutAnimationDuration = 100
// The horizontal padding on the left and right of text
private val HorizontalTextPadding = 16.dp
private val IconDiameter = 24.dp
// Distance from the top of the indicator to the text baseline when there is one line of text
private val SingleLineTextBaseline = 18.sp
// 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.sp
// Distance from the top of the indicator to the last text baseline when there are two lines of text
private val DoubleLineTextBaseline = 10.sp
// 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.sp
// Distance from the first text baseline to the bottom of the icon in a combined tab
private val IconDistanceFromBaseline = 20.sp