| /* |
| * Copyright 2022 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.tv.material |
| |
| import androidx.compose.animation.animateColorAsState |
| import androidx.compose.animation.core.animateDpAsState |
| import androidx.compose.foundation.background |
| import androidx.compose.foundation.horizontalScroll |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Spacer |
| import androidx.compose.foundation.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.height |
| import androidx.compose.foundation.layout.offset |
| import androidx.compose.foundation.layout.width |
| import androidx.compose.foundation.layout.wrapContentSize |
| import androidx.compose.foundation.rememberScrollState |
| import androidx.compose.foundation.selection.selectableGroup |
| import androidx.compose.foundation.shape.RoundedCornerShape |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.CompositionLocalProvider |
| import androidx.compose.runtime.compositionLocalOf |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Alignment |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.draw.clipToBounds |
| import androidx.compose.ui.focus.onFocusChanged |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.layout.Placeable |
| import androidx.compose.ui.layout.SubcomposeLayout |
| import androidx.compose.ui.unit.Constraints |
| import androidx.compose.ui.unit.Density |
| import androidx.compose.ui.unit.DpRect |
| import androidx.compose.ui.unit.dp |
| import androidx.compose.ui.unit.height |
| import androidx.compose.ui.unit.width |
| import androidx.compose.ui.zIndex |
| |
| /** |
| * TV-Material Design Horizontal TabRow |
| * |
| * Display all tabs in a set simultaneously and if the tabs exceed the container size, it has |
| * scrolling to navigate to next tab. They are best for switching between related content quickly, |
| * such as between transportation methods in a map. To navigate between tabs, use d-pad left or |
| * d-pad right when focused. |
| * |
| * A TvTabRow contains a row of []s, and displays an indicator underneath the currently selected |
| * tab. A TvTabRow places its tabs offset from the starting edge, and allows scrolling to tabs that |
| * are placed off screen. |
| * |
| * Examples: |
| * @sample androidx.tv.samples.PillIndicatorTabRow |
| * @sample androidx.tv.samples.UnderlinedIndicatorTabRow |
| * @sample androidx.tv.samples.TabRowWithDebounce |
| * @sample androidx.tv.samples.OnClickNavigation |
| * |
| * @param selectedTabIndex the index of the currently selected tab |
| * @param modifier the [Modifier] to be applied to this tab row |
| * @param containerColor the color used for the background of this tab row |
| * @param contentColor the primary color used in the tabs |
| * @param separator use this composable to add a separator between the tabs |
| * @param indicator used to indicate which tab is currently selected and/or focused |
| * @param tabs a composable which will render all the tabs |
| */ |
| @Composable |
| fun TabRow( |
| selectedTabIndex: Int, |
| modifier: Modifier = Modifier, |
| containerColor: Color = TabRowDefaults.ContainerColor, |
| contentColor: Color = TabRowDefaults.contentColor(), |
| separator: @Composable () -> Unit = { TabRowDefaults.TabSeparator() }, |
| indicator: @Composable (tabPositions: List<DpRect>) -> Unit = |
| @Composable { tabPositions -> |
| tabPositions.getOrNull(selectedTabIndex)?.let { |
| TabRowDefaults.PillIndicator(currentTabPosition = it) |
| } |
| }, |
| tabs: @Composable () -> Unit |
| ) { |
| val scrollState = rememberScrollState() |
| var isAnyTabFocused by remember { mutableStateOf(false) } |
| |
| CompositionLocalProvider( |
| LocalTabRowHasFocus provides isAnyTabFocused, |
| LocalContentColor provides contentColor |
| ) { |
| SubcomposeLayout( |
| modifier = |
| modifier |
| .background(containerColor) |
| .clipToBounds() |
| .horizontalScroll(scrollState) |
| .onFocusChanged { isAnyTabFocused = it.hasFocus } |
| .selectableGroup() |
| ) { constraints -> |
| // Tab measurables |
| val tabMeasurables = subcompose(TabRowSlots.Tabs, tabs) |
| |
| // Tab placeables |
| val tabPlaceables = |
| tabMeasurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } |
| val tabsCount = tabMeasurables.size |
| val separatorsCount = tabsCount - 1 |
| |
| // Separators |
| val separators = @Composable { repeat(separatorsCount) { separator() } } |
| val separatorMeasurables = subcompose(TabRowSlots.Separator, separators) |
| val separatorPlaceables = |
| separatorMeasurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } |
| val separatorWidth = separatorPlaceables.firstOrNull()?.width ?: 0 |
| |
| val layoutWidth = tabPlaceables.sumOf { it.width } + separatorsCount * separatorWidth |
| val layoutHeight = |
| (tabMeasurables.maxOfOrNull { it.maxIntrinsicHeight(Constraints.Infinity) } ?: 0) |
| .coerceAtLeast(0) |
| |
| // Position the children |
| layout(layoutWidth, layoutHeight) { |
| |
| // Place the tabs |
| val tabPositions = mutableListOf<DpRect>() |
| var left = 0 |
| tabPlaceables.forEachIndexed { index, tabPlaceable -> |
| // place the tab |
| tabPlaceable.placeRelative(left, 0) |
| |
| tabPositions.add( |
| this@SubcomposeLayout.buildTabPosition(placeable = tabPlaceable, initialLeft = left) |
| ) |
| left += tabPlaceable.width |
| |
| // place the separator |
| if (tabPlaceables.lastIndex != index) { |
| separatorPlaceables[index].placeRelative(left, 0) |
| } |
| |
| left += separatorWidth |
| } |
| |
| // Place the indicator |
| subcompose(TabRowSlots.Indicator) { indicator(tabPositions) } |
| .forEach { it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0) } |
| } |
| } |
| } |
| } |
| |
| object TabRowDefaults { |
| /** Color of the background of a tab */ |
| val ContainerColor = Color.Transparent |
| |
| /** Space between tabs in the tab row */ |
| @Composable |
| fun TabSeparator() { |
| Spacer(modifier = Modifier.width(20.dp)) |
| } |
| |
| /** Default accent color for the TabRow */ |
| // TODO: Use value from a theme |
| @Composable fun contentColor(): Color = Color(0xFFC9C5D0) |
| |
| /** |
| * Adds a pill indicator behind the tab |
| * |
| * @param currentTabPosition position of the current selected tab |
| * @param modifier modifier to be applied to the indicator |
| * @param activeColor color of indicator when [TabRow] is active |
| * @param inactiveColor color of indicator when [TabRow] is inactive |
| */ |
| @Composable |
| fun PillIndicator( |
| currentTabPosition: DpRect, |
| modifier: Modifier = Modifier, |
| activeColor: Color = Color(0xFFE5E1E6), |
| inactiveColor: Color = Color(0xFF484362).copy(alpha = 0.4f) |
| ) { |
| val anyTabFocused = LocalTabRowHasFocus.current |
| val width by animateDpAsState(targetValue = currentTabPosition.width) |
| val height = currentTabPosition.height |
| val leftOffset by animateDpAsState(targetValue = currentTabPosition.left) |
| val topOffset = currentTabPosition.top |
| |
| val pillColor by |
| animateColorAsState(targetValue = if (anyTabFocused) activeColor else inactiveColor) |
| |
| Box( |
| modifier |
| .fillMaxWidth() |
| .wrapContentSize(Alignment.BottomStart) |
| .offset(x = leftOffset, y = topOffset) |
| .width(width) |
| .height(height) |
| .background(color = pillColor, shape = RoundedCornerShape(50)) |
| .zIndex(-1f) |
| ) |
| } |
| |
| /** |
| * Adds an underlined indicator below the tab |
| * |
| * @param currentTabPosition position of the current selected tab |
| * @param modifier modifier to be applied to the indicator |
| * @param activeColor color of indicator when [TabRow] is active |
| * @param inactiveColor color of indicator when [TabRow] is inactive |
| */ |
| @Composable |
| fun UnderlinedIndicator( |
| currentTabPosition: DpRect, |
| modifier: Modifier = Modifier, |
| activeColor: Color = Color(0xFFC9BFFF), |
| inactiveColor: Color = Color(0xFFC9C2E8) |
| ) { |
| val anyTabFocused = LocalTabRowHasFocus.current |
| val unfocusedUnderlineWidth = 10.dp |
| val indicatorHeight = 2.dp |
| val width by |
| animateDpAsState( |
| targetValue = if (anyTabFocused) currentTabPosition.width else unfocusedUnderlineWidth |
| ) |
| val leftOffset by |
| animateDpAsState( |
| targetValue = |
| if (anyTabFocused) { |
| currentTabPosition.left |
| } else { |
| val tabCenter = currentTabPosition.left + currentTabPosition.width / 2 |
| tabCenter - unfocusedUnderlineWidth / 2 |
| } |
| ) |
| |
| val underlineColor by |
| animateColorAsState(targetValue = if (anyTabFocused) activeColor else inactiveColor) |
| |
| Box( |
| modifier |
| .fillMaxWidth() |
| .wrapContentSize(Alignment.BottomStart) |
| .offset(x = leftOffset) |
| .width(width) |
| .height(indicatorHeight) |
| .background(color = underlineColor) |
| ) |
| } |
| } |
| |
| /** A provider to store whether any [Tab] is focused inside the [TabRow] */ |
| internal val LocalTabRowHasFocus = compositionLocalOf { false } |
| |
| /** Slots for [TabRow]'s content */ |
| private enum class TabRowSlots { |
| Tabs, |
| Indicator, |
| Separator |
| } |
| |
| /** Builds TabPosition based on placeable */ |
| private fun Density.buildTabPosition( |
| placeable: Placeable, |
| initialLeft: Int = 0, |
| initialTop: Int = 0, |
| ): DpRect = |
| DpRect( |
| left = initialLeft.toDp(), |
| right = (initialLeft + placeable.width).toDp(), |
| top = initialTop.toDp(), |
| bottom = (initialTop + placeable.height).toDp(), |
| ) |