[go: nahoru, domu]

blob: 1233c2819ff805ebf1c504ff3474483efe7d122e [file] [log] [blame]
/*
* Copyright 2023 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.compose.material3.pulltorefresh
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@MediumTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalMaterial3Api::class)
class PullToRefreshStateImplTest {
@get:Rule
val rule = createComposeRule()
@Test
fun refreshTrigger_onlyAfterThreshold() {
var refreshCount = 0
var touchSlop = 0f
val positionalThreshold = 400f
lateinit var state: PullToRefreshState
rule.setContent {
touchSlop = LocalViewConfiguration.current.touchSlop
state = remember {
PullToRefreshStateImpl(
initialRefreshing = false,
positionalThreshold = positionalThreshold,
enabled = { true },
)
}
if (state.isRefreshing) {
LaunchedEffect(true) {
refreshCount++
state.endRefresh()
}
}
Box(
Modifier
.nestedScroll(state.nestedScrollConnection)
.testTag(PullRefreshTag)) {
LazyColumn {
items(100) {
Text("item $it")
}
}
}
}
// Account for DragModifier - pull down twice the threshold value.
// Less than threshold
pullRefreshNode.performTouchInput {
swipeDown(endY = 2 * positionalThreshold + touchSlop - 1f)
}
rule.waitForIdle()
// Equal to threshold
pullRefreshNode.performTouchInput {
swipeDown(endY = 2 * positionalThreshold + touchSlop)
}
rule.runOnIdle {
assertThat(refreshCount).isEqualTo(0)
// Since onRefresh was not called, we should reset the position back to 0
assertThat(state.progress).isEqualTo(0f)
assertThat(state.verticalOffset).isEqualTo(0f)
}
pullRefreshNode.performTouchInput {
swipeDown(endY = 2 * positionalThreshold + touchSlop + 1f)
}
rule.runOnIdle { assertThat(refreshCount).isEqualTo(1) }
}
@Test
fun progressAndVerticalOffset_scaleCorrectly_untilThreshold() {
lateinit var state: PullToRefreshStateImpl
var refreshCount = 0
val positionalThreshold = 400f
rule.setContent {
state = remember {
PullToRefreshStateImpl(
initialRefreshing = false,
positionalThreshold = positionalThreshold,
enabled = { true },
)
}
if (state.isRefreshing) {
LaunchedEffect(true) {
refreshCount++
state.endRefresh()
}
}
Box(
Modifier
.nestedScroll(state.nestedScrollConnection)
.testTag(PullRefreshTag)) {
LazyColumn {
items(100) {
Text("item $it")
}
}
}
}
state.distancePulled = positionalThreshold
rule.runOnIdle {
// Expected values given drag modifier of 0.5f
assertThat(state.progress).isEqualTo(0.5f)
assertThat(state.calculateVerticalOffset()).isEqualTo(200f)
assertThat(refreshCount).isEqualTo(0)
}
}
@Test
fun progressAndPosition_scaleCorrectly_beyondThreshold() {
lateinit var state: PullToRefreshStateImpl
lateinit var scope: CoroutineScope
var refreshCount = 0
val positionalThreshold = 400f
rule.setContent {
state = remember {
PullToRefreshStateImpl(
initialRefreshing = false,
positionalThreshold = positionalThreshold,
enabled = { true },
)
}
scope = rememberCoroutineScope()
if (state.isRefreshing) {
LaunchedEffect(true) {
refreshCount++
state.endRefresh()
}
}
Box(
Modifier
.nestedScroll(state.nestedScrollConnection)
.testTag(PullRefreshTag)) {
LazyColumn {
items(100) {
Text("item $it")
}
}
}
}
state.distancePulled = 2 * positionalThreshold
rule.runOnIdle {
assertThat(state.progress).isEqualTo(1f)
// Account for PullMultiplier.
assertThat(state.calculateVerticalOffset()).isEqualTo(positionalThreshold)
assertThat(refreshCount).isEqualTo(0)
}
state.distancePulled += positionalThreshold
rule.runOnIdle {
assertThat(state.progress).isEqualTo(1.5f)
assertThat(refreshCount).isEqualTo(0)
}
scope.launch { state.onRelease(0f) }
rule.runOnIdle {
assertThat(state.progress).isEqualTo(0f)
assertThat(refreshCount).isEqualTo(1)
}
}
@Test
fun positionIsCapped() {
lateinit var state: PullToRefreshStateImpl
var refreshCount = 0
val positionalThreshold = 400f
rule.setContent {
state = remember {
PullToRefreshStateImpl(
initialRefreshing = false,
positionalThreshold = positionalThreshold,
enabled = { true },
)
}
if (state.isRefreshing) {
LaunchedEffect(true) {
refreshCount++
state.endRefresh()
}
}
Box(
Modifier
.nestedScroll(state.nestedScrollConnection)
.testTag(PullRefreshTag)) {
LazyColumn {
items(100) {
Text("item $it")
}
}
}
}
state.distancePulled = 10 * positionalThreshold
rule.runOnIdle {
assertThat(state.progress).isEqualTo(5f) // Account for PullMultiplier.
// Indicator position is capped to 2 times the refresh threshold.
assertThat(state.calculateVerticalOffset()).isEqualTo(2 * positionalThreshold)
assertThat(refreshCount).isEqualTo(0)
}
}
@Test
fun nestedPreScroll_negativeDelta_notRefreshing() {
var refreshCount = 0
val positionalThreshold = 200f
lateinit var state: PullToRefreshStateImpl
val dispatcher = NestedScrollDispatcher()
val connection = object : NestedScrollConnection {}
rule.setContent {
state = remember {
PullToRefreshStateImpl(
initialRefreshing = false,
positionalThreshold = positionalThreshold,
enabled = { true },
)
}
if (state.isRefreshing) {
LaunchedEffect(true) {
refreshCount++
state.endRefresh()
}
}
Box(
Modifier
.nestedScroll(state.nestedScrollConnection)
.testTag(PullRefreshTag)) {
Box(
Modifier
.size(100.dp)
.nestedScroll(connection, dispatcher))
}
}
// 100 pixels up
val dragUpOffset = Offset(0f, -100f)
rule.runOnIdle {
val preConsumed = dispatcher.dispatchPreScroll(dragUpOffset, NestedScrollSource.Drag)
// Pull refresh is not showing, so we should consume nothing
assertThat(preConsumed).isEqualTo(Offset.Zero)
assertThat(state.verticalOffset).isEqualTo(0f)
}
// Pull the state by a bit
state.distancePulled = 200f
rule.runOnIdle {
assertThat(state.calculateVerticalOffset())
.isEqualTo(100f /* 200 / 2 for drag multiplier */)
val preConsumed = dispatcher.dispatchPreScroll(dragUpOffset, NestedScrollSource.Drag)
// Pull refresh is currently showing, so we should consume all the delta
assertThat(preConsumed).isEqualTo(dragUpOffset)
assertThat(state.calculateVerticalOffset())
.isEqualTo(50f /* (200 - 100) / 2 for drag multiplier */)
}
}
@Test
fun state_restoresPullRefreshState() {
val restorationTester = StateRestorationTester(rule)
var pullToRefreshState: PullToRefreshState? = null
lateinit var scope: CoroutineScope
restorationTester.setContent {
pullToRefreshState = rememberPullToRefreshState()
scope = rememberCoroutineScope()
}
with(pullToRefreshState!!) {
rule.runOnIdle { scope.launch { startRefresh() } }
pullToRefreshState = null
restorationTester.emulateSavedInstanceStateRestore()
assertThat(isRefreshing).isTrue()
}
}
private val PullRefreshTag = "PullRefresh"
private val pullRefreshNode = rule.onNodeWithTag(PullRefreshTag)
}