Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 621a6a3c authored by omarmt's avatar omarmt Committed by Omar Miatello
Browse files

Added PriorityPostNestedScrollConnection

This NestedScrollConnection waits for a child to scroll, and then
decides if it should take over scrolling. If it does, it will scroll
before its children.

Test: atest PriorityPostNestedScrollConnection
Bug: 291025415
(cherry picked from https://googleplex-android-review.googlesource.com/q/commit:c4b24a4aeb01f5f5aa1303f9c15d69d36139cd9f)
Change-Id: Ib34e8cd2212db6275df691c8012f5a51909ece4f
parent ccb47564
Loading
Loading
Loading
Loading
+107 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 com.android.compose.nestedscroll

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity

/**
 * This [NestedScrollConnection] waits for a child to scroll ([onPostScroll]), and then decides (via
 * [canStart]) if it should take over scrolling. If it does, it will scroll before its children,
 * until [canContinueScroll] allows it.
 *
 * Note: Call [reset] before destroying this object to make sure you always get a call to [onStop]
 * after [onStart].
 *
 * @sample com.android.compose.animation.scene.rememberSwipeToSceneNestedScrollConnection
 */
class PriorityPostNestedScrollConnection(
    private val canStart: (offsetAvailable: Offset) -> Boolean,
    private val canContinueScroll: () -> Boolean,
    private val onStart: () -> Unit,
    private val onScroll: (offsetAvailable: Offset) -> Offset,
    private val onStop: (velocityAvailable: Velocity) -> Velocity,
    private val onPostFling: suspend (velocityAvailable: Velocity) -> Velocity,
) : NestedScrollConnection {

    /** In priority mode [onPreScroll] events are first consumed by the parent, via [onScroll]. */
    private var isPriorityMode = false

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource,
    ): Offset {
        if (isPriorityMode || source == NestedScrollSource.Fling || !canStart(available)) {
            // The priority mode cannot start so we won't consume the available offset.
            return Offset.Zero
        }

        // Step 1: It's our turn! We start capturing scroll events when one of our children has an
        // available offset following a scroll event.
        isPriorityMode = true

        // Note: onStop will be called if we cannot continue to scroll (step 3a), or the finger is
        // lifted (step 3b), or this object has been destroyed (step 3c).
        onStart()

        return onScroll(available)
    }

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        if (!isPriorityMode) {
            return Offset.Zero
        }

        if (!canContinueScroll()) {
            // Step 3a: We have lost priority and we no longer need to intercept scroll events.
            onPriorityStop(velocity = Velocity.Zero)
            return Offset.Zero
        }

        // Step 2: We have the priority and can consume the scroll events.
        return onScroll(available)
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        // Step 3b: The finger is lifted, we can stop intercepting scroll events and use the speed
        // of the fling gesture.
        return onPriorityStop(velocity = available)
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return onPostFling(available)
    }

    /** Method to call before destroying the object or to reset the initial state. */
    fun reset() {
        // Step 3c: To ensure that an onStop is always called for every onStart.
        onPriorityStop(velocity = Velocity.Zero)
    }

    private fun onPriorityStop(velocity: Velocity): Velocity {
        if (!isPriorityMode) {
            return Velocity.Zero
        }

        isPriorityMode = false

        return onStop(velocity)
    }
}
+172 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.compose.nestedscroll

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class PriorityPostNestedScrollConnectionTest {
    private var canStart = false
    private var canContinueScroll = false
    private var isStarted = false
    private var lastScroll: Offset? = null
    private var returnOnScroll = Offset.Zero
    private var lastStop: Velocity? = null
    private var returnOnStop = Velocity.Zero
    private var lastOnPostFling: Velocity? = null
    private var returnOnPostFling = Velocity.Zero

    private val scrollConnection =
        PriorityPostNestedScrollConnection(
            canStart = { canStart },
            canContinueScroll = { canContinueScroll },
            onStart = { isStarted = true },
            onScroll = {
                lastScroll = it
                returnOnScroll
            },
            onStop = {
                lastStop = it
                returnOnStop
            },
            onPostFling = {
                lastOnPostFling = it
                returnOnPostFling
            },
        )

    private val offset1 = Offset(1f, 1f)
    private val offset2 = Offset(2f, 2f)
    private val velocity1 = Velocity(1f, 1f)
    private val velocity2 = Velocity(2f, 2f)

    private fun startPriorityMode() {
        canStart = true
        scrollConnection.onPostScroll(
            consumed = Offset.Zero,
            available = Offset.Zero,
            source = NestedScrollSource.Drag
        )
    }

    @Test
    fun step1_priorityModeShouldStartOnlyOnPostScroll() = runTest {
        canStart = true

        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
        assertThat(isStarted).isEqualTo(false)

        scrollConnection.onPreFling(available = Velocity.Zero)
        assertThat(isStarted).isEqualTo(false)

        scrollConnection.onPostFling(consumed = Velocity.Zero, available = Velocity.Zero)
        assertThat(isStarted).isEqualTo(false)

        startPriorityMode()
        assertThat(isStarted).isEqualTo(true)
    }

    @Test
    fun step1_priorityModeShouldStartOnlyIfAllowed() {
        scrollConnection.onPostScroll(
            consumed = Offset.Zero,
            available = Offset.Zero,
            source = NestedScrollSource.Drag
        )
        assertThat(isStarted).isEqualTo(false)

        startPriorityMode()
        assertThat(isStarted).isEqualTo(true)
    }

    @Test
    fun step1_onPriorityModeStarted_receiveAvailableOffset() {
        canStart = true

        scrollConnection.onPostScroll(
            consumed = offset1,
            available = offset2,
            source = NestedScrollSource.Drag
        )

        assertThat(lastScroll).isEqualTo(offset2)
    }

    @Test
    fun step2_onPriorityMode_shouldContinueIfAllowed() {
        startPriorityMode()
        canContinueScroll = true

        scrollConnection.onPreScroll(available = offset1, source = NestedScrollSource.Drag)
        assertThat(lastScroll).isEqualTo(offset1)

        canContinueScroll = false
        scrollConnection.onPreScroll(available = offset2, source = NestedScrollSource.Drag)
        assertThat(lastScroll).isNotEqualTo(offset2)
        assertThat(lastScroll).isEqualTo(offset1)
    }

    @Test
    fun step3a_onPriorityMode_shouldStopIfCannotContinue() {
        startPriorityMode()
        canContinueScroll = false

        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)

        assertThat(lastStop).isNotNull()
    }

    @Test
    fun step3b_onPriorityMode_shouldStopOnFling() = runTest {
        startPriorityMode()
        canContinueScroll = true

        scrollConnection.onPreFling(available = Velocity.Zero)

        assertThat(lastStop).isNotNull()
    }

    @Test
    fun step3c_onPriorityMode_shouldStopOnReset() {
        startPriorityMode()
        canContinueScroll = true

        scrollConnection.reset()

        assertThat(lastStop).isNotNull()
    }

    @Test
    fun receive_onPostFling() = runTest {
        scrollConnection.onPostFling(
            consumed = velocity1,
            available = velocity2,
        )

        assertThat(lastOnPostFling).isEqualTo(velocity2)
    }
}