[go: nahoru, domu]

blob: b3d3b3d0d08ce0344c6ed126e500aa526cd5585c [file] [log] [blame]
/*
* 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.compose.foundation.gesture.snapping
import androidx.compose.animation.SplineBasedFloatDecayAnimationSpec
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.FloatDecayAnimationSpec
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.VectorizedAnimationSpec
import androidx.compose.animation.core.generateDecayAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.snapping.MinFlingVelocityDp
import androidx.compose.foundation.gestures.snapping.NoVelocity
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.findClosestOffset
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.Density
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import kotlin.test.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@MediumTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalFoundationApi::class)
class SnapFlingBehaviorTest {
@get:Rule
val rule = createComposeRule()
private val inspectSpringAnimationSpec = InspectSpringAnimationSpec(spring())
private val density: Density
get() = rule.density
@Test
fun performFling_whenVelocityIsBelowThreshold_shouldShortSnap() {
val testLayoutInfoProvider = TestLayoutInfoProvider()
rule.setContent {
val testFlingBehavior = rememberSnapFlingBehavior(testLayoutInfoProvider)
VelocityEffect(testFlingBehavior, calculateVelocityThreshold() - 1)
}
rule.runOnIdle {
assertEquals(0, testLayoutInfoProvider.calculateApproachOffsetCount)
}
}
@Test
fun performFling_whenVelocityIsAboveThreshold_shouldLongSnap() {
val testLayoutInfoProvider = TestLayoutInfoProvider()
rule.setContent {
val testFlingBehavior = rememberSnapFlingBehavior(testLayoutInfoProvider)
VelocityEffect(testFlingBehavior, calculateVelocityThreshold() + 1)
}
rule.runOnIdle {
assertEquals(1, testLayoutInfoProvider.calculateApproachOffsetCount)
}
}
@Test
fun performFling_afterSnappingVelocity_shouldReturnNoVelocity() {
val testLayoutInfoProvider = TestLayoutInfoProvider()
var afterFlingVelocity = 0f
rule.setContent {
val scrollableState = rememberScrollableState(consumeScrollDelta = { it })
val testFlingBehavior = rememberSnapFlingBehavior(testLayoutInfoProvider)
LaunchedEffect(Unit) {
scrollableState.scroll {
afterFlingVelocity = with(testFlingBehavior) {
performFling(50000f)
}
}
}
}
rule.runOnIdle {
assertEquals(NoVelocity, afterFlingVelocity)
}
}
@Test
fun findClosestOffset_noFlingDirection_shouldReturnAbsoluteDistance() {
val testLayoutInfoProvider = TestLayoutInfoProvider()
val offset = findClosestOffset(0f, testLayoutInfoProvider, density)
assertEquals(offset, MinOffset)
}
@Test
fun findClosestOffset_flingDirection_shouldReturnCorrectBound() {
val testLayoutInfoProvider = TestLayoutInfoProvider()
val forwardOffset = findClosestOffset(1f, testLayoutInfoProvider, density)
val backwardOffset = findClosestOffset(-1f, testLayoutInfoProvider, density)
assertEquals(forwardOffset, MaxOffset)
assertEquals(backwardOffset, MinOffset)
}
@Test
fun approach_cannotDecay_justSnap() {
val testLayoutInfoProvider = TestLayoutInfoProvider(approachOffset = SnapStep * 5)
var inspectSplineAnimationSpec: InspectSplineAnimationSpec? = null
rule.setContent {
val splineAnimationSpec = rememberInspectSplineAnimationSpec().also {
inspectSplineAnimationSpec = it
}
val testFlingBehavior = rememberSnapFlingBehavior(
snapLayoutInfoProvider = testLayoutInfoProvider,
approachAnimationSpec = splineAnimationSpec.generateDecayAnimationSpec()
)
VelocityEffect(testFlingBehavior, calculateVelocityThreshold() * 2)
}
rule.runOnIdle {
assertEquals(0, inspectSplineAnimationSpec?.animationWasExecutions)
}
}
@Test
fun approach_canDecayWithoutHalfStep_decayAndSnap() {
val testLayoutInfoProvider = TestLayoutInfoProvider(maxOffset = 100f)
var inspectSplineAnimationSpec: InspectSplineAnimationSpec? = null
rule.setContent {
val splineAnimationSpec = rememberInspectSplineAnimationSpec().also {
inspectSplineAnimationSpec = it
}
val testFlingBehavior = rememberSnapFlingBehavior(
snapLayoutInfoProvider = testLayoutInfoProvider,
approachAnimationSpec = splineAnimationSpec.generateDecayAnimationSpec(),
snapAnimationSpec = inspectSpringAnimationSpec
)
VelocityEffect(testFlingBehavior, calculateVelocityThreshold() * 5)
}
rule.runOnIdle {
assertEquals(1, inspectSplineAnimationSpec?.animationWasExecutions)
assertEquals(1, inspectSpringAnimationSpec.animationWasExecutions)
}
}
@Test
fun approach_canDecayWithHalfStep_doubleDecayAndSnap() {
val testLayoutInfoProvider = TestLayoutInfoProvider(maxOffset = 300f, snapStep = 400f)
var inspectSplineAnimationSpec: InspectSplineAnimationSpec? = null
rule.setContent {
val splineAnimationSpec = rememberInspectSplineAnimationSpec().also {
inspectSplineAnimationSpec = it
}
val testFlingBehavior = rememberSnapFlingBehavior(
snapLayoutInfoProvider = testLayoutInfoProvider,
approachAnimationSpec = splineAnimationSpec.generateDecayAnimationSpec(),
snapAnimationSpec = inspectSpringAnimationSpec
)
VelocityEffect(testFlingBehavior, calculateVelocityThreshold() * 3)
}
rule.runOnIdle {
assertEquals(2, inspectSplineAnimationSpec?.animationWasExecutions)
assertEquals(1, inspectSpringAnimationSpec.animationWasExecutions)
}
}
}
@Composable
private fun VelocityEffect(testFlingBehavior: FlingBehavior, velocity: Float) {
val scrollableState = rememberScrollableState(consumeScrollDelta = { it })
LaunchedEffect(Unit) {
scrollableState.scroll {
with(testFlingBehavior) {
performFling(velocity)
}
}
}
}
private class InspectSpringAnimationSpec(
private val springSpec: SpringSpec<Float>
) : AnimationSpec<Float> by springSpec {
var animationWasExecutions = 0
override fun <V : AnimationVector> vectorize(
converter: TwoWayConverter<Float, V>
): VectorizedAnimationSpec<V> {
animationWasExecutions++
return springSpec.vectorize(converter)
}
}
private class InspectSplineAnimationSpec(
private val splineBasedFloatDecayAnimationSpec: SplineBasedFloatDecayAnimationSpec
) : FloatDecayAnimationSpec by splineBasedFloatDecayAnimationSpec {
private var valueFromNanosCalls = 0
val animationWasExecutions: Int
get() = valueFromNanosCalls / 2
override fun getValueFromNanos(
playTimeNanos: Long,
initialValue: Float,
initialVelocity: Float
): Float {
if (playTimeNanos == 0L) {
valueFromNanosCalls++
}
return splineBasedFloatDecayAnimationSpec.getValueFromNanos(
playTimeNanos,
initialValue,
initialVelocity
)
}
}
@Composable
private fun rememberInspectSplineAnimationSpec(): InspectSplineAnimationSpec {
val density = LocalDensity.current
return remember {
InspectSplineAnimationSpec(
SplineBasedFloatDecayAnimationSpec(density)
)
}
}
@Composable
private fun calculateVelocityThreshold(): Float {
val density = LocalDensity.current
return with(density) { MinFlingVelocityDp.toPx() }
}
private const val SnapStep = 250f
private const val MinOffset = -200f
private const val MaxOffset = 300f
@OptIn(ExperimentalFoundationApi::class)
private class TestLayoutInfoProvider(
val minOffset: Float = MinOffset,
val maxOffset: Float = MaxOffset,
val snapStep: Float = SnapStep,
val approachOffset: Float = 0f
) : SnapLayoutInfoProvider {
var calculateApproachOffsetCount = 0
override fun Density.snapStepSize(): Float {
return snapStep
}
override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange<Float> {
return minOffset.rangeTo(maxOffset)
}
override fun Density.calculateApproachOffset(initialVelocity: Float): Float {
calculateApproachOffsetCount++
return approachOffset
}
}