diff --git a/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/Tab.kt b/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/Tab.kt index 2cfbfd9..195ef9f 100644 --- a/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/Tab.kt +++ b/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/Tab.kt @@ -23,4 +23,469 @@ package com.highcapable.flexiui.component -// TODO: To be implemented \ No newline at end of file +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import com.highcapable.flexiui.LocalColors +import com.highcapable.flexiui.LocalShapes +import com.highcapable.flexiui.LocalSizes +import com.highcapable.flexiui.extension.horizontal +import com.highcapable.flexiui.extension.orElse +import com.highcapable.flexiui.extension.status +import com.highcapable.flexiui.interaction.rippleClickable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Immutable +data class TabColors( + val indicatorColor: Color, + val selectedContentColor: Color, + val unselectedContentColor: Color +) + +@Immutable +data class TabStyle( + val contentPadding: PaddingValues, + val contentShape: Shape, + val indicatorWidth: Dp, + val indicatorHeight: Dp, + val indicatorShape: Shape +) + +@Composable +fun TabRow( + selectedTabIndex: Int = 0, + modifier: Modifier = Modifier, + colors: TabColors = Tab.colors, + style: TabStyle = Tab.style, + tabs: @Composable () -> Unit +) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = modifier, + colors = colors, + style = style, + pagerState = null, + tabs = tabs + ) +} + +@Composable +fun TabRow( + selectedTabIndex: Int = 0, + modifier: Modifier = Modifier, + colors: TabColors = Tab.colors, + style: TabStyle = Tab.style, + pagerState: PagerState?, + tabs: @Composable () -> Unit +) { + TabStyleBox(modifier, colors, style) { + SubcomposeLayout(Modifier.fillMaxWidth().selectableGroup()) { constraints -> + val maximumConstraints = Constraints() + val tabRowWidth = constraints.maxWidth + val tabMeasurables = subcompose(TabSlots.Tabs, tabs) + val tabAverageMeasurables = subcompose(TabSlots.TabsAverage, tabs) + val tabCount = tabAverageMeasurables.size + val tabAverageWidth = (tabRowWidth / tabCount) + val tabPlaceables = tabMeasurables.map { it.measure(maximumConstraints) } + val tabAveragePlaceables = tabAverageMeasurables.map { + it.measure(constraints.copy(minWidth = tabAverageWidth, maxWidth = tabAverageWidth)) + } + val tabRowHeight = tabAveragePlaceables.maxByOrNull { it.height }?.height ?: 0 + val tabPositions = List(tabCount) { index -> + val tabWidth = tabPlaceables[index].width - style.contentPadding.horizontal.toPx() + TabPosition(tabAverageWidth.toDp() * index, tabAverageWidth.toDp(), tabWidth.toDp()) + } + layout(tabRowWidth, tabRowHeight) { + tabAveragePlaceables.forEachIndexed { index, placeable -> + placeable.placeRelative(x = index * tabAverageWidth, y = 0) + } + subcompose(TabSlots.Indicator) { + TabIndicator(selectedTabIndex, colors, style, pagerState, tabPositions) + }.forEach { + it.measure(Constraints.fixed(tabRowWidth, tabRowHeight)).placeRelative(x = 0, y = 0) + } + } + } + } +} + +@Composable +fun ScrollableTabRow( + selectedTabIndex: Int = 0, + modifier: Modifier = Modifier, + colors: TabColors = Tab.colors, + style: TabStyle = Tab.style, + scrollState: ScrollState = rememberScrollState(), + tabs: @Composable () -> Unit +) { + ScrollableTabRow( + selectedTabIndex = selectedTabIndex, + modifier = modifier, + colors = colors, + style = style, + pagerState = null, + scrollState = scrollState, + tabs = tabs + ) +} + +@Composable +fun ScrollableTabRow( + selectedTabIndex: Int = 0, + modifier: Modifier = Modifier, + colors: TabColors = Tab.colors, + style: TabStyle = Tab.style, + pagerState: PagerState?, + scrollState: ScrollState = rememberScrollState(), + tabs: @Composable () -> Unit +) { + TabStyleBox(modifier, colors, style) { + val scrollableTabData = rememberScrollableTabData(scrollState) + SubcomposeLayout( + modifier = Modifier.fillMaxWidth() + .wrapContentSize(align = Alignment.CenterStart) + .horizontalScroll(scrollState) + .selectableGroup() + .clipToBounds() + ) { constraints -> + val maximumConstraints = Constraints() + val tabMeasurables = subcompose(TabSlots.Tabs, tabs) + val tabAverageMeasurables = subcompose(TabSlots.TabsAverage, tabs) + val tabPlaceables = tabMeasurables.map { it.measure(maximumConstraints) } + val tabAveragePlaceables = tabAverageMeasurables.map { it.measure(constraints) } + var layoutWidth = 0 + var layoutHeight = 0 + tabAveragePlaceables.forEach { + layoutWidth += it.width + layoutHeight = maxOf(layoutHeight, it.height) + } + layout(layoutWidth, layoutHeight) { + var tabLeft = 0 + val tabPositions = mutableListOf() + tabAveragePlaceables.forEachIndexed { index, placeables -> + val tabWidth = tabPlaceables[index].width - style.contentPadding.horizontal.toPx() + placeables.placeRelative(x = tabLeft, y = 0) + tabPositions.add(TabPosition(tabLeft.toDp(), placeables.width.toDp(), tabWidth.toDp())) + tabLeft += placeables.width + } + subcompose(TabSlots.Indicator) { + TabIndicator(selectedTabIndex, colors, style, pagerState, tabPositions) + }.forEach { + it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(x = 0, y = 0) + } + scrollableTabData.onLaidOut( + density = this@SubcomposeLayout, + tabPositions = tabPositions, + selectedTab = selectedTabIndex + ) + } + } + } +} + +@Composable +fun Tab( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selectedContentColor: Color = Color.Unspecified, + unselectedContentColor: Color = Color.Unspecified, + contentPadding: PaddingValues? = null, + contentShape: Shape? = null, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit +) { + val currentSelectedContentColor = selectedContentColor.orElse() + ?: LocalTabSelectedContentColor.current.orElse() ?: Tab.colors.selectedContentColor + val currentUnselectedContentColor = unselectedContentColor.orElse() + ?: LocalTabUnselectedContentColor.current.orElse() ?: Tab.colors.unselectedContentColor + val currentContentPadding = contentPadding ?: LocalTabContentPadding.current ?: Tab.style.contentPadding + val currentContentShape = contentShape ?: LocalTabContentShape.current ?: Tab.style.contentShape + val contentColor by animateColorAsState(if (selected) currentSelectedContentColor else currentUnselectedContentColor) + val contentStyle = LocalTextStyle.current.default(contentColor) + CompositionLocalProvider( + LocalIconTint provides contentColor, + LocalTextStyle provides contentStyle + ) { + Row( + modifier = Modifier.status(enabled) + .clip(currentContentShape) + .then(modifier) + .rippleClickable( + enabled = enabled, + onClick = onClick, + role = Role.Tab, + interactionSource = interactionSource + ) + .padding(currentContentPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } +} + +@Composable +private fun TabIndicator( + selectedTabIndex: Int, + colors: TabColors, + style: TabStyle, + pagerState: PagerState?, + tabPositions: List +) { + val indicatorModifier = pagerState?.let { Modifier.pagerTabIndicatorOffset(it, tabPositions, style.indicatorWidth) } + ?: Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex], style.indicatorWidth) + Box( + modifier = Modifier.then(indicatorModifier) + .height(style.indicatorHeight) + .background(colors.indicatorColor, style.indicatorShape) + ) +} + +@Composable +private fun TabStyleBox( + modifier: Modifier, + colors: TabColors, + style: TabStyle, + content: @Composable () -> Unit +) { + Box(modifier = modifier) { + CompositionLocalProvider( + LocalTabSelectedContentColor provides colors.selectedContentColor, + LocalTabUnselectedContentColor provides colors.unselectedContentColor, + LocalTabContentPadding provides style.contentPadding, + content = content + ) + } +} + +@Composable +private fun rememberScrollableTabData(scrollState: ScrollState): ScrollableTabData { + val coroutineScope = rememberCoroutineScope() + return remember(scrollState, coroutineScope) { ScrollableTabData(scrollState, coroutineScope) } +} + +private fun Modifier.tabIndicatorOffset( + currentTabPosition: TabPosition, + indicatorWidth: Dp +) = composed( + inspectorInfo = debugInspectorInfo { + name = "tabIndicatorOffset" + properties["currentTabPosition"] = currentTabPosition + properties["indicatorWidth"] = indicatorWidth + } +) { + val currentWidth = indicatorWidth.orElse() ?: currentTabPosition.tabWidth + val animatedWidh by animateDpAsState( + targetValue = currentWidth, + animationSpec = tween(DefaultTabIndicatorDuration, easing = FastOutSlowInEasing) + ) + val animatedOffsetX by animateDpAsState( + targetValue = currentTabPosition.calculateCenter(currentWidth), + animationSpec = tween(DefaultTabIndicatorDuration, easing = FastOutSlowInEasing) + ) + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset(x = animatedOffsetX) + .width(animatedWidh) +} + +private fun Modifier.pagerTabIndicatorOffset( + pagerState: PagerState, + tabPositions: List, + indicatorWidth: Dp +) = composed( + inspectorInfo = debugInspectorInfo { + name = "pagerTabIndicatorOffset" + properties["pagerState"] = pagerState + properties["tabPositions"] = tabPositions + properties["indicatorWidth"] = indicatorWidth + } +) { + layout { measurable, constraints -> + // If there are no pages, nothing to show. + if (tabPositions.isEmpty()) return@layout layout(constraints.maxWidth, 0) {} + val currentPage = minOf(tabPositions.lastIndex, pagerState.currentPage) + val currentTab = tabPositions[currentPage] + val previousTab = tabPositions.getOrNull(currentPage - 1) + val nextTab = tabPositions.getOrNull(currentPage + 1) + val currentWidth = indicatorWidth.orElse() ?: currentTab.tabWidth + val nextWidth = indicatorWidth.orElse() ?: nextTab?.tabWidth ?: currentWidth + val previousWidth = indicatorWidth.orElse() ?: previousTab?.tabWidth ?: currentWidth + val fraction = pagerState.currentPageOffsetFraction + // Calculate the width of the indicator from the current and next / previous tab. + val movableWidth = when { + fraction > 0 && nextTab != null -> lerp(currentWidth, nextWidth, fraction) + fraction < 0 && previousTab != null -> lerp(currentWidth, previousWidth, -fraction) + else -> currentWidth + }.roundToPx() + // Calculate the offset X of the indicator from the current and next / previous tab. + val movableOffsetX = when { + fraction > 0 && nextTab != null -> + lerp(currentTab.calculateCenter(currentWidth), nextTab.calculateCenter(nextWidth), fraction) + fraction < 0 && previousTab != null -> + lerp(currentTab.calculateCenter(currentWidth), previousTab.calculateCenter(previousWidth), -fraction) + else -> currentTab.calculateCenter(currentWidth) + }.roundToPx() + val placeable = measurable.measure( + Constraints( + minWidth = movableWidth, + maxWidth = movableWidth, + minHeight = 0, + maxHeight = constraints.maxHeight + ) + ) + val offsetY = maxOf(constraints.minHeight - placeable.height, 0) + val measureWidth = constraints.maxWidth + val measureHeight = maxOf(placeable.height, constraints.minHeight) + layout(measureWidth, measureHeight) { placeable.placeRelative(movableOffsetX, offsetY) } + } +} + +@Stable +private class ScrollableTabData(private val scrollState: ScrollState, private val coroutineScope: CoroutineScope) { + + private var selectedTab: Int? = null + + fun onLaidOut(density: Density, tabPositions: List, selectedTab: Int) { + // Animate if the new tab is different from the old tab, or this is called for the first + // time (i.e selectedTab is `null`). + 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, tabPositions) + if (scrollState.value != calculatedOffset) + coroutineScope.launch { + scrollState.animateScrollTo( + value = calculatedOffset, + animationSpec = tween(DefaultTabIndicatorDuration, easing = FastOutSlowInEasing) + ) + } + } + } + } + + /** + * @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, tabPositions: List): Int = + with(density) { + val totalTabRowWidth = tabPositions.last().right.roundToPx() + val visibleWidth = totalTabRowWidth - scrollState.maxValue + val tabOffset = left.roundToPx() + val scrollerCenter = visibleWidth / 2 + val tabWidth = tabWidth.roundToPx() + 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) + } +} + +@Immutable +private data class TabPosition(val left: Dp, val width: Dp, val tabWidth: Dp) { + + val right get() = left + width + + fun calculateCenter(currentWidth: Dp) = left + width / 2 - currentWidth / 2 +} + +@Stable +private enum class TabSlots { Tabs, TabsAverage, Indicator } + +object Tab { + val colors: TabColors + @Composable + @ReadOnlyComposable + get() = defaultTabColors() + val style: TabStyle + @Composable + @ReadOnlyComposable + get() = defaultTabStyle() +} + +private val LocalTabSelectedContentColor = compositionLocalOf { Color.Unspecified } + +private val LocalTabUnselectedContentColor = compositionLocalOf { Color.Unspecified } + +private val LocalTabContentPadding = compositionLocalOf { null } + +private val LocalTabContentShape = compositionLocalOf { null } + +@Composable +@ReadOnlyComposable +private fun defaultTabColors() = TabColors( + indicatorColor = LocalColors.current.themePrimary, + selectedContentColor = LocalColors.current.themePrimary, + unselectedContentColor = LocalColors.current.textSecondary +) + +@Composable +@ReadOnlyComposable +private fun defaultTabStyle() = TabStyle( + contentPadding = PaddingValues( + horizontal = LocalSizes.current.spacingPrimary, + vertical = LocalSizes.current.spacingSecondary, + ), + contentShape = when (LocalInAreaBox.current) { + true -> LocalAreaBoxShape.current + else -> LocalShapes.current.secondary + }, + indicatorWidth = Dp.Unspecified, + indicatorHeight = DefaultTabIndicatorHeight, + indicatorShape = LocalShapes.current.tertiary +) + +private const val DefaultTabIndicatorDuration = 250 +private val DefaultTabIndicatorHeight = 3.dp \ No newline at end of file