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

Commit 94ba5f01 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin Committed by Ale Nijamkin
Browse files

[Media] Content revealing pager.

Adds support for a horizontal pager that doesn't dismiss. Instead, it
reveals a box with a spinning/fading settings icon that can be clicked
to open media settings.

The CL adds the new ContentRevealingHorizontalPager, cleans up the
previously-existing swipe-to-dismiss pager in
DismissibleHorizontalPager, and ties it all in Media.kt.

The CL also includes minimal changes to NestedDraggable and
OffsetOverscrollEffect that are necessary to support the new
swipe-to-reveal pager.

Bug: 397989775
Test: manually verified both swipe-to-dismiss and to-reveal in the
Compose gallery app
Flag: EXEMPT code isn't yet used in production

Change-Id: I216c5df586c959c1d1adddd78ddfa5c47632ede0
parent 9118e512
Loading
Loading
Loading
Loading
+0 −2
Original line number Diff line number Diff line
@@ -16,7 +16,6 @@

package com.android.compose.gesture.effect

import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.OverscrollFactory
@@ -94,7 +93,6 @@ class OffsetOverscrollEffect(animationScope: CoroutineScope, animationSpec: Anim
    companion object {
        private val MaxDistance = 400.dp

        @VisibleForTesting
        fun computeOffset(density: Density, overscrollDistance: Float): Int {
            val maxDistancePx = with(density) { MaxDistance.toPx() }
            val progress = ProgressConverter.Default.convert(overscrollDistance / maxDistancePx)
+119 −39
Original line number Diff line number Diff line
@@ -33,6 +33,7 @@ import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -54,6 +55,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
@@ -108,6 +111,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastRoundToInt
@@ -120,6 +124,7 @@ import com.android.compose.animation.scene.SceneKey
import com.android.compose.animation.scene.SceneTransitionLayout
import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState
import com.android.compose.animation.scene.transitions
import com.android.compose.gesture.effect.rememberOffsetOverscrollEffect
import com.android.compose.ui.graphics.painter.rememberDrawablePainter
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
@@ -137,7 +142,9 @@ import com.android.systemui.media.remedia.ui.viewmodel.MediaNavigationViewModel
import com.android.systemui.media.remedia.ui.viewmodel.MediaOutputSwitcherChipViewModel
import com.android.systemui.media.remedia.ui.viewmodel.MediaPlayPauseActionViewModel
import com.android.systemui.media.remedia.ui.viewmodel.MediaSecondaryActionViewModel
import com.android.systemui.media.remedia.ui.viewmodel.MediaSettingsButtonViewModel
import com.android.systemui.media.remedia.ui.viewmodel.MediaViewModel
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

@@ -208,60 +215,74 @@ private fun CardCarouselContent(
    onDismissed: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val pagerState =
        rememberDismissibleHorizontalPagerState(
            isDismissible = behavior.isCarouselDismissible,
            isScrollingEnabled = behavior.isCarouselScrollingEnabled,
        ) {
            viewModel.cards.size
        }
    val pagerState = rememberPagerState { viewModel.cards.size }
    LaunchedEffect(pagerState.currentPage) { viewModel.onCardSelected(pagerState.currentPage) }

    var isFalseTouchDetected: Boolean by
        remember(behavior.isCarouselScrollFalseTouch) { mutableStateOf(false) }
    val isSwipingEnabled = behavior.isCarouselScrollingEnabled && !isFalseTouchDetected

    val roundedCornerShape = RoundedCornerShape(32.dp)

    LaunchedEffect(pagerState.pagerState.currentPage) {
        viewModel.onCardSelected(pagerState.pagerState.currentPage)
    Box(
        modifier =
            modifier.padding(8.dp).clip(roundedCornerShape).pointerInput(behavior) {
                if (behavior.isCarouselScrollFalseTouch != null) {
                    awaitEachGesture {
                        awaitFirstDown(false, PointerEventPass.Initial)
                        isFalseTouchDetected = behavior.isCarouselScrollFalseTouch.invoke()
                    }

    DismissibleHorizontalPager(
                }
            }
    ) {
        @Composable
        fun PagerContent(overscrollEffect: OverscrollEffect? = null) {
            Box {
                HorizontalPager(
                    state = pagerState,
        onDismissed = onDismissed,
                    userScrollEnabled = isSwipingEnabled,
                    pageSpacing = 8.dp,
        key = { index -> viewModel.cards[index].key },
        indicator = {
            if (pagerState.pagerState.pageCount > 1) {
                    key = { index: Int -> viewModel.cards[index].key },
                    overscrollEffect = overscrollEffect ?: rememberOffsetOverscrollEffect(),
                ) { pageIndex: Int ->
                    Card(
                        viewModel = viewModel.cards[pageIndex],
                        presentationStyle = presentationStyle,
                        modifier = Modifier.clip(roundedCornerShape),
                    )
                }

                if (pagerState.pageCount > 1) {
                    PagerDots(
                    pagerState = pagerState.pagerState,
                        pagerState = pagerState,
                        activeColor = Color(0xffdee0ff),
                        nonActiveColor = Color(0xffa7a9ca),
                        dotSize = 6.dp,
                        spaceSize = 6.dp,
                    modifier =
                        Modifier.align(Alignment.BottomCenter).padding(8.dp).graphicsLayer {
                            translationX = pagerState.offset.value
                        },
                        modifier = Modifier.align(Alignment.BottomCenter).padding(8.dp),
                    )
                }
        },
        isFalseTouchDetected = isFalseTouchDetected,
        modifier =
            modifier.padding(8.dp).clip(roundedCornerShape).pointerInput(behavior) {
                if (behavior.isCarouselScrollFalseTouch != null) {
                    awaitEachGesture {
                        awaitFirstDown(false, PointerEventPass.Initial)
                        isFalseTouchDetected = behavior.isCarouselScrollFalseTouch.invoke()
            }
        }

        if (behavior.isCarouselDismissible) {
            SwipeToDismiss(content = { PagerContent() }, onDismissed = onDismissed)
        } else {
            val overscrollEffect = rememberOffsetOverscrollEffect()
            SwipeToReveal(
                foregroundContent = { PagerContent(overscrollEffect) },
                foregroundContentEffect = overscrollEffect,
                revealedContent = { revealAmount ->
                    RevealedContent(
                        viewModel = viewModel.settingsButtonViewModel,
                        revealAmount = revealAmount,
                    )
                },
    ) { index ->
        Card(
            viewModel = viewModel.cards[index],
            presentationStyle = presentationStyle,
            modifier = Modifier.clip(roundedCornerShape),
                isSwipingEnabled = isSwipingEnabled,
            )
        }
    }
}

/** Renders the UI of a single media card. */
@Composable
@@ -1186,6 +1207,65 @@ private fun SecondaryActionContent(
    }
}

/**
 * Renders the revealed content on the sides of the horizontal pager.
 *
 * @param revealAmount A callback that can return the amount of revealing done. This value will be
 *   in a range slightly larger than `-1` to `+1` where `1` is fully revealed on the left-hand side,
 *   `-1` is fully revealed on the right-hand side, and `0` is not revealed at all. Numbers lower
 *   than `-1` or greater than `1` are possible when the overscroll effect adds additional pixels of
 *   offset.
 */
@Composable
private fun RevealedContent(
    viewModel: MediaSettingsButtonViewModel,
    revealAmount: () -> Float,
    modifier: Modifier = Modifier,
) {
    val horizontalPadding = 18.dp

    // This custom layout's purpose is only to place the icon in the center of the revealed content,
    // taking into account the amount of reveal.
    Layout(
        content = {
            Icon(
                icon = viewModel.icon,
                modifier =
                    Modifier.size(48.dp)
                        .padding(12.dp)
                        .graphicsLayer {
                            alpha = abs(revealAmount()).fastCoerceIn(0f, 1f)
                            rotationZ = revealAmount() * 90
                        }
                        .clickable { viewModel.onClick() },
            )
        },
        modifier = modifier,
    ) { measurables, constraints ->
        check(measurables.size == 1)
        val placeable = measurables[0].measure(constraints)
        val totalWidth =
            min(horizontalPadding.roundToPx() * 2 + placeable.measuredWidth, constraints.maxWidth)

        layout(totalWidth, constraints.maxHeight) {
            coordinates?.size?.let { size ->
                val reveal = revealAmount()
                val x =
                    if (reveal >= 0f) {
                        ((size.width * abs(reveal)) - placeable.measuredWidth) / 2
                    } else {
                        size.width * (1 - abs(reveal) / 2) - placeable.measuredWidth / 2
                    }

                placeable.place(
                    x = x.fastRoundToInt(),
                    y = (size.height - placeable.measuredHeight) / 2,
                )
            }
        }
    }
}

/** Enumerates all supported media presentation styles. */
enum class MediaPresentationStyle {
    /** The "normal" 3-row carousel look. */
+115 −0
Original line number Diff line number Diff line
@@ -17,85 +17,50 @@
package com.android.systemui.media.remedia.ui.compose

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerScope
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
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.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.android.compose.modifiers.thenIf
import androidx.compose.ui.util.fastRoundToInt
import kotlinx.coroutines.launch

/** State for a [DismissibleHorizontalPager] */
class DismissibleHorizontalPagerState(
    val isDismissible: Boolean,
    val isScrollingEnabled: Boolean,
    val pagerState: PagerState,
    val offset: Animatable<Float, AnimationVector1D>,
)

/**
 * Returns a remembered [DismissibleHorizontalPagerState] that starts at [initialPage] and has
 * [pageCount] total pages.
 */
@Composable
fun rememberDismissibleHorizontalPagerState(
    isDismissible: Boolean = true,
    isScrollingEnabled: Boolean = true,
    initialPage: Int = 0,
    pageCount: () -> Int,
): DismissibleHorizontalPagerState {
    val pagerState = rememberPagerState(initialPage = initialPage, pageCount = pageCount)
    val offset = remember { Animatable(0f) }

    return remember(isDismissible, isScrollingEnabled, pagerState, offset) {
        DismissibleHorizontalPagerState(
            isDismissible = isDismissible,
            isScrollingEnabled = isScrollingEnabled,
            pagerState = pagerState,
            offset = offset,
        )
    }
}

/**
 * A [HorizontalPager] that can be swiped-away to dismiss by the user when swiped farther left or
 * right once fully scrolled to the left-most or right-most page, respectively.
 */
/** Swipe to dismiss that supports nested scrolling. */
@Composable
fun DismissibleHorizontalPager(
    state: DismissibleHorizontalPagerState,
fun SwipeToDismiss(
    content: @Composable (overscrollEffect: OverscrollEffect?) -> Unit,
    onDismissed: () -> Unit,
    modifier: Modifier = Modifier,
    key: ((Int) -> Any)? = null,
    pageSpacing: Dp = 0.dp,
    isFalseTouchDetected: Boolean,
    indicator: @Composable BoxScope.() -> Unit,
    pageContent: @Composable PagerScope.(page: Int) -> Unit,
) {
    val scope = rememberCoroutineScope()
    val offsetAnimatable = remember { Animatable(0f) }

    // This is the width of the revealed content UI box. It's not a state because it's not
    // observed in any composition and is an object with a value to avoid the extra cost
    // associated with boxing and unboxing an int.
    val revealedContentBoxWidth = remember {
        object {
            var value = 0
        }
    }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                return if (state.offset.value > 0f && available.x < 0f) {
                    scope.launch { state.offset.snapTo(state.offset.value + available.x) }
                return if (offsetAnimatable.value > 0f && available.x < 0f) {
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else if (state.offset.value < 0f && available.x > 0f) {
                    scope.launch { state.offset.snapTo(state.offset.value + available.x) }
                } else if (offsetAnimatable.value < 0f && available.x > 0f) {
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else {
                    Offset.Zero
@@ -108,10 +73,10 @@ fun DismissibleHorizontalPager(
                source: NestedScrollSource,
            ): Offset {
                return if (available.x > 0f) {
                    scope.launch { state.offset.snapTo(state.offset.value + available.x) }
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else if (available.x < 0f) {
                    scope.launch { state.offset.snapTo(state.offset.value + available.x) }
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else {
                    Offset.Zero
@@ -120,18 +85,16 @@ fun DismissibleHorizontalPager(

            override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
                scope.launch {
                    state.offset.animateTo(
                        if (state.offset.value >= state.pagerState.layoutInfo.pageSize / 2f) {
                            state.pagerState.layoutInfo.pageSize * 2f
                        } else if (
                            state.offset.value <= -state.pagerState.layoutInfo.pageSize / 2f
                        ) {
                            -state.pagerState.layoutInfo.pageSize * 2f
                    offsetAnimatable.animateTo(
                        if (offsetAnimatable.value >= revealedContentBoxWidth.value / 2f) {
                            revealedContentBoxWidth.value * 2f
                        } else if (offsetAnimatable.value <= -revealedContentBoxWidth.value / 2f) {
                            -revealedContentBoxWidth.value * 2f
                        } else {
                            0f
                        }
                    )
                    if (state.offset.value != 0f) {
                    if (offsetAnimatable.value != 0f) {
                        onDismissed()
                    }
                }
@@ -140,21 +103,13 @@ fun DismissibleHorizontalPager(
        }
    }

    Box(modifier = modifier) {
        HorizontalPager(
            state = state.pagerState,
            userScrollEnabled = state.isScrollingEnabled && !isFalseTouchDetected,
            key = key,
            pageSpacing = pageSpacing,
            pageContent = pageContent,
    Box(
        modifier =
                Modifier.thenIf(state.isDismissible) {
                    Modifier.nestedScroll(nestedScrollConnection).graphicsLayer {
                        translationX = state.offset.value
                    }
                },
        )

        indicator()
            modifier
                .onSizeChanged { revealedContentBoxWidth.value = it.width }
                .nestedScroll(nestedScrollConnection)
                .offset { IntOffset(x = offsetAnimatable.value.fastRoundToInt(), y = 0) }
    ) {
        content(null)
    }
}
+264 −0

File added.

Preview size limit exceeded, changes collapsed.

+21 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.systemui.media.remedia.ui.viewmodel

import com.android.systemui.common.shared.model.Icon

data class MediaSettingsButtonViewModel(val icon: Icon.Resource, val onClick: () -> Unit)
Loading