From a38888576a68ac4ecd563e0ffd335ff992267ab4 Mon Sep 17 00:00:00 2001 From: Vladyslav Mihalatiuk Date: Tue, 26 Aug 2025 17:31:54 +0300 Subject: [PATCH] feat: animated tabs row --- design/api/current.api | 54 +++++ .../design/ui/tabsrow/AnimatedTabsRow.kt | 149 ++++++++++++ .../ui/tabsrow/AnimatedTabsRowScaffold.kt | 95 ++++++++ .../android/design/ui/tabsrow/TabsRow.kt | 226 ++++++++++++++++++ .../components/RememberScrollProgress.kt | 41 ++++ .../SynchronizedLazyGridScrollState.kt | 102 ++++++++ .../ui/tabsrow/components/VerticalOffset.kt | 75 ++++++ .../ui/tabsrow/constants/TabsRowConstants.kt | 11 + .../ui/tabsrow/constants/TabsRowDimens.kt | 9 + 9 files changed, 762 insertions(+) create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRow.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRowScaffold.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/TabsRow.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/RememberScrollProgress.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/SynchronizedLazyGridScrollState.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/VerticalOffset.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowConstants.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowDimens.kt diff --git a/design/api/current.api b/design/api/current.api index b4abb22..0e5a3ee 100644 --- a/design/api/current.api +++ b/design/api/current.api @@ -450,6 +450,60 @@ package com.urlaunched.android.design.ui.slider { } +package com.urlaunched.android.design.ui.tabsrow { + + public final class AnimatedTabsRowKt { + method @androidx.compose.runtime.Composable public static void AnimatedTabsRow(optional androidx.compose.ui.Modifier modifier, com.urlaunched.android.design.ui.tabsrow.components.SynchronizedLazyGridScrollState synchronizedLazyGridScrollState, androidx.compose.foundation.pager.PagerState pagerState, kotlin.jvm.functions.Function0 topBarContent, java.util.List pages, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional float tabsHeight, optional float indicatorHeight, optional androidx.compose.ui.unit.Dp? indicatorShadow, optional boolean enabled, optional java.util.List colors, optional long selectedTextColor, optional long unselectedTextColor, optional long containerColor, optional androidx.compose.ui.text.TextStyle selectedTextStyle, optional androidx.compose.ui.text.TextStyle unselectedTextStyle, optional long minFontSize, optional long backgroundColor, optional kotlin.jvm.functions.Function1 onTabChange, optional kotlin.jvm.functions.Function1? indicator, optional kotlin.jvm.functions.Function1? startContent, optional kotlin.jvm.functions.Function1? endContent); + } + + public final class AnimatedTabsRowScaffoldKt { + method @androidx.compose.runtime.Composable public static void AnimatedTabsRowScaffold(optional androidx.compose.ui.Modifier modifier, com.urlaunched.android.design.ui.tabsrow.components.SynchronizedLazyGridScrollState synchronizedLazyGridScrollState, androidx.compose.foundation.pager.PagerState pagerState, java.util.List pages, optional androidx.compose.foundation.layout.PaddingValues tabsPadding, optional float tabsHeight, optional float indicatorHeight, optional androidx.compose.ui.unit.Dp? indicatorShadow, optional boolean enabled, optional java.util.List colors, optional long selectedTextColor, optional long unselectedTextColor, optional long containerColor, optional androidx.compose.ui.text.TextStyle selectedTextStyle, optional androidx.compose.ui.text.TextStyle unselectedTextStyle, optional long minFontSize, optional long backgroundColor, optional kotlin.jvm.functions.Function1 onTabChange, optional kotlin.jvm.functions.Function1? tabIndicator, optional kotlin.jvm.functions.Function1? tabStartContent, optional kotlin.jvm.functions.Function1? tabEndContent, kotlin.jvm.functions.Function0 topBarContent, kotlin.jvm.functions.Function0 content); + } + + public final class TabsRowKt { + method @androidx.compose.runtime.Composable public static void TabsRow(optional androidx.compose.ui.Modifier modifier, java.util.List pages, androidx.compose.foundation.pager.PagerState pagerState, optional float tabsHeight, optional float indicatorHeight, optional androidx.compose.ui.unit.Dp? indicatorShadow, optional boolean enabled, optional java.util.List colors, optional long selectedTextColor, optional long unselectedTextColor, optional long containerColor, optional androidx.compose.ui.text.TextStyle selectedTextStyle, optional androidx.compose.ui.text.TextStyle unselectedTextStyle, optional long minFontSize, optional kotlin.jvm.functions.Function1 onTabChange, optional kotlin.jvm.functions.Function1? indicator, optional kotlin.jvm.functions.Function1? startContent, optional kotlin.jvm.functions.Function1? endContent); + } + +} + +package com.urlaunched.android.design.ui.tabsrow.components { + + public final class RememberScrollProgressKt { + method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State rememberScrollProgress(androidx.compose.foundation.lazy.grid.LazyGridState, float targetOffset); + method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State rememberScrollProgress(androidx.compose.foundation.lazy.LazyListState, float targetOffset); + } + + public final class SynchronizedLazyGridScrollState { + ctor public SynchronizedLazyGridScrollState(androidx.compose.foundation.lazy.grid.LazyGridState firstTabState, androidx.compose.foundation.lazy.grid.LazyGridState secondTabState, int targetOffset, int firstItemOffsetPx); + method public androidx.compose.foundation.lazy.grid.LazyGridState component1(); + method public androidx.compose.foundation.lazy.grid.LazyGridState component2(); + method public int component3(); + method public com.urlaunched.android.design.ui.tabsrow.components.SynchronizedLazyGridScrollState copy(androidx.compose.foundation.lazy.grid.LazyGridState firstTabState, androidx.compose.foundation.lazy.grid.LazyGridState secondTabState, int targetOffset, int firstItemOffsetPx); + method public androidx.compose.foundation.lazy.grid.LazyGridState getFirstTabState(); + method public float getFirstTabStateProgress(); + method public androidx.compose.foundation.lazy.grid.LazyGridState getSecondTabState(); + method public float getSecondTabStateProgress(); + method public int getTargetOffset(); + method public suspend Object? scrollToTopFirstTab(optional boolean animated, optional kotlin.coroutines.Continuation); + method public suspend Object? scrollToTopSecondTab(optional boolean animated, optional kotlin.coroutines.Continuation); + property public final androidx.compose.foundation.lazy.grid.LazyGridState firstTabState; + property public final float firstTabStateProgress; + property public final androidx.compose.foundation.lazy.grid.LazyGridState secondTabState; + property public final float secondTabStateProgress; + property public final int targetOffset; + } + + public final class SynchronizedLazyGridScrollStateKt { + method @androidx.compose.runtime.Composable public static com.urlaunched.android.design.ui.tabsrow.components.SynchronizedLazyGridScrollState rememberSynchronizedLazyGridScrollStates(optional float spacing, optional float initialOffset); + } + + public final class VerticalOffsetKt { + method @androidx.compose.runtime.Composable public static androidx.compose.ui.Modifier verticalOffset(androidx.compose.ui.Modifier, androidx.compose.foundation.lazy.grid.LazyGridState firstScrollState, androidx.compose.foundation.lazy.grid.LazyGridState secondScrollState, float initialOffset, float maxOffset); + method @androidx.compose.runtime.Composable public static androidx.compose.ui.Modifier verticalOffset(androidx.compose.ui.Modifier, androidx.compose.foundation.lazy.LazyListState firstScrollState, androidx.compose.foundation.lazy.LazyListState secondScrollState, float initialOffset, float maxOffset); + } + +} + package com.urlaunched.android.design.ui.textfield { public final class LocalTextFieldConfigsKt { diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRow.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRow.kt new file mode 100644 index 0000000..0e09a31 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRow.kt @@ -0,0 +1,149 @@ +package com.urlaunched.android.design.ui.tabsrow + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import com.urlaunched.android.design.resources.dimens.Dimens +import com.urlaunched.android.design.ui.tabsrow.components.SynchronizedLazyGridScrollState +import com.urlaunched.android.design.ui.tabsrow.components.rememberScrollProgress +import com.urlaunched.android.design.ui.tabsrow.components.rememberSynchronizedLazyGridScrollStates +import com.urlaunched.android.design.ui.tabsrow.components.verticalOffset +import com.urlaunched.android.design.ui.tabsrow.constants.TabsRowDimens +import kotlin.math.max + +@Composable +fun AnimatedTabsRow( + modifier: Modifier = Modifier, + synchronizedLazyGridScrollState: SynchronizedLazyGridScrollState, + pagerState: PagerState, + topBarContent: @Composable () -> Unit, + pages: List, + contentPadding: PaddingValues = PaddingValues(), + tabsHeight: Dp = TabsRowDimens.pagerTabsHeight, + indicatorHeight: Dp = TabsRowDimens.pagerTabsIndicatorHeight, + indicatorShadow: Dp? = null, + enabled: Boolean = true, + colors: List = listOf(Color.DarkGray), + selectedTextColor: Color = Color.White, + unselectedTextColor: Color = Color.Gray, + containerColor: Color = Color.White, + selectedTextStyle: TextStyle = Typography().titleSmall, + unselectedTextStyle: TextStyle = Typography().labelLarge, + minFontSize: TextUnit = Typography().bodyMedium.fontSize, + backgroundColor: Color = Color.White, + onTabChange: (index: Int) -> Unit = {}, + indicator: @Composable ((page: Int) -> Unit)? = null, + startContent: @Composable (RowScope.() -> Unit)? = null, + endContent: @Composable (RowScope.() -> Unit)? = null +) { + val firstTabScrollProgress by synchronizedLazyGridScrollState.firstTabState.rememberScrollProgress(Dimens.spacingNormalSpecial) + val secondTabScrollProgress by synchronizedLazyGridScrollState.secondTabState.rememberScrollProgress(Dimens.spacingNormalSpecial) + val progress by remember { derivedStateOf { max(firstTabScrollProgress, secondTabScrollProgress) } } + + Box(modifier = modifier) { + Box( + modifier = Modifier + .matchParentSize() + .verticalOffset( + firstScrollState = synchronizedLazyGridScrollState.firstTabState, + secondScrollState = synchronizedLazyGridScrollState.secondTabState, + initialOffset = Dimens.spacingNormal, + maxOffset = Dimens.zeroDp + ) + .clip( + RoundedCornerShape( + bottomStart = Dimens.cornerRadiusNormal, + bottomEnd = Dimens.cornerRadiusNormal + ) + ) + .alpha(progress) + .background(backgroundColor) + ) + + Column { + topBarContent() + + TabsRow( + modifier = Modifier + .padding(contentPadding) + .verticalOffset( + firstScrollState = synchronizedLazyGridScrollState.firstTabState, + secondScrollState = synchronizedLazyGridScrollState.secondTabState, + initialOffset = Dimens.spacingSmall, + maxOffset = Dimens.spacingSmall + ), + pages = pages, + containerColor = lerp( + start = backgroundColor, + stop = containerColor, + fraction = progress + ), + pagerState = pagerState, + tabsHeight = tabsHeight, + indicatorHeight = indicatorHeight, + indicatorShadow = indicatorShadow, + enabled = enabled, + colors = colors, + selectedTextColor = selectedTextColor, + unselectedTextColor = unselectedTextColor, + selectedTextStyle = selectedTextStyle, + unselectedTextStyle = unselectedTextStyle, + onTabChange = onTabChange, + minFontSize = minFontSize, + indicator = indicator, + startContent = startContent, + endContent = endContent + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xff999999) +@Composable +private fun AnimatedTabRowPreview() { + val pagerState = rememberPagerState(pageCount = { 2 }) + + Box(modifier = Modifier.padding(bottom = Dimens.spacingBig)) { + AnimatedTabsRow( + modifier = Modifier.padding(horizontal = Dimens.spacingNormal), + synchronizedLazyGridScrollState = rememberSynchronizedLazyGridScrollStates( + spacing = Dimens.spacingNormalSpecial, + initialOffset = Dimens.spacingNormal + ), + pagerState = pagerState, + pages = listOf("First", "Second"), + containerColor = Color.White, + topBarContent = { + Box( + modifier = Modifier.height(Dimens.spacingLarge), + contentAlignment = Alignment.Center + ) { + Text("Top Bar") + } + } + ) + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRowScaffold.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRowScaffold.kt new file mode 100644 index 0000000..024c4ac --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/AnimatedTabsRowScaffold.kt @@ -0,0 +1,95 @@ +package com.urlaunched.android.design.ui.tabsrow + +import androidx.compose.foundation.LocalOverscrollFactory +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import com.urlaunched.android.design.ui.tabsrow.components.SynchronizedLazyGridScrollState +import com.urlaunched.android.design.ui.tabsrow.constants.TabsRowDimens + +@Composable +fun AnimatedTabsRowScaffold( + modifier: Modifier = Modifier, + synchronizedLazyGridScrollState: SynchronizedLazyGridScrollState, + pagerState: PagerState, + pages: List, + tabsPadding: PaddingValues = PaddingValues(), + tabsHeight: Dp = TabsRowDimens.pagerTabsHeight, + indicatorHeight: Dp = TabsRowDimens.pagerTabsIndicatorHeight, + indicatorShadow: Dp? = null, + enabled: Boolean = true, + colors: List = listOf(Color.DarkGray), + selectedTextColor: Color = Color.White, + unselectedTextColor: Color = Color.Gray, + containerColor: Color = Color.White, + selectedTextStyle: TextStyle = Typography().titleSmall, + unselectedTextStyle: TextStyle = Typography().labelLarge, + minFontSize: TextUnit = Typography().bodyMedium.fontSize, + backgroundColor: Color = Color.White, + onTabChange: (index: Int) -> Unit = {}, + tabIndicator: @Composable ((page: Int) -> Unit)? = null, + tabStartContent: @Composable (RowScope.() -> Unit)? = null, + tabEndContent: @Composable (RowScope.() -> Unit)? = null, + topBarContent: @Composable () -> Unit, + content: @Composable () -> Unit +) { + Layout( + modifier = modifier, + content = { + AnimatedTabsRow( + synchronizedLazyGridScrollState = synchronizedLazyGridScrollState, + pagerState = pagerState, + pages = pages, + topBarContent = topBarContent, + contentPadding = tabsPadding, + tabsHeight = tabsHeight, + indicatorHeight = indicatorHeight, + indicatorShadow = indicatorShadow, + enabled = enabled, + colors = colors, + selectedTextColor = selectedTextColor, + unselectedTextColor = unselectedTextColor, + containerColor = containerColor, + backgroundColor = backgroundColor, + selectedTextStyle = selectedTextStyle, + unselectedTextStyle = unselectedTextStyle, + minFontSize = minFontSize, + onTabChange = onTabChange, + indicator = tabIndicator, + startContent = tabStartContent, + endContent = tabEndContent + ) + + CompositionLocalProvider(LocalOverscrollFactory provides null) { + content() + } + } + ) { measurables, constraints -> + val offset = synchronizedLazyGridScrollState.targetOffset + val topBarPlaceable = measurables[0].measure(constraints) + val contentPlaceable = measurables[1].measure( + constraints.copy(maxHeight = constraints.maxHeight - topBarPlaceable.height + offset) + ) + + layout(width = constraints.maxWidth, height = constraints.maxHeight) { + contentPlaceable.placeRelative( + x = 0, + y = topBarPlaceable.height - offset + ) + + topBarPlaceable.placeRelative( + x = 0, + y = 0 + ) + } + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/TabsRow.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/TabsRow.kt new file mode 100644 index 0000000..9e17957 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/TabsRow.kt @@ -0,0 +1,226 @@ +package com.urlaunched.android.design.ui.tabsrow + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.Tab +import androidx.compose.material3.TabPosition +import androidx.compose.material3.TabRow +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.zIndex +import com.urlaunched.android.design.resources.dimens.Dimens +import com.urlaunched.android.design.ui.modifiers.ifNotNull +import com.urlaunched.android.design.ui.tabsrow.constants.TabsRowConstants +import com.urlaunched.android.design.ui.tabsrow.constants.TabsRowDimens +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TabsRow( + modifier: Modifier = Modifier, + pages: List, + pagerState: PagerState, + tabsHeight: Dp = TabsRowDimens.pagerTabsHeight, + indicatorHeight: Dp = TabsRowDimens.pagerTabsIndicatorHeight, + indicatorShadow: Dp? = null, + enabled: Boolean = true, + colors: List = listOf(Color.DarkGray), + selectedTextColor: Color = Color.White, + unselectedTextColor: Color = Color.Gray, + containerColor: Color = Color.White, + selectedTextStyle: TextStyle = Typography().titleSmall, + unselectedTextStyle: TextStyle = Typography().labelLarge, + minFontSize: TextUnit = Typography().bodyMedium.fontSize, + onTabChange: (index: Int) -> Unit = {}, + indicator: @Composable ((page: Int) -> Unit)? = null, + startContent: @Composable (RowScope.() -> Unit)? = null, + endContent: @Composable (RowScope.() -> Unit)? = null +) { + val coroutineScope = rememberCoroutineScope() + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + startContent?.let { + startContent() + } + + BoxWithConstraints( + modifier = Modifier + .clip(CircleShape) + .weight(TabsRowConstants.TABS_WEIGHT) + ) { + TabRow( + modifier = Modifier + .height(tabsHeight) + .clip(CircleShape), + selectedTabIndex = pagerState.currentPage, + containerColor = containerColor, + indicator = { tabPositions: List -> + CustomIndicator( + tabPositions = tabPositions, + pagerState = pagerState, + colors = colors, + elevation = indicatorShadow, + maxWidth = maxWidth, + height = indicatorHeight + ) + }, + // Remove divider + divider = {} + ) { + pages.forEachIndexed { index, text -> + val selected = pagerState.currentPage == index + CompositionLocalProvider(LocalRippleConfiguration provides null) { + Tab( + modifier = Modifier.zIndex(TabsRowConstants.TAB_ZINDEX), + text = { + if (indicator != null) { + indicator(index) + } else { + BasicText( + text = text, + style = if (selected) { + selectedTextStyle.copy( + textAlign = TextAlign.Center, + color = selectedTextColor + ) + } else { + unselectedTextStyle.copy( + textAlign = TextAlign.Center, + color = unselectedTextColor + ) + }, + maxLines = 1, + autoSize = TextAutoSize.StepBased( + maxFontSize = if (selected) { + selectedTextStyle.fontSize + } else { + unselectedTextStyle.fontSize + }, + minFontSize = minFontSize + ) + ) + } + }, + enabled = enabled, + selected = selected, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + + onTabChange(index) + }, + selectedContentColor = containerColor, + unselectedContentColor = containerColor, + interactionSource = remember { MutableInteractionSource() } + ) + } + } + } + } + + endContent?.let { + endContent() + } + } +} + +@Composable +private fun CustomIndicator( + modifier: Modifier = Modifier, + tabPositions: List, + pagerState: PagerState, + colors: List, + maxWidth: Dp, + height: Dp, + elevation: Dp? = null +) { + val transition = + updateTransition( + targetState = pagerState.currentPage, + label = TabsRowConstants.PAGER_INDICATOR_PAGE_TRANSITION_LABEL + ) + val indicatorStart by transition.animateDp( + transitionSpec = { + spring(stiffness = Spring.StiffnessMedium) + }, + label = TabsRowConstants.PAGER_INDICATOR_START_POSITION_LABEL + ) { + tabPositions[it].left + } + + val color by transition.animateColor( + transitionSpec = { + spring(stiffness = Spring.StiffnessMedium) + }, + label = TabsRowConstants.PAGER_INDICATOR_COLOR_LABEL + ) { page -> + colors.getOrElse(index = page, defaultValue = { colors[0] }) + } + + Box( + modifier + .offset(x = indicatorStart) + .wrapContentSize(align = Alignment.CenterStart) + .width(maxWidth / pagerState.pageCount) + .padding(horizontal = Dimens.spacingTinyHalf) + .ifNotNull(elevation) { + elevation?.let { Modifier.shadow(elevation = elevation, shape = CircleShape) } ?: Modifier + } + .clip(CircleShape) + .background(color) + .height(height) + .zIndex(TabsRowConstants.INDICATOR_ZINDEX) + ) +} + +@Preview +@Composable +private fun TabsRowPreview() { + TabsRow( + pages = listOf("Test 1", "Test 2", "Test 3"), + pagerState = rememberPagerState(0, pageCount = { 3 }), + onTabChange = { }, + startContent = { -> } + ) +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/RememberScrollProgress.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/RememberScrollProgress.kt new file mode 100644 index 0000000..256c4b3 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/RememberScrollProgress.kt @@ -0,0 +1,41 @@ +@file:Suppress("ktlint:standard:filename") + +package com.urlaunched.android.design.ui.tabsrow.components + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp +import com.urlaunched.android.design.extensions.toPx +import kotlin.math.min + +@Composable +fun LazyGridState.rememberScrollProgress(targetOffset: Dp): State { + val firstItemOffsetPx = targetOffset.toPx() + return remember { + derivedStateOf { + if (firstVisibleItemIndex == 0) { + min(1f / firstItemOffsetPx * firstVisibleItemScrollOffset, 1f) + } else { + 1f + } + } + } +} + +@Composable +fun LazyListState.rememberScrollProgress(targetOffset: Dp): State { + val firstItemOffsetPx = targetOffset.toPx() + return remember { + derivedStateOf { + if (firstVisibleItemIndex == 0) { + min(1f / firstItemOffsetPx * firstVisibleItemScrollOffset, 1f) + } else { + 1f + } + } + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/SynchronizedLazyGridScrollState.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/SynchronizedLazyGridScrollState.kt new file mode 100644 index 0000000..d2ea5e6 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/SynchronizedLazyGridScrollState.kt @@ -0,0 +1,102 @@ +package com.urlaunched.android.design.ui.tabsrow.components + +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp +import com.urlaunched.android.design.extensions.toPx +import com.urlaunched.android.design.resources.dimens.Dimens +import kotlin.math.min + +data class SynchronizedLazyGridScrollState( + val firstTabState: LazyGridState, + val secondTabState: LazyGridState, + val targetOffset: Int, + private val firstItemOffsetPx: Int +) { + val firstTabStateProgress by firstTabState.getProgressState() + val secondTabStateProgress by secondTabState.getProgressState() + + internal suspend fun syncFirstTab() { + with(firstTabState) { + if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset <= targetOffset) { + scrollToItem(0, (targetOffset * secondTabStateProgress).toInt()) + } + } + } + + internal suspend fun syncSecondTab() { + with(secondTabState) { + if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset <= targetOffset) { + scrollToItem(0, (targetOffset * firstTabStateProgress).toInt()) + } + } + } + + suspend fun scrollToTopFirstTab(animated: Boolean = true) { + with(firstTabState) { + if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset > targetOffset || firstVisibleItemIndex > 0) { + if (animated) { + animateScrollToItem(0, (targetOffset * secondTabStateProgress).toInt()) + } else { + scrollToItem(0, (targetOffset * secondTabStateProgress).toInt()) + } + } + } + } + + suspend fun scrollToTopSecondTab(animated: Boolean = true) { + with(secondTabState) { + if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset > targetOffset || firstVisibleItemIndex > 0) { + if (animated) { + animateScrollToItem(0, (targetOffset * secondTabStateProgress).toInt()) + } else { + scrollToItem(0, (targetOffset * firstTabStateProgress).toInt()) + } + } + } + } + + private fun LazyGridState.getProgressState() = derivedStateOf { + if (firstVisibleItemIndex == 0) { + min(1f / firstItemOffsetPx * firstVisibleItemScrollOffset, 1f) + } else { + 1f + } + } +} + +@Composable +fun rememberSynchronizedLazyGridScrollStates( + spacing: Dp = Dimens.spacingNormalSpecial, + initialOffset: Dp = Dimens.spacingNormal +): SynchronizedLazyGridScrollState { + val firstTabState = rememberLazyGridState() + val secondTabState = rememberLazyGridState() + + val targetOffset = initialOffset.toPx() + val firstItemOffsetPx = spacing.toPx() + + val synchronizedLazyGridScrollState = remember(spacing, initialOffset) { + SynchronizedLazyGridScrollState( + firstTabState = firstTabState, + secondTabState = secondTabState, + firstItemOffsetPx = firstItemOffsetPx, + targetOffset = targetOffset + ) + } + + LaunchedEffect(synchronizedLazyGridScrollState.secondTabStateProgress) { + synchronizedLazyGridScrollState.syncFirstTab() + } + + LaunchedEffect(synchronizedLazyGridScrollState.firstTabStateProgress) { + synchronizedLazyGridScrollState.syncSecondTab() + } + + return synchronizedLazyGridScrollState +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/VerticalOffset.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/VerticalOffset.kt new file mode 100644 index 0000000..e344b8d --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/components/VerticalOffset.kt @@ -0,0 +1,75 @@ +package com.urlaunched.android.design.ui.tabsrow.components + +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import com.urlaunched.android.design.extensions.toPx +import kotlin.math.max +import kotlin.math.min + +@Composable +fun Modifier.verticalOffset( + firstScrollState: LazyGridState, + secondScrollState: LazyGridState, + initialOffset: Dp, + maxOffset: Dp +): Modifier { + val maxOffsetPx = maxOffset.toPx() + val initialOffsetPx = initialOffset.toPx() + val backgroundOffset by remember { + derivedStateOf { + val scrollStateOffset = if (firstScrollState.firstVisibleItemIndex == 0) { + max(-firstScrollState.firstVisibleItemScrollOffset + initialOffsetPx, -maxOffsetPx) + } else { + -maxOffsetPx + } + + val scrollStateSOffset = if (secondScrollState.firstVisibleItemIndex == 0) { + max(-secondScrollState.firstVisibleItemScrollOffset + initialOffsetPx, -maxOffsetPx) + } else { + -maxOffsetPx + } + + min(scrollStateOffset, scrollStateSOffset) + } + } + + return offset { IntOffset(x = 0, y = backgroundOffset) } +} + +@Composable +fun Modifier.verticalOffset( + firstScrollState: LazyListState, + secondScrollState: LazyListState, + initialOffset: Dp, + maxOffset: Dp +): Modifier { + val maxOffsetPx = maxOffset.toPx() + val initialOffsetPx = initialOffset.toPx() + val backgroundOffset by remember { + derivedStateOf { + val scrollStateOffset = if (firstScrollState.firstVisibleItemIndex == 0) { + max(-firstScrollState.firstVisibleItemScrollOffset + initialOffsetPx, -maxOffsetPx) + } else { + -maxOffsetPx + } + + val scrollStateSOffset = if (secondScrollState.firstVisibleItemIndex == 0) { + max(-secondScrollState.firstVisibleItemScrollOffset + initialOffsetPx, -maxOffsetPx) + } else { + -maxOffsetPx + } + + min(scrollStateOffset, scrollStateSOffset) + } + } + + return offset { IntOffset(x = 0, y = backgroundOffset) } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowConstants.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowConstants.kt new file mode 100644 index 0000000..01f3b1a --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowConstants.kt @@ -0,0 +1,11 @@ +package com.urlaunched.android.design.ui.tabsrow.constants + +internal object TabsRowConstants { + const val PAGER_INDICATOR_START_POSITION_LABEL = "startPosition" + const val PAGER_INDICATOR_COLOR_LABEL = "colorTransition" + const val PAGER_INDICATOR_PAGE_TRANSITION_LABEL = "pageTransition" + const val INDICATOR_ZINDEX = 1f + const val TAB_ZINDEX = 2f + const val TABS_WEIGHT = 1.5f + const val TITLE_WEIGHT = 1f +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowDimens.kt b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowDimens.kt new file mode 100644 index 0000000..ff1f20a --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/tabsrow/constants/TabsRowDimens.kt @@ -0,0 +1,9 @@ +package com.urlaunched.android.design.ui.tabsrow.constants + +import androidx.compose.ui.unit.dp + +internal object TabsRowDimens { + val pagerTabsHeight = 32.dp + val pagerTabsIndicatorHeight = 28.dp + val snapshotIndicatorWidthOffset = 120.dp +} \ No newline at end of file