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

Commit 1263ecaf authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[multi-shade] Lock screen touch integration.

Integrates the multi-shade framework into the existing touch routing
system in NotificationShadeWindowView such that, on the lock screen,
swiping down shows the dual shades and tapping outside collapses them.

The approach taken was to implement a second interactor,
MultiShadeMotionEventInteractor and integrating it where the
DragDownHelper is currently integrated.

The next steps are:
a. Refactor the bouncer a bit so it can receive its expansion from
multi-shade, not just from the current shade expansion
b. Drive the expansion of the bouncer by dragging up when the shades are
collapsed, while on the lock screen
c. Figure out why clicking on the user switcher chip in the status bar
doesn't work in dual shade (there likely is some interference between
the right-hand side shade and the chip)

Bug: 274159734
Test: included new unit tests for MultiShadeMotionEventInteractor
Test: updated existing unit tests for MultiShadeInteractor
Test: manually verified the following interactions on the lock screen:
1. Dragging down anywhere reveals the correct shade
2. As the shades are revealed, the scrim fades in (there was a bug in
   this before)
3. When shade A is expanded, touching anywhere in the area of shade B
   collapses shade A
4. Clicking outside the shade collapses it
5. When any shade is expanded, it's not possible to touch things behind
   the scrim
6. When both shades are collapsed, touch passes correctly to the
   existing UI elements like the notifications

Change-Id: I94130e5e8dbb3b2a398452651855a47f231c2764
parent 4ea5e12c
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -53,6 +53,7 @@ fun MultiShade(
    modifier: Modifier = Modifier,
) {
    val isScrimEnabled: Boolean by viewModel.isScrimEnabled.collectAsState()
    val scrimAlpha: Float by viewModel.scrimAlpha.collectAsState()

    // TODO(b/273298030): find a different way to get the height constraint from its parent.
    BoxWithConstraints(modifier = modifier) {
@@ -61,7 +62,7 @@ fun MultiShade(
        Scrim(
            modifier = Modifier.fillMaxSize(),
            remoteTouch = viewModel::onScrimTouched,
            alpha = { viewModel.scrimAlpha.value },
            alpha = { scrimAlpha },
            isScrimEnabled = isScrimEnabled,
        )
        Shade(
+5 −0
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.multishade.data.model.MultiShadeInteractionModel
import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
import com.android.systemui.multishade.data.repository.MultiShadeRepository
import com.android.systemui.multishade.shared.math.isZero
import com.android.systemui.multishade.shared.model.ProxiedInputModel
import com.android.systemui.multishade.shared.model.ShadeConfig
import com.android.systemui.multishade.shared.model.ShadeId
@@ -63,6 +64,10 @@ constructor(
            }
        }

    /** Whether any shade is expanded, even a little bit. */
    val isAnyShadeExpanded: Flow<Boolean> =
        maxShadeExpansion.map { maxExpansion -> !maxExpansion.isZero() }.distinctUntilChanged()

    /**
     * A _processed_ version of the proxied input flow.
     *
+191 −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.systemui.multishade.domain.interactor

import android.content.Context
import android.view.MotionEvent
import android.view.ViewConfiguration
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.multishade.shared.model.ProxiedInputModel
import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn

/**
 * Encapsulates business logic to handle [MotionEvent]-based user input.
 *
 * This class is meant purely for the legacy `View`-based system to be able to pass `MotionEvent`s
 * into the newer multi-shade framework for processing.
 */
class MultiShadeMotionEventInteractor
@Inject
constructor(
    @Application private val applicationContext: Context,
    @Application private val applicationScope: CoroutineScope,
    private val interactor: MultiShadeInteractor,
) {

    private val isAnyShadeExpanded: StateFlow<Boolean> =
        interactor.isAnyShadeExpanded.stateIn(
            scope = applicationScope,
            started = SharingStarted.Eagerly,
            initialValue = false,
        )

    private var interactionState: InteractionState? = null

    /**
     * Returns `true` if the given [MotionEvent] and the rest of events in this gesture should be
     * passed to this interactor's [onTouchEvent] method.
     *
     * Note: the caller should continue to pass [MotionEvent] instances into this method, even if it
     * returns `false` as the gesture may be intercepted mid-stream.
     */
    fun shouldIntercept(event: MotionEvent): Boolean {
        if (isAnyShadeExpanded.value) {
            // If any shade is expanded, we assume that touch handling outside the shades is handled
            // by the scrim that appears behind the shades. No need to intercept anything here.
            return false
        }

        return when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                // Record where the pointer was placed and which pointer it was.
                interactionState =
                    InteractionState(
                        initialX = event.x,
                        initialY = event.y,
                        currentY = event.y,
                        pointerId = event.getPointerId(0),
                        isDraggingHorizontally = false,
                        isDraggingVertically = false,
                    )

                false
            }
            MotionEvent.ACTION_MOVE -> {
                interactionState?.let {
                    val pointerIndex = event.findPointerIndex(it.pointerId)
                    val currentX = event.getX(pointerIndex)
                    val currentY = event.getY(pointerIndex)
                    if (!it.isDraggingHorizontally && !it.isDraggingVertically) {
                        val xDistanceTravelled = abs(currentX - it.initialX)
                        val yDistanceTravelled = abs(currentY - it.initialY)
                        val touchSlop = ViewConfiguration.get(applicationContext).scaledTouchSlop
                        interactionState =
                            when {
                                yDistanceTravelled > touchSlop ->
                                    it.copy(isDraggingVertically = true)
                                xDistanceTravelled > touchSlop ->
                                    it.copy(isDraggingHorizontally = true)
                                else -> interactionState
                            }
                    }
                }

                // We want to intercept the rest of the gesture if we're dragging.
                interactionState.isDraggingVertically()
            }
            MotionEvent.ACTION_UP,
            MotionEvent.ACTION_CANCEL ->
                // Make sure that we intercept the up or cancel if we're dragging, to handle drag
                // end and cancel.
                interactionState.isDraggingVertically()
            else -> false
        }
    }

    /**
     * Notifies that a [MotionEvent] in a series of events of a gesture that was intercepted due to
     * the result of [shouldIntercept] has been received.
     *
     * @param event The [MotionEvent] to handle.
     * @param viewWidthPx The width of the view, in pixels.
     * @return `true` if the event was consumed, `false` otherwise.
     */
    fun onTouchEvent(event: MotionEvent, viewWidthPx: Int): Boolean {
        return when (event.actionMasked) {
            MotionEvent.ACTION_MOVE -> {
                interactionState?.let {
                    if (it.isDraggingVertically) {
                        val pointerIndex = event.findPointerIndex(it.pointerId)
                        val previousY = it.currentY
                        val currentY = event.getY(pointerIndex)
                        interactionState =
                            it.copy(
                                currentY = currentY,
                            )

                        val yDragAmountPx = currentY - previousY
                        if (yDragAmountPx != 0f) {
                            interactor.sendProxiedInput(
                                ProxiedInputModel.OnDrag(
                                    xFraction = event.x / viewWidthPx,
                                    yDragAmountPx = yDragAmountPx,
                                )
                            )
                        }
                    }
                }

                true
            }
            MotionEvent.ACTION_UP -> {
                if (interactionState.isDraggingVertically()) {
                    // We finished dragging. Record that so the multi-shade framework can issue a
                    // fling, if the velocity reached in the drag was high enough, for example.
                    interactor.sendProxiedInput(ProxiedInputModel.OnDragEnd)
                }

                interactionState = null
                true
            }
            MotionEvent.ACTION_CANCEL -> {
                if (interactionState.isDraggingVertically()) {
                    // Our drag gesture was canceled by the system. This happens primarily in one of
                    // two occasions: (a) the parent view has decided to intercept the gesture
                    // itself and/or route it to a different child view or (b) the pointer has
                    // traveled beyond the bounds of our view and/or the touch display. Either way,
                    // we pass the cancellation event to the multi-shade framework to record it.
                    // Doing that allows the multi-shade framework to know that the gesture ended to
                    // allow new gestures to be accepted.
                    interactor.sendProxiedInput(ProxiedInputModel.OnDragCancel)
                }

                interactionState = null
                true
            }
            else -> false
        }
    }

    private data class InteractionState(
        val initialX: Float,
        val initialY: Float,
        val currentY: Float,
        val pointerId: Int,
        val isDraggingHorizontally: Boolean,
        val isDraggingVertically: Boolean,
    )

    private fun InteractionState?.isDraggingVertically(): Boolean {
        return this?.isDraggingVertically == true
    }
}
+27 −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.systemui.multishade.shared.math

import androidx.annotation.VisibleForTesting
import kotlin.math.abs

/** Returns `true` if this [Float] is within [epsilon] of `0`. */
fun Float.isZero(epsilon: Float = EPSILON): Boolean {
    return abs(this) < epsilon
}

@VisibleForTesting private const val EPSILON = 0.0001f
+1 −5
Original line number Diff line number Diff line
@@ -26,7 +26,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@@ -87,10 +86,7 @@ class MultiShadeViewModel(
                when (shadeConfig) {
                    // In the dual shade configuration, the scrim is enabled when the expansion is
                    // greater than zero on any one of the shades.
                    is ShadeConfig.DualShadeConfig ->
                        interactor.maxShadeExpansion
                            .map { expansion -> expansion > 0 }
                            .distinctUntilChanged()
                    is ShadeConfig.DualShadeConfig -> interactor.isAnyShadeExpanded
                    // No scrim in the single shade configuration.
                    is ShadeConfig.SingleShadeConfig -> flowOf(false)
                }
Loading