diff --git a/pager-indicators/src/androidTest/kotlin/com/google/accompanist/pager/TabIndicatorTest.kt b/pager-indicators/src/androidTest/kotlin/com/google/accompanist/pager/TabIndicatorTest.kt new file mode 100644 index 000000000..3843d2667 --- /dev/null +++ b/pager-indicators/src/androidTest/kotlin/com/google/accompanist/pager/TabIndicatorTest.kt @@ -0,0 +1,172 @@ +/* + * 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 + * + * https://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 com.google.accompanist.pager + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.Tab +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.unit.times +import androidx.compose.ui.unit.width +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalPagerApi::class) +@RunWith(AndroidJUnit4::class) +class TabIndicatorTest { + @get:Rule + val rule = createComposeRule() + + private val IndicatorTag = "indicator" + private val TabRowTag = "TabRow" + + @Test + fun emptyPager() { + rule.setContent { + val pagerState = rememberPagerState() + TabRow(pagerState) + } + } + + @Test + fun scrollOffsetIsPositive() { + lateinit var pagerState: PagerState + rule.setContent { + pagerState = rememberPagerState() + Column { + TabRow(pagerState) + HorizontalPager(count = 4, state = pagerState) { + Box(Modifier.fillMaxSize()) + } + } + } + + rule.runOnIdle { + runBlocking { pagerState.scrollToPage(1, 0.25f) } + } + + val tab1Bounds = rule.onNodeWithTag("1").getBoundsInRoot() + val tab2Bounds = rule.onNodeWithTag("2").getBoundsInRoot() + val indicatorBounds = rule.onNodeWithTag(IndicatorTag).getBoundsInRoot() + + with(rule.density) { + assertThat(indicatorBounds.left.roundToPx()) + .isEqualTo(lerp(tab1Bounds.left, tab2Bounds.left, 0.25f).roundToPx()) + assertThat(indicatorBounds.width.roundToPx()) + .isEqualTo(lerp(tab1Bounds.width, tab2Bounds.width, 0.25f).roundToPx()) + } + } + + @Test + fun scrollOffsetIsNegative() { + lateinit var pagerState: PagerState + rule.setContent { + pagerState = rememberPagerState() + Column { + TabRow(pagerState) + HorizontalPager(count = 4, state = pagerState) { + Box(Modifier.fillMaxSize()) + } + } + } + + rule.runOnIdle { + runBlocking { pagerState.scrollToPage(0, 0.75f) } + } + + val tab1Bounds = rule.onNodeWithTag("1").getBoundsInRoot() + val tab0Bounds = rule.onNodeWithTag("0").getBoundsInRoot() + val indicatorBounds = rule.onNodeWithTag(IndicatorTag).getBoundsInRoot() + + with(rule.density) { + assertThat(indicatorBounds.left.roundToPx()) + .isEqualTo(lerp(tab1Bounds.left, tab0Bounds.left, 0.25f).roundToPx()) + assertThat(indicatorBounds.width.roundToPx()) + .isEqualTo(lerp(tab1Bounds.width, tab0Bounds.width, 0.25f).roundToPx()) + } + } + + @Test + fun indicatorIsAtBottom() { + lateinit var pagerState: PagerState + rule.setContent { + pagerState = rememberPagerState() + Column { + TabRow(pagerState) + HorizontalPager(count = 4, state = pagerState) { + Box(Modifier.fillMaxSize()) + } + } + } + + rule.runOnIdle { + runBlocking { pagerState.scrollToPage(1, 0.25f) } + } + + val tabRowBounds = rule.onNodeWithTag(TabRowTag).getBoundsInRoot() + + val indicatorBounds = rule.onNodeWithTag(IndicatorTag).getBoundsInRoot() + + with(rule.density) { + assertThat(indicatorBounds.height.roundToPx()).isEqualTo(2.dp.roundToPx()) + assertThat(indicatorBounds.bottom).isEqualTo(tabRowBounds.bottom) + } + } + + @Composable + private fun TabRow(pagerState: PagerState) { + ScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier + .pagerTabIndicatorOffset(pagerState, tabPositions) + .testTag(IndicatorTag), + height = 2.dp + ) + }, + modifier = Modifier.testTag(TabRowTag) + ) { + // Add tabs for all of our pages + (0 until pagerState.pageCount).forEach { index -> + Tab( + text = { Text("Tab $index", Modifier.padding(horizontal = index * 5.dp)) }, + selected = pagerState.currentPage == index, + modifier = Modifier.testTag("$index"), + onClick = {} + ) + } + } + } +} diff --git a/pager-indicators/src/main/java/com/google/accompanist/pager/PagerTab.kt b/pager-indicators/src/main/java/com/google/accompanist/pager/PagerTab.kt index 6b71b733f..f093dbaf2 100644 --- a/pager-indicators/src/main/java/com/google/accompanist/pager/PagerTab.kt +++ b/pager-indicators/src/main/java/com/google/accompanist/pager/PagerTab.kt @@ -16,21 +16,13 @@ package com.google.accompanist.pager -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.ScrollableTabRow import androidx.compose.material.TabPosition import androidx.compose.material.TabRow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.lerp -import kotlin.math.absoluteValue -import kotlin.math.max /** * This indicator syncs up a [TabRow] or [ScrollableTabRow] tab indicator with a @@ -42,37 +34,43 @@ import kotlin.math.max fun Modifier.pagerTabIndicatorOffset( pagerState: PagerState, tabPositions: List, -): Modifier = composed { - // If there are no pages, nothing to show - if (pagerState.pageCount == 0) return@composed this - - val targetIndicatorOffset: Dp - val indicatorWidth: Dp - - val currentTab = tabPositions[minOf(tabPositions.lastIndex, pagerState.currentPage)] - val targetPage = pagerState.targetPage - val targetTab = tabPositions.getOrNull(targetPage) - - if (targetTab != null) { - // The distance between the target and current page. If the pager is animating over many - // items this could be > 1 - val targetDistance = (targetPage - pagerState.currentPage).absoluteValue - // Our normalized fraction over the target distance - val fraction = (pagerState.currentPageOffset / max(targetDistance, 1)).absoluteValue - - targetIndicatorOffset = lerp(currentTab.left, targetTab.left, fraction) - indicatorWidth = lerp(currentTab.width, targetTab.width, fraction).absoluteValue +): Modifier = layout { measurable, constraints -> + if (tabPositions.isEmpty()) { + // If there are no pages, nothing to show + layout(constraints.maxWidth, 0) {} } else { - // Otherwise we just use the current tab/page - targetIndicatorOffset = currentTab.left - indicatorWidth = currentTab.width + val currentPage = minOf(tabPositions.lastIndex, pagerState.currentPage) + val currentTab = tabPositions[currentPage] + val previousTab = tabPositions.getOrNull(currentPage - 1) + val nextTab = tabPositions.getOrNull(currentPage + 1) + val fraction = pagerState.currentPageOffset + val indicatorWidth = if (fraction > 0 && nextTab != null) { + lerp(currentTab.width, nextTab.width, fraction).roundToPx() + } else if (fraction < 0 && previousTab != null) { + lerp(currentTab.width, previousTab.width, -fraction).roundToPx() + } else { + currentTab.width.roundToPx() + } + val indicatorOffset = if (fraction > 0 && nextTab != null) { + lerp(currentTab.left, nextTab.left, fraction).roundToPx() + } else if (fraction < 0 && previousTab != null) { + lerp(currentTab.left, previousTab.left, -fraction).roundToPx() + } else { + currentTab.left.roundToPx() + } + val placeable = measurable.measure( + Constraints( + minWidth = indicatorWidth, + maxWidth = indicatorWidth, + minHeight = 0, + maxHeight = constraints.maxHeight + ) + ) + layout(constraints.maxWidth, maxOf(placeable.height, constraints.minHeight)) { + placeable.place( + indicatorOffset, + maxOf(constraints.minHeight - placeable.height, 0) + ) + } } - - fillMaxWidth() - .wrapContentSize(Alignment.BottomStart) - .offset(x = targetIndicatorOffset) - .width(indicatorWidth) } - -private inline val Dp.absoluteValue: Dp - get() = value.absoluteValue.dp diff --git a/pager-indicators/src/sharedTest/kotlin/com/google/accompanist/insets/TabIndicatorTest.kt b/pager-indicators/src/sharedTest/kotlin/com/google/accompanist/insets/TabIndicatorTest.kt deleted file mode 100644 index b25b7a82f..000000000 --- a/pager-indicators/src/sharedTest/kotlin/com/google/accompanist/insets/TabIndicatorTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2021 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 - * - * https://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 com.google.accompanist.insets - -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ScrollableTabRow -import androidx.compose.material.Tab -import androidx.compose.material.TabRowDefaults -import androidx.compose.material.Text -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@OptIn(ExperimentalPagerApi::class) -@RunWith(AndroidJUnit4::class) -class TabIndicatorTest { - @get:Rule - val rule = createComposeRule() - - @Test - fun emptyPager() { - rule.setContent { - MaterialTheme { - val pagerState = rememberPagerState() - ScrollableTabRow( - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions) - ) - }, - ) { - // Add tabs for all of our pages - (0 until pagerState.pageCount).forEach { index -> - Tab( - text = { Text("Tab $index") }, - selected = pagerState.currentPage == index, - onClick = {} - ) - } - } - } - } - } -}