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

Commit a02f98ec authored by Jeremy Sim's avatar Jeremy Sim Committed by Vinit Nayak
Browse files

Implement tooltip for split divider drag

A tooltip will show if the divider is dragged more than halfway toward a new snap position.

Known issues:
- On certain displays/orientations, the tooltip surface can be truncated because it exceeds the bounds of its parent.
- Strings, measurements, and colors are not finalized.
- No animations or rounded edges.

Bug: 349828130
Flag: com.android.wm.shell.enable_magnetic_split_divider
Test: Manual
Change-Id: I5cd355810c20bf2a553e56165ef51ac5849341df
parent f1944fbb
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -24,6 +24,14 @@
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.android.wm.shell.common.split.DividerTooltip
            android:id="@+id/docked_divider_tooltip"
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_gravity="center"
            android:gravity="center"
            android:visibility="gone" />

        <com.android.wm.shell.common.split.DividerHandleView
            android:id="@+id/docked_divider_handle"
            android:layout_height="match_parent"
+15 −1
Original line number Diff line number Diff line
@@ -199,7 +199,7 @@ public class SplitScreenConstants {
    })
    public @interface SplitScreenState {}

    /** Converts a {@link SplitScreenState} to a human-readable string. */
    /** Converts a {@link SplitScreenState} to a human-readable string, for debug use. */
    public static String stateToString(@SplitScreenState int state) {
        return switch (state) {
            case NOT_IN_SPLIT -> "NOT_IN_SPLIT";
@@ -217,6 +217,20 @@ public class SplitScreenConstants {
        };
    }

    /** Converts a {@link SnapPosition} to a string, for UI use. */
    public static String snapPositionToUIString(@SnapPosition int snapPosition) {
        return switch (snapPosition) {
            case SNAP_TO_START_AND_DISMISS -> "\u2715";
            case SNAP_TO_END_AND_DISMISS -> "\u2715";
            case SNAP_TO_2_33_66 -> "30:70";
            case SNAP_TO_2_50_50 -> "50:50";
            case SNAP_TO_2_66_33 -> "70:30";
            case SNAP_TO_2_90_10 -> "90:10";
            case SNAP_TO_2_10_90 -> "10:90";
            default -> "Split";
        };
    }

    /**
     * Convenience method to convert between the IntDef's to avoid some errors
     * @return {@code -1} if splitScreenState does not have a valid/corresponding
+105 −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.Context
import android.graphics.Color
import android.graphics.Rect
import android.util.AttributeSet
import android.util.Log
import android.widget.FrameLayout
import androidx.appcompat.widget.AppCompatTextView
import com.android.wm.shell.R
import com.android.wm.shell.common.pip.PipUtils.dpToPx
import com.android.wm.shell.shared.TypefaceUtils
import com.android.wm.shell.shared.TypefaceUtils.Companion.setTypeface
import androidx.core.graphics.toColorInt

/**
 * A small tooltip bubble that educates the user about split screen breakpoints.
 */
class DividerTooltip(context: Context, attrs: AttributeSet?) :
    AppCompatTextView(context, attrs) {
    /** The length of the split divider handle, along its long edge, in px.  */
    private val mDividerHandleLengthPx =
        resources.getDimensionPixelSize(R.dimen.split_divider_handle_width)
    private var mIsLeftRightSplit = false

    init {
        alpha = 0f
        setTextColor(TOOLTIP_TEXT_COLOR)
        setBackgroundColor(TOOLTIP_BG_COLOR)
        setTypeface(this, TOOLTIP_FONT)
    }

    /**
     * Called in DividerView.setup() to determine orientation. Expected to always be called on
     * divider initialization.
     */
    fun setIsLeftRightSplit(isLeftRightSplit: Boolean) {
        mIsLeftRightSplit = isLeftRightSplit
    }

    /** Converts dp to px.  */
    private fun dpToPx(dpValue: Int): Int {
        return dpToPx(dpValue.toFloat(), mContext.resources.displayMetrics)
    }

    /**
     * Resizes the tooltip to fit the current text, and adjusts margins to put it in the correct
     * place on the screen.
     */
    override fun onTextChanged(
        text: CharSequence,
        start: Int,
        lengthBefore: Int,
        lengthAfter: Int) {
        if (layoutParams == null) {
            return
        }

        // Get a Rect representing the raw size of the current text string.
        val textBounds = Rect()
        paint.getTextBounds(text.toString(), 0, text.length, textBounds)

        val lp = layoutParams as FrameLayout.LayoutParams
        lp.height = textBounds.height() + (dpToPx(TOOLTIP_PADDING_DP) * 2)
        lp.width = textBounds.width() + (dpToPx(TOOLTIP_PADDING_DP) * 2)
        if (mIsLeftRightSplit) {
            lp.bottomMargin = ((mDividerHandleLengthPx / 2) + (lp.height / 2)
                    + dpToPx(TOOLTIP_DISTANCE_FROM_HANDLE_DP))
            lp.rightMargin = 0
        } else {
            lp.bottomMargin = 0
            lp.rightMargin = ((mDividerHandleLengthPx / 2) + (lp.width / 2)
                    + dpToPx(TOOLTIP_DISTANCE_FROM_HANDLE_DP))
        }

        layoutParams = lp
    }

    companion object {
        private val TOOLTIP_TEXT_COLOR = "#4A3F08".toColorInt()
        private val TOOLTIP_BG_COLOR = "#F5E29D".toColorInt()
        private val TOOLTIP_FONT = TypefaceUtils.FontFamily.GSF_BODY_MEDIUM_EMPHASIZED

        /** The padding between the tooltip's text and its outer border, on all four sides, in dp.  */
        private const val TOOLTIP_PADDING_DP = 12

        /** The distance between the tooltip's border and the (full-sized) divider handle, in dp.  */
        private const val TOOLTIP_DISTANCE_FROM_HANDLE_DP = 16
    }
}
+54 −3
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import static android.view.PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
import static android.view.WindowManager.LayoutParams.FLAG_SLIPPERY;

import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.CURSOR_HOVER_STATES_ENABLED;
import static com.android.wm.shell.shared.split.SplitScreenConstants.snapPositionToUIString;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
@@ -75,6 +76,7 @@ import java.util.Objects;
public class DividerView extends FrameLayout implements View.OnTouchListener {
    public static final long TOUCH_ANIMATION_DURATION = 150;
    public static final long TOUCH_RELEASE_ANIMATION_DURATION = 200;
    private static final boolean SHOW_DRAG_TOOLTIP = true;

    private final Paint mPaint = new Paint();
    private final Rect mBackgroundRect = new Rect();
@@ -85,6 +87,8 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
    private SurfaceControlViewHost mViewHost;
    private DividerHandleView mHandle;
    private DividerRoundedCorner mCorners;
    /** A tooltip view that appears to educate users about split screen breakpoints. */
    private DividerTooltip mTooltip;
    private int mTouchElevation;

    private VelocityTracker mVelocityTracker;
@@ -101,6 +105,11 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
    // Calculation classes for "magnetic snap" user-controlled movement
    private DistanceGestureContext mDistanceGestureContext;
    private ViewMotionValue mViewMotionValue;
    /**
     * The @SnapPosition where the user started dragging from. Used for mid-drag calculations, null
     * otherwise.
     */
    @Nullable private Integer mDragStartingSnapPosition;
    @Nullable private Integer mLastHoveredOverSnapPosition;

    /**
@@ -247,6 +256,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
        final boolean isLeftRightSplit = mSplitLayout.isLeftRightSplit();
        mHandle.setIsLeftRightSplit(isLeftRightSplit);
        mCorners.setIsLeftRightSplit(isLeftRightSplit);
        mTooltip.setIsLeftRightSplit(isLeftRightSplit);

        mHandleRegionWidth = getResources().getDimensionPixelSize(isLeftRightSplit
                ? R.dimen.split_divider_handle_region_height
@@ -297,6 +307,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
        mDividerBar = findViewById(R.id.divider_bar);
        mHandle = findViewById(R.id.docked_divider_handle);
        mCorners = findViewById(R.id.docked_divider_rounded_corner);
        mTooltip = findViewById(R.id.docked_divider_tooltip);
        mTouchElevation = getResources().getDimensionPixelSize(
                R.dimen.docked_stack_divider_lift_elevation);
        mDoubleTapDetector = new GestureDetector(getContext(), new DoubleTapListener());
@@ -382,19 +393,40 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
                                mSplitLayout.mDividerSnapAlgorithm.getMotionSpec(),
                                "dividerView::pos" /* label */);
                        mLastHoveredOverSnapPosition = mSplitLayout.calculateCurrentSnapPosition();
                        mDragStartingSnapPosition = mSplitLayout.calculateCurrentSnapPosition();
                        mViewMotionValue.addUpdateCallback(viewMotionValue -> {
                            int snappedPosition = (int) viewMotionValue.getOutput();
                            // Whenever MotionValue updates (from user moving the divider):
                            // - Place divider in its new position
                            placeDivider((int) viewMotionValue.getOutput());
                            placeDivider(snappedPosition);
                            // - Play a haptic if entering a magnetic zone
                            Integer currentlyHoveredOverSnapZone = viewMotionValue.get(
                                    MagneticDividerUtils.getSNAP_POSITION_KEY());
                            if (currentlyHoveredOverSnapZone != null && !Objects.equals(
                                    currentlyHoveredOverSnapZone, mLastHoveredOverSnapPosition)) {

                            boolean changedSnapPosition = !Objects.equals(
                                    currentlyHoveredOverSnapZone, mLastHoveredOverSnapPosition);
                            if (currentlyHoveredOverSnapZone != null && changedSnapPosition) {
                                playHapticClick();
                            }
                            // - Update the last-hovered-over snap zone
                            mLastHoveredOverSnapPosition = currentlyHoveredOverSnapZone;
                            // - Update tooltip state if needed
                            if (SHOW_DRAG_TOOLTIP && changedSnapPosition) {
                                // - Update internal state for closest snap position (i.e. where the
                                // user will end up if drag is released)
                                final float velocity = isLeftRightSplit
                                        ? mVelocityTracker.getXVelocity()
                                        : mVelocityTracker.getYVelocity();
                                int closestSnapPosition = mSplitLayout
                                        .findSnapTarget(snappedPosition,
                                                velocity, false /* hardDismiss */)
                                        .snapPosition;
                                if (closestSnapPosition != mDragStartingSnapPosition) {
                                    showTooltip(snapPositionToUIString(closestSnapPosition));
                                } else {
                                    hideTooltip();
                                }
                            }
                        });
                    }
                }
@@ -443,6 +475,23 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
        mSplitLayout.getHapticPlayer().playToken(MSDLToken.SWIPE_THRESHOLD_INDICATOR, null);
    }

    private void showTooltip(String tooltipText) {
        mTooltip.setText(tooltipText);
        if (mTooltip.getVisibility() == VISIBLE) {
            return;
        }
        mTooltip.setVisibility(VISIBLE);
        mTooltip.setAlpha(1f);
    }

    private void hideTooltip() {
        if (mTooltip.getVisibility() == GONE) {
            return;
        }
        mTooltip.setAlpha(0f);
        mTooltip.setVisibility(GONE);
    }

    /** Updates the position of the divider. */
    private void placeDivider(int position) {
        mSplitLayout.updateDividerBounds(position, true /* shouldUseParallaxEffect */);
@@ -470,6 +519,8 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
        mDistanceGestureContext = null;
        mViewMotionValue = null;
        mLastHoveredOverSnapPosition = null;
        mDragStartingSnapPosition = null;
        hideTooltip();
    }

    private void setTouching() {