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

Commit a9263cdb authored by Jeremy Sim's avatar Jeremy Sim Committed by Android (Google) Code Review
Browse files

Merge "Refine haptics and snap zones for magnetic split divider" into main

parents 8e5c79a0 1e4861ab
Loading
Loading
Loading
Loading
+17 −15
Original line number Diff line number Diff line
@@ -55,18 +55,20 @@ import androidx.annotation.Nullable;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.protolog.ProtoLog;
import com.android.mechanics.spec.BreakpointKey;
import com.android.mechanics.spec.InputDirection;
import com.android.mechanics.view.DistanceGestureContext;
import com.android.mechanics.view.ViewMotionValue;
import com.android.wm.shell.Flags;
import com.android.wm.shell.R;
import com.android.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget;
import com.android.wm.shell.protolog.ShellProtoLogGroup;
import com.android.wm.shell.shared.animation.Interpolators;
import com.android.wm.shell.shared.desktopmode.DesktopState;

import com.google.android.msdl.data.model.MSDLToken;

import java.util.Objects;

/**
 * Divider for multi window splits.
 */
@@ -99,7 +101,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
    // Calculation classes for "magnetic snap" user-controlled movement
    private DistanceGestureContext mDistanceGestureContext;
    private ViewMotionValue mViewMotionValue;
    private BreakpointKey mCurrentHapticBreakpoint;
    @Nullable private Integer mLastHoveredOverSnapPosition;

    /**
     * This is not the visible bounds you see on screen, but the actual behind-the-scenes window
@@ -193,7 +195,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
                return true;
            }

            DividerSnapAlgorithm.SnapTarget nextTarget = null;
            SnapTarget nextTarget = null;
            DividerSnapAlgorithm snapAlgorithm = mSplitLayout.mDividerSnapAlgorithm;
            if (action == R.id.action_move_tl_full) {
                nextTarget = snapAlgorithm.getDismissEndTarget();
@@ -379,20 +381,20 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
                                mDistanceGestureContext,
                                mSplitLayout.mDividerSnapAlgorithm.getMotionSpec(),
                                "dividerView::pos" /* label */);
                        mLastHoveredOverSnapPosition = mSplitLayout.calculateCurrentSnapPosition();
                        mViewMotionValue.addUpdateCallback(viewMotionValue -> {
                            // Whenever MotionValue updates (from user moving the divider):
                            // - Place divider in its new position
                            placeDivider((int) viewMotionValue.getOutput());
                            // Each MotionValue "segment" has two "breakpoints", one on each end.
                            // We can uniquely identify each segment by just one of its breakpoints,
                            // so we arbitrarily listen for changes to the "min-side" breakpoint
                            // to determine when the user has moved the onto a new segment (i.e.
                            // moved the divider from the "free-drag" segment to the "snapped"
                            // segment, or vice versa). We play a haptic when this happens.
                            if (!viewMotionValue.getSegmentKey().getMinBreakpoint()
                                    .equals(mCurrentHapticBreakpoint)) {
                            // - Play a haptic if entering a magnetic zone
                            Integer currentlyHoveredOverSnapZone = viewMotionValue.get(
                                    MagneticDividerUtils.getSNAP_POSITION_KEY());
                            if (currentlyHoveredOverSnapZone != null && !Objects.equals(
                                    currentlyHoveredOverSnapZone, mLastHoveredOverSnapPosition)) {
                                playHapticClick();
                            }
                            mCurrentHapticBreakpoint =
                                    viewMotionValue.getSegmentKey().getMinBreakpoint();
                            // - Update the last-hovered-over snap zone
                            mLastHoveredOverSnapPosition = currentlyHoveredOverSnapZone;
                        });
                    }
                }
@@ -423,7 +425,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
                        ? mVelocityTracker.getXVelocity()
                        : mVelocityTracker.getYVelocity();
                final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos;
                final DividerSnapAlgorithm.SnapTarget snapTarget =
                final SnapTarget snapTarget =
                        mSplitLayout.findSnapTarget(position, velocity, false /* hardDismiss */);
                mSplitLayout.snapToTarget(position, snapTarget);
                mMoving = false;
@@ -467,7 +469,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
        }
        mDistanceGestureContext = null;
        mViewMotionValue = null;
        mCurrentHapticBreakpoint = null;
        mLastHoveredOverSnapPosition = null;
    }

    private void setTouching() {
+121 −43
Original line number Diff line number Diff line
@@ -17,16 +17,14 @@
package com.android.wm.shell.common.split

import android.content.res.Resources
import com.android.mechanics.spec.Breakpoint
import com.android.mechanics.spec.Breakpoint.Companion.maxLimit
import com.android.mechanics.spec.Breakpoint.Companion.minLimit
import com.android.mechanics.spec.BreakpointKey
import com.android.mechanics.spec.DirectionalMotionSpec
import com.android.mechanics.spec.Guarantee
import androidx.compose.ui.unit.dp
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.MotionSpec
import com.android.mechanics.spring.SpringParameters.Companion.Snap
import com.android.wm.shell.common.pip.PipUtils
import com.android.mechanics.spec.SemanticKey
import com.android.mechanics.spec.builder.spatialDirectionalMotionSpec
import com.android.mechanics.spec.with
import com.android.mechanics.spring.SpringParameters
import com.android.mechanics.view.standardViewMotionBuilderContext
import com.android.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget

/**
@@ -36,48 +34,128 @@ import com.android.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget
class MagneticDividerUtils {
    companion object {
        /**
         * The size of the "snap zone" (a zone around the snap point that attracts the divider.)
         * In dp.
         * When the user moves the divider towards or away from a snap point, a magnetic spring
         * movement and haptic will take place at this distance.
         */
        private const val MAGNETIC_ZONE_SIZE = 30f
        private val DEFAULT_MAGNETIC_ATTACH_THRESHOLD = 56.dp
        /** The minimum spacing between snap zones, to prevent overlap on smaller displays. */
        private val MINIMUM_SPACE_BETWEEN_SNAP_ZONES = 4.dp
        /** The stiffness of the magnetic snap effect. */
        private const val ATTACH_STIFFNESS = 850f
        /** The damping ratio of the magnetic snap effect. */
        private const val ATTACH_DAMPING_RATIO = 0.95f
        /** The spring used for the magnetic snap effect. */
        private val MagneticSpring = SpringParameters(
            stiffness = ATTACH_STIFFNESS,
            dampingRatio = ATTACH_DAMPING_RATIO
        )
        /**
         * When inside the magnetic snap zone, the divider's movement is reduced by this amount.
         */
        private const val ATTACH_DETACH_SCALE = 0.5f
        /**
         * A key that can be passed into a MotionValue to retrieve the SnapPosition associated
         * with the current drag.
         */
        @JvmStatic val SNAP_POSITION_KEY = SemanticKey<Int?>()

        /**
         * Create a MotionSpec that has "snap zones" for each of the SnapTargets provided.
         */
        @JvmStatic
        fun generateMotionSpec(targets: List<SnapTarget>, res: Resources): MotionSpec {
            val breakpoints: MutableList<Breakpoint> = ArrayList()
            val mappings: MutableList<Mapping> = ArrayList()
            // Create a new MotionSpec object and return it. A MotionSpec is composed of at least
            // one DirectionalMotionSpec, so below we will create a DirectionalMotionSpec and pass
            // it as the single argument to the MotionSpec constructor.
            return MotionSpec(
                // To do that, we first create a "context" object, which gives access to a
                // DirectionalMotionSpec builder and some convenience functions, like for converting
                // dp > px.
                with(standardViewMotionBuilderContext(res.displayMetrics.density)) {
                    // Inside of this "with" block, we can write code freely -- the final evaluated
                    // value of this block will be the value of the final expression we write. See
                    // Kotlin docs for more details.

                    // First, get the position of the left-most (or top-most) dismiss point.
                    val topLeftDismissTarget = targets.first()
                    val topLeftDismissPosition = topLeftDismissTarget.position.toFloat()
                    // Create a DirectionalMotionSpec using a pre-set builder method. We choose the
                    // "spatialDirectionalMotionSpec", which is meant for "spatial" movement (as
                    // opposed to "effects" movement).
                    spatialDirectionalMotionSpec(
                        initialMapping = Mapping.Fixed(topLeftDismissPosition),
                        semantics = listOf(SNAP_POSITION_KEY with null),
                        defaultSpring = MagneticSpring
                    ) {
                        // NOTE: This block is a trailing lambda passed in as the "init" parameter.

                        // A DirectionalMotionSpec is essentially a number line from -infinity to
                        // infinity, with instructions on how to interpret the value at each point.
                        // We create each individual segment below to fill out our number line.

            // Add the "min" breakpoint, the "max" breakpoint, and 2 breakpoints for each snap point
            // (for a total of n breakpoints).
            // Add n-1 mappings that go between the breakpoints
            breakpoints.add(minLimit)
            mappings.add(Mapping.Identity)
            for (i in targets.indices) {
                val t: SnapTarget = targets[i]
                val halfZoneSizePx = PipUtils.dpToPx(MAGNETIC_ZONE_SIZE, res.displayMetrics) / 2f
                val startOfZone = t.position - halfZoneSizePx
                val endOfZone = t.position + halfZoneSizePx
                        // Start by finding the smallest span between two targets and setting an
                        // appropriate magnetic snap threshold.
                        val smallestSpanBetweenTargets = targets.zipWithNext { t1, t2 ->
                            t2.position.toFloat() - t1.position.toFloat()
                        }.reduce { minSoFar, currentDiff ->
                            kotlin.math.min(minSoFar, currentDiff)
                        }
                        val availableSpaceForSnapZone = (smallestSpanBetweenTargets -
                                MINIMUM_SPACE_BETWEEN_SNAP_ZONES.toPx()) / 2f
                        val snapThreshold = kotlin.math.min(
                            DEFAULT_MAGNETIC_ATTACH_THRESHOLD.toPx(), availableSpaceForSnapZone)

                val startOfMagneticZone = Breakpoint(
                    BreakpointKey("snapzone$i::start", startOfZone),
                    startOfZone,
                    Snap,
                    Guarantee.None
                        // Our first breakpoint is located at topLeftDismissPosition. On the right
                        // side of this breakpoint, we'll use the "identity" instruction, which
                        // means values won't be converted.
                        identity(
                            breakpoint = topLeftDismissPosition,
                            semantics = listOf(SNAP_POSITION_KEY with null)
                        )
                val endOfMagneticZone = Breakpoint(
                    BreakpointKey("snapzone$i::end", endOfZone),
                    endOfZone,
                    Snap,
                    Guarantee.None

                        // We continue creating alternating zones of "identity" and
                        // "fractionalInputFromCurrent", which will give us the behavior we're
                        // looking for, where the divider can be dragged along normally in some
                        // areas (the identity zones) and resists the user's movement in some areas
                        // (the fractionalInputFromCurrent zones). The targets have to be created in
                        // ascending order.

                        // Iterating from the second target to the second-last target (EXCLUDING the
                        // first and last):
                        for (i in (1 until targets.size - 1)) {
                            val target = targets[i]
                            val targetPosition = target.position.toFloat()

                            // Create a fractionalInputFromCurrent zone.
                            fractionalInputFromCurrent(
                                breakpoint = targetPosition - snapThreshold,
                                // With every magnetic segment, we also pass in the associated
                                // snapPosition as a "semantic association", so we can later query
                                // the MotionValue for it.
                                semantics = listOf(SNAP_POSITION_KEY with target.snapPosition),
                                delta = snapThreshold * (1 - ATTACH_DETACH_SCALE),
                                fraction = ATTACH_DETACH_SCALE,
                            )

                breakpoints.add(startOfMagneticZone)
                mappings.add(Mapping.Fixed(t.position.toFloat()))
                breakpoints.add(endOfMagneticZone)
                mappings.add(Mapping.Identity)
                            // Create another identity zone.
                            identity(
                                breakpoint = targetPosition + snapThreshold,
                                semantics = listOf(SNAP_POSITION_KEY with null)
                            )
                        }
            breakpoints.add(maxLimit)

            return MotionSpec(DirectionalMotionSpec(breakpoints, mappings))
                        // Finally, create one last fixedValue zone, from the bottom/right
                        // dismiss point to infinity.
                        val bottomRightDismissTarget = targets.last()
                        val bottomRightDismissPosition = bottomRightDismissTarget.position.toFloat()
                        fixedValue(
                            breakpoint = bottomRightDismissPosition,
                            value = bottomRightDismissPosition,
                            semantics = listOf(SNAP_POSITION_KEY with null)
                        )
                    }
                }
            )
        }
    }
}
+20 −42
Original line number Diff line number Diff line
@@ -16,75 +16,53 @@

package com.android.wm.shell.common.split;

import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_10_90;
import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_50_50;
import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_90_10;
import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_END_AND_DISMISS;
import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_START_AND_DISMISS;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.anyFloat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;

import android.content.res.Resources;
import android.util.DisplayMetrics;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

import com.android.mechanics.spec.MotionSpec;
import com.android.wm.shell.common.pip.PipUtils;
import com.android.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoSession;

import java.util.List;

@RunWith(AndroidJUnit4.class)
public class MagneticDividerUtilsTests {
    private MockitoSession mMockitoSession;

    private final List<SnapTarget> mTargets = List.of(
            new SnapTarget(0, SNAP_TO_START_AND_DISMISS),
            new SnapTarget(100, SNAP_TO_2_10_90),
            new SnapTarget(500, SNAP_TO_2_50_50),
            new SnapTarget(900, SNAP_TO_2_90_10),
            new SnapTarget(1000, SNAP_TO_END_AND_DISMISS)
    );

    @Mock Resources mResources;
    @Mock DisplayMetrics mDisplayMetrics;
    Resources mResources;

    @Before
    public void setup() {
        mMockitoSession = mockitoSession()
                .initMocks(this)
                .mockStatic(PipUtils.class)
                .startMocking();
    }

    @After
    public void tearDown() {
        mMockitoSession.finishMocking();
        mResources = InstrumentationRegistry.getInstrumentation().getContext().getResources();
    }

    @Test
    public void generateMotionSpec_producesCorrectNumberOfBreakpointsAndMappings() {
        when(mResources.getDisplayMetrics()).thenReturn(mDisplayMetrics);
        when(PipUtils.dpToPx(anyFloat(), eq(mDisplayMetrics))).thenReturn(30);
    public void generateMotionSpec_worksOnThisDeviceWithoutCrashing() {
        int longEdge = Math.max(
                mResources.getDisplayMetrics().heightPixels,
                mResources.getDisplayMetrics().widthPixels
        );

        MotionSpec motionSpec = MagneticDividerUtils.generateMotionSpec(mTargets, mResources);
        List<SnapTarget> mTargets = List.of(
                new SnapTarget(0, SNAP_TO_START_AND_DISMISS),
                new SnapTarget(longEdge / 10, SNAP_TO_2_10_90),
                new SnapTarget(longEdge / 2, SNAP_TO_2_50_50),
                new SnapTarget(longEdge - (longEdge / 10), SNAP_TO_2_90_10),
                new SnapTarget(longEdge, SNAP_TO_END_AND_DISMISS)
        );

        // Expect 12 breakpoints: the "min" breakpoint, the "max" breakpoint, and 2 breakpoints for
        // each of the 5 snap points.
        assertEquals(12, motionSpec.getMaxDirection().getBreakpoints().size());
        // Expect 11 mappings, that go between the breakpoints.
        assertEquals(11, motionSpec.getMaxDirection().getMappings().size());
        // Check that a MotionSpec gets created without crashing. A crash can happen if the dp
        // values set MagneticDividerUtils are large enough that the snap zones overlap on smaller
        // screens.
        MotionSpec motionSpec = MagneticDividerUtils.generateMotionSpec(mTargets, mResources);
    }
}