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

Commit 1e4861ab authored by Jeremy Sim's avatar Jeremy Sim
Browse files

Refine haptics and snap zones for magnetic split divider

- Refactors MagneticDividerUtils to utilize more advanced Kotlin and match the MotionValue API better.
- Haptic now only plays on attach, not detach.
- No longer plays a snap or haptic at the screen edge.
- Widens the snap zone to 56dp on each side.
- Softens the magnetic spring to 850/0.95.

Bug: 383631946
Flag: com.android.wm.shell.enable_magnetic_split_divider
Test: MagneticDividerUtilsTests
Change-Id: I0f34f5101b6a875b0c5f051fb121c029685e9c71
parent c0b13b32
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);
    }
}