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

Commit b50db518 authored by Jeremy Sim's avatar Jeremy Sim
Browse files

Splitscreen: Use MotionValue to implement magnetic snap zones

First pass at creating magnetic snap behavior for the divider, behind flag.

Bug: 383631946
Flag: com.android.wm.shell.enable_magnetic_split_divider
Test: Divider now snaps while the user is dragging it.
Change-Id: I431aa8d3b052c90f3d1a753dd181a129f0b401ca
parent 09e87480
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ import android.graphics.Rect;

import androidx.annotation.Nullable;

import com.android.mechanics.spec.MotionSpec;
import com.android.wm.shell.Flags;
import com.android.wm.shell.shared.split.SplitScreenConstants.PersistentSnapPosition;

@@ -108,6 +109,8 @@ public class DividerSnapAlgorithm {
    private final SnapTarget mDismissEndTarget;
    private final SnapTarget mMiddleTarget;

    /** A spec used for "magnetic snap" user-controlled movement. */
    private final MotionSpec mMotionSpec;

    public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
            boolean isLeftRightSplit, Rect insets, Rect pinnedTaskbarInsets, int dockSide) {
@@ -157,6 +160,7 @@ public class DividerSnapAlgorithm {
        mDismissEndTarget = mTargets.get(mTargets.size() - 1);
        mMiddleTarget = mTargets.get(mTargets.size() / 2);
        mMiddleTarget.isMiddleTarget = true;
        mMotionSpec = MagneticDividerUtils.generateMotionSpec(mTargets, res);
    }

    /**
@@ -445,6 +449,10 @@ public class DividerSnapAlgorithm {
        return snap(currentPosition, /* hardDismiss */ true).snapPosition;
    }

    public MotionSpec getMotionSpec() {
        return mMotionSpec;
    }

    /**
     * An object, calculated at boot time, representing a legal position for the split screen
     * divider (i.e. the divider can be dragged to this spot).
+63 −2
Original line number Diff line number Diff line
@@ -55,6 +55,10 @@ import androidx.annotation.Nullable;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.protolog.ProtoLog;
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.protolog.ShellProtoLogGroup;
import com.android.wm.shell.shared.animation.Interpolators;
@@ -89,6 +93,10 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
    private int mHandleRegionWidth;
    private int mHandleRegionHeight;

    // Calculation classes for "magnetic snap" user-controlled movement
    private DistanceGestureContext mDistanceGestureContext;
    private ViewMotionValue mViewMotionValue;

    /**
     * This is not the visible bounds you see on screen, but the actual behind-the-scenes window
     * bounds, which is larger.
@@ -353,14 +361,33 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
                break;
            case MotionEvent.ACTION_MOVE:
                mVelocityTracker.addMovement(event);
                if (!mMoving && Math.abs(touchPos - mStartPos) > mTouchSlop) {
                int displacement = touchPos - mStartPos;
                if (!mMoving && Math.abs(displacement) > mTouchSlop) {
                    mStartPos = touchPos;
                    mMoving = true;
                    if (Flags.enableMagneticSplitDivider()) {
                        // Move gesture is confirmed, create framework for magnetic snap
                        InputDirection direction =
                                displacement > 0 ? InputDirection.Max : InputDirection.Min;
                        mDistanceGestureContext = DistanceGestureContext.create(mContext, mStartPos,
                                direction);
                        mViewMotionValue = new ViewMotionValue(mStartPos,
                                mDistanceGestureContext,
                                mSplitLayout.mDividerSnapAlgorithm.getMotionSpec(),
                                "dividerView::pos" /* label */);
                        mViewMotionValue.addUpdateCallback(viewMotionValue -> {
                            placeDivider((int) viewMotionValue.getOutput());
                        });
                    }
                }
                if (mMoving) {
                    final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos;
                    mLastDraggingPosition = position;
                    mSplitLayout.updateDividerBounds(position, true /* shouldUseParallaxEffect */);
                    if (Flags.enableMagneticSplitDivider()) {
                        updateMagneticSnapCalculation(position);
                    } else {
                        placeDivider(position);
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
@@ -368,6 +395,9 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
                releaseTouching();
                if (!mMoving) {
                    mSplitLayout.onDraggingCancelled();
                    if (Flags.enableMagneticSplitDivider()) {
                        cleanUpMagneticSnapFramework();
                    }
                    break;
                }

@@ -381,12 +411,43 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
                        mSplitLayout.findSnapTarget(position, velocity, false /* hardDismiss */);
                mSplitLayout.snapToTarget(position, snapTarget);
                mMoving = false;
                if (Flags.enableMagneticSplitDivider()) {
                    cleanUpMagneticSnapFramework();
                }
                break;
        }

        return true;
    }

    /** Updates the position of the divider. */
    private void placeDivider(int position) {
        mSplitLayout.updateDividerBounds(position, true /* shouldUseParallaxEffect */);
    }

    /**
     * Sends a position update to the magnetic snap framework, allowing a calculation to occur. The
     * position of the divider will be updated.
     * @param position The current position of the user's finger.
     */
    private void updateMagneticSnapCalculation(int position) {
        if (mDistanceGestureContext != null) {
            mDistanceGestureContext.setDragOffset(position);
        }
        if (mViewMotionValue != null) {
            mViewMotionValue.setInput(position);
        }
    }

    /** Cleans up the magnetic snap framework after the drag gesture completes. */
    private void cleanUpMagneticSnapFramework() {
        if (mViewMotionValue != null) {
            mViewMotionValue.dispose();
        }
        mDistanceGestureContext = null;
        mViewMotionValue = null;
    }

    private void setTouching() {
        setSlippery(false);
        mHandle.setTouching(true, true);
+83 −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.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 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.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget

/**
 * Utility class used to create a framework that enables the divider to snap magnetically to snap
 * points while the user is dragging it.
 */
class MagneticDividerUtils {
    companion object {
        /**
         * The size of the "snap zone" (a zone around the snap point that attracts the divider.)
         * In dp.
         */
        private const val MAGNETIC_ZONE_SIZE = 30f

        @JvmStatic
        fun generateMotionSpec(targets: List<SnapTarget>, res: Resources): MotionSpec {
            val breakpoints: MutableList<Breakpoint> = ArrayList()
            val mappings: MutableList<Mapping> = ArrayList()

            // 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

                val startOfMagneticZone = Breakpoint(
                    BreakpointKey("snapzone$i::start", startOfZone),
                    startOfZone,
                    Snap,
                    Guarantee.None
                )
                val endOfMagneticZone = Breakpoint(
                    BreakpointKey("snapzone$i::end", endOfZone),
                    endOfZone,
                    Snap,
                    Guarantee.None
                )

                breakpoints.add(startOfMagneticZone)
                mappings.add(Mapping.Fixed(t.position.toFloat()))
                breakpoints.add(endOfMagneticZone)
                mappings.add(Mapping.Identity)
            }
            breakpoints.add(maxLimit)

            return MotionSpec(DirectionalMotionSpec(breakpoints, mappings))
        }
    }
}
 No newline at end of file
+90 −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.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 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;

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

    @After
    public void tearDown() {
        mMockitoSession.finishMocking();
    }

    @Test
    public void generateMotionSpec_producesCorrectNumberOfBreakpointsAndMappings() {
        when(mResources.getDisplayMetrics()).thenReturn(mDisplayMetrics);
        when(PipUtils.dpToPx(anyFloat(), eq(mDisplayMetrics))).thenReturn(30);

        MotionSpec motionSpec = MagneticDividerUtils.generateMotionSpec(mTargets, mResources);

        // 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());
    }
}