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

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

Added LargeTopAppBarNestedScrollConnection

A NestedScrollConnection that listens for all vertical scroll events and
 responds in the
following way:
- If you **scroll up**, it **first brings the [height]** back to the
[minHeight] and then allows scrolling of the children (usually the
content).
- If you **scroll down**, it **first allows scrolling of the children**
(usually the content) and then resets the [height] to [maxHeight].
This behavior is useful for implementing a
[Large top app bar](https://m3.material.io/components/top-app-bar/specs)
 effect or something similar.

Test: atest LargeTopAppBarNestedScrollConnectionTest
Bug: 291025415
(cherry picked from https://googleplex-android-review.googlesource.com/q/commit:6b12c257c0d62cddab4041227348638472487241)
Change-Id: Idc0669d1a95b804acda9a5546ff3faa11e4c6e9c
parent baed8958
Loading
Loading
Loading
Loading
+92 −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

/**
 * A [NestedScrollConnection] that listens for all vertical scroll events and responds in the
 * following way:
 * - If you **scroll up**, it **first brings the [height]** back to the [minHeight] and then allows
 *   scrolling of the children (usually the content).
 * - If you **scroll down**, it **first allows scrolling of the children** (usually the content) and
 *   then resets the [height] to [maxHeight].
 *
 * This behavior is useful for implementing a
 * [Large top app bar](https://m3.material.io/components/top-app-bar/specs) effect or something
 * similar.
 *
 * @sample com.android.compose.animation.scene.demo.Shade
 */
class LargeTopAppBarNestedScrollConnection(
    private val height: () -> Float,
    private val onChangeHeight: (Float) -> Unit,
    private val minHeight: Float,
    private val maxHeight: Float,
) : NestedScrollConnection {

    constructor(
        height: () -> Float,
        onHeightChanged: (Float) -> Unit,
        heightRange: ClosedFloatingPointRange<Float>,
    ) : this(
        height = height,
        onChangeHeight = onHeightChanged,
        minHeight = heightRange.start,
        maxHeight = heightRange.endInclusive,
    )

    /**
     * When swiping up, the LargeTopAppBar will shrink (to [minHeight]) and the content will expand.
     * Then, you can then scroll down the content.
     */
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val y = available.y
        val currentHeight = height()
        if (y >= 0 || currentHeight <= minHeight) {
            return Offset.Zero
        }

        val amountLeft = minHeight - currentHeight
        val amountConsumed = y.coerceAtLeast(amountLeft)
        onChangeHeight(currentHeight + amountConsumed)
        return Offset(0f, amountConsumed)
    }

    /**
     * When swiping down, the content will scroll up until it reaches the top. Then, the
     * LargeTopAppBar will expand until it reaches its [maxHeight].
     */
    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        val y = available.y
        val currentHeight = height()
        if (y <= 0 || currentHeight >= maxHeight) {
            return Offset.Zero
        }

        val amountLeft = maxHeight - currentHeight
        val amountConsumed = y.coerceAtMost(amountLeft)
        onChangeHeight(currentHeight + amountConsumed)
        return Offset(0f, amountConsumed)
    }
}
+145 −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.NestedScrollSource
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized

@RunWith(Parameterized::class)
class LargeTopAppBarNestedScrollConnectionTest(testCase: TestCase) {
    val scrollSource = testCase.scrollSource

    private var height = 0f

    private fun buildScrollConnection(heightRange: ClosedFloatingPointRange<Float>) =
        LargeTopAppBarNestedScrollConnection(
            height = { height },
            onHeightChanged = { height = it },
            heightRange = heightRange,
        )

    @Test
    fun onScrollUp_consumeHeightFirst() {
        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
        height = 1f

        val offsetConsumed =
            scrollConnection.onPreScroll(available = Offset(x = 0f, y = -1f), source = scrollSource)

        // It can decrease by 1 the height
        assertThat(offsetConsumed).isEqualTo(Offset(0f, -1f))
        assertThat(height).isEqualTo(0f)
    }

    @Test
    fun onScrollUp_consumeDownToMin() {
        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
        height = 0f

        val offsetConsumed =
            scrollConnection.onPreScroll(available = Offset(x = 0f, y = -1f), source = scrollSource)

        // It should not change the height (already at min)
        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
        assertThat(height).isEqualTo(0f)
    }

    @Test
    fun onScrollUp_ignorePostScroll() {
        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
        height = 1f

        val offsetConsumed =
            scrollConnection.onPostScroll(
                consumed = Offset.Zero,
                available = Offset(x = 0f, y = -1f),
                source = scrollSource
            )

        // It should ignore all onPostScroll events
        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
        assertThat(height).isEqualTo(1f)
    }

    @Test
    fun onScrollDown_allowConsumeContentFirst() {
        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
        height = 1f

        val offsetConsumed =
            scrollConnection.onPreScroll(available = Offset(x = 0f, y = 1f), source = scrollSource)

        // It should ignore all onPreScroll events
        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
        assertThat(height).isEqualTo(1f)
    }

    @Test
    fun onScrollDown_consumeHeightPostScroll() {
        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
        height = 1f

        val offsetConsumed =
            scrollConnection.onPostScroll(
                consumed = Offset.Zero,
                available = Offset(x = 0f, y = 1f),
                source = scrollSource
            )

        // It can increase by 1 the height
        assertThat(offsetConsumed).isEqualTo(Offset(0f, 1f))
        assertThat(height).isEqualTo(2f)
    }

    @Test
    fun onScrollDown_consumeUpToMax() {
        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
        height = 2f

        val offsetConsumed =
            scrollConnection.onPostScroll(
                consumed = Offset.Zero,
                available = Offset(x = 0f, y = 1f),
                source = scrollSource
            )

        // It should not change the height (already at max)
        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
        assertThat(height).isEqualTo(2f)
    }

    // NestedScroll Source is a value/inline class and must be wrapped in a parameterized test
    // https://youtrack.jetbrains.com/issue/KT-35523/Parameterized-JUnit-tests-with-inline-classes-throw-IllegalArgumentException
    data class TestCase(val scrollSource: NestedScrollSource) {
        override fun toString() = scrollSource.toString()
    }

    companion object {
        @Parameterized.Parameters(name = "{0}")
        @JvmStatic
        fun data(): List<TestCase> =
            listOf(
                TestCase(NestedScrollSource.Drag),
                TestCase(NestedScrollSource.Fling),
                TestCase(NestedScrollSource.Wheel),
            )
    }
}