1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.wm.shell.common.split; 18 19 import static android.view.WindowManager.DOCKED_LEFT; 20 import static android.view.WindowManager.DOCKED_RIGHT; 21 22 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_10_90; 23 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_33_66; 24 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_50_50; 25 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_66_33; 26 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_90_10; 27 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_END_AND_DISMISS; 28 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_MINIMIZE; 29 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_NONE; 30 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_START_AND_DISMISS; 31 import static com.android.wm.shell.shared.split.SplitScreenConstants.SnapPosition; 32 33 import android.content.res.Resources; 34 import android.graphics.Rect; 35 36 import androidx.annotation.Nullable; 37 38 import com.android.wm.shell.Flags; 39 import com.android.wm.shell.shared.split.SplitScreenConstants.PersistentSnapPosition; 40 41 import java.util.ArrayList; 42 import java.util.stream.IntStream; 43 44 /** 45 * Calculates the snap targets and the snap position given a position and a velocity. All positions 46 * here are to be interpreted as the left/top edge of the divider rectangle. 47 * 48 * @hide 49 */ 50 public class DividerSnapAlgorithm { 51 52 private static final int MIN_FLING_VELOCITY_DP_PER_SECOND = 400; 53 private static final int MIN_DISMISS_VELOCITY_DP_PER_SECOND = 600; 54 55 /** 56 * 3 snap targets: left/top has 16:9 ratio (for videos), 1:1, and right/bottom has 16:9 ratio 57 */ 58 private static final int SNAP_MODE_16_9 = 0; 59 60 /** 61 * 3 snap targets: fixed ratio, 1:1, (1 - fixed ratio) 62 */ 63 private static final int SNAP_FIXED_RATIO = 1; 64 65 /** 66 * 1 snap target: 1:1 67 */ 68 private static final int SNAP_ONLY_1_1 = 2; 69 70 /** 71 * 1 snap target: minimized height, (1 - minimized height) 72 */ 73 private static final int SNAP_MODE_MINIMIZED = 3; 74 75 /** 76 * A mode where apps can be "flexibly offscreen" on smaller displays. 77 */ 78 private static final int SNAP_FLEXIBLE_SPLIT = 4; 79 80 private final float mMinFlingVelocityPxPerSecond; 81 private final float mMinDismissVelocityPxPerSecond; 82 private final int mDisplayWidth; 83 private final int mDisplayHeight; 84 private final int mDividerSize; 85 private final ArrayList<SnapTarget> mTargets = new ArrayList<>(); 86 private final Rect mInsets = new Rect(); 87 private final Rect mPinnedTaskbarInsets = new Rect(); 88 private final int mSnapMode; 89 private final boolean mFreeSnapMode; 90 private final int mMinimalSizeResizableTask; 91 private final int mTaskHeightInMinimizedMode; 92 private final float mFixedRatio; 93 /** Allows split ratios to calculated dynamically instead of using {@link #mFixedRatio}. */ 94 private final boolean mCalculateRatiosBasedOnAvailableSpace; 95 /** Allows split ratios that go offscreen (a.k.a. "flexible split") */ 96 private final boolean mAllowOffscreenRatios; 97 private final boolean mIsLeftRightSplit; 98 /** In SNAP_MODE_MINIMIZED, the side of the screen on which an app will "dock" when minimized */ 99 private final int mDockSide; 100 101 /** The first target which is still splitting the screen */ 102 private final SnapTarget mFirstSplitTarget; 103 104 /** The last target which is still splitting the screen */ 105 private final SnapTarget mLastSplitTarget; 106 107 private final SnapTarget mDismissStartTarget; 108 private final SnapTarget mDismissEndTarget; 109 private final SnapTarget mMiddleTarget; 110 111 DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isLeftRightSplit, Rect insets, Rect pinnedTaskbarInsets, int dockSide)112 public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, 113 boolean isLeftRightSplit, Rect insets, Rect pinnedTaskbarInsets, int dockSide) { 114 this(res, displayWidth, displayHeight, dividerSize, isLeftRightSplit, insets, 115 pinnedTaskbarInsets, dockSide, false /* minimized */, true /* resizable */); 116 } 117 DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isLeftRightSplit, Rect insets, Rect pinnedTaskbarInsets, int dockSide, boolean isMinimizedMode, boolean isHomeResizable)118 public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, 119 boolean isLeftRightSplit, Rect insets, Rect pinnedTaskbarInsets, int dockSide, 120 boolean isMinimizedMode, boolean isHomeResizable) { 121 mMinFlingVelocityPxPerSecond = 122 MIN_FLING_VELOCITY_DP_PER_SECOND * res.getDisplayMetrics().density; 123 mMinDismissVelocityPxPerSecond = 124 MIN_DISMISS_VELOCITY_DP_PER_SECOND * res.getDisplayMetrics().density; 125 mDividerSize = dividerSize; 126 mDisplayWidth = displayWidth; 127 mDisplayHeight = displayHeight; 128 mIsLeftRightSplit = isLeftRightSplit; 129 mDockSide = dockSide; 130 mInsets.set(insets); 131 mPinnedTaskbarInsets.set(pinnedTaskbarInsets); 132 if (Flags.enableFlexibleTwoAppSplit()) { 133 mSnapMode = SNAP_FLEXIBLE_SPLIT; 134 } else { 135 // Set SNAP_MODE_MINIMIZED, SNAP_MODE_16_9, or SNAP_FIXED_RATIO depending on config 136 mSnapMode = isMinimizedMode 137 ? SNAP_MODE_MINIMIZED 138 : res.getInteger( 139 com.android.internal.R.integer.config_dockedStackDividerSnapMode); 140 } 141 mFreeSnapMode = res.getBoolean( 142 com.android.internal.R.bool.config_dockedStackDividerFreeSnapMode); 143 mFixedRatio = res.getFraction( 144 com.android.internal.R.fraction.docked_stack_divider_fixed_ratio, 1, 1); 145 mMinimalSizeResizableTask = res.getDimensionPixelSize( 146 com.android.internal.R.dimen.default_minimal_size_resizable_task); 147 mCalculateRatiosBasedOnAvailableSpace = res.getBoolean( 148 com.android.internal.R.bool.config_flexibleSplitRatios); 149 // If this is a small screen or a foldable, use offscreen ratios 150 mAllowOffscreenRatios = SplitScreenUtils.allowOffscreenRatios(res); 151 mTaskHeightInMinimizedMode = isHomeResizable ? res.getDimensionPixelSize( 152 com.android.internal.R.dimen.task_height_of_minimized_mode) : 0; 153 calculateTargets(); 154 mFirstSplitTarget = mTargets.get(1); 155 mLastSplitTarget = mTargets.get(mTargets.size() - 2); 156 mDismissStartTarget = mTargets.get(0); 157 mDismissEndTarget = mTargets.get(mTargets.size() - 1); 158 mMiddleTarget = mTargets.get(mTargets.size() / 2); 159 mMiddleTarget.isMiddleTarget = true; 160 } 161 162 /** 163 * @param position the top/left position of the divider 164 * @param velocity current dragging velocity 165 * @param hardToDismiss if set, make it a bit harder to get reach the dismiss targets 166 */ calculateSnapTarget(int position, float velocity, boolean hardToDismiss)167 public SnapTarget calculateSnapTarget(int position, float velocity, boolean hardToDismiss) { 168 if (position < mFirstSplitTarget.position && velocity < -mMinDismissVelocityPxPerSecond) { 169 return mDismissStartTarget; 170 } 171 if (position > mLastSplitTarget.position && velocity > mMinDismissVelocityPxPerSecond) { 172 return mDismissEndTarget; 173 } 174 if (Math.abs(velocity) < mMinFlingVelocityPxPerSecond) { 175 return snap(position, hardToDismiss); 176 } 177 if (velocity < 0) { 178 return mFirstSplitTarget; 179 } else { 180 return mLastSplitTarget; 181 } 182 } 183 calculateNonDismissingSnapTarget(int position)184 public SnapTarget calculateNonDismissingSnapTarget(int position) { 185 SnapTarget target = snap(position, false /* hardDismiss */); 186 if (target == mDismissStartTarget) { 187 return mFirstSplitTarget; 188 } else if (target == mDismissEndTarget) { 189 return mLastSplitTarget; 190 } else { 191 return target; 192 } 193 } 194 195 /** 196 * Gets the SnapTarget corresponding to the given {@link SnapPosition}, or null if no such 197 * SnapTarget exists. 198 */ 199 @Nullable findSnapTarget(@napPosition int snapPosition)200 public SnapTarget findSnapTarget(@SnapPosition int snapPosition) { 201 for (SnapTarget t : mTargets) { 202 if (t.snapPosition == snapPosition) { 203 return t; 204 } 205 } 206 207 return null; 208 } 209 calculateDismissingFraction(int position)210 public float calculateDismissingFraction(int position) { 211 if (position < mFirstSplitTarget.position) { 212 return 1f - (float) (position - getStartInset()) 213 / (mFirstSplitTarget.position - getStartInset()); 214 } else if (position > mLastSplitTarget.position) { 215 return (float) (position - mLastSplitTarget.position) 216 / (mDismissEndTarget.position - mLastSplitTarget.position - mDividerSize); 217 } 218 return 0f; 219 } 220 getFirstSplitTarget()221 public SnapTarget getFirstSplitTarget() { 222 return mFirstSplitTarget; 223 } 224 getLastSplitTarget()225 public SnapTarget getLastSplitTarget() { 226 return mLastSplitTarget; 227 } 228 getDismissStartTarget()229 public SnapTarget getDismissStartTarget() { 230 return mDismissStartTarget; 231 } 232 getDismissEndTarget()233 public SnapTarget getDismissEndTarget() { 234 return mDismissEndTarget; 235 } 236 getStartInset()237 private int getStartInset() { 238 if (mIsLeftRightSplit) { 239 return mInsets.left; 240 } else { 241 return mInsets.top; 242 } 243 } 244 getEndInset()245 private int getEndInset() { 246 if (mIsLeftRightSplit) { 247 return mInsets.right; 248 } else { 249 return mInsets.bottom; 250 } 251 } 252 shouldApplyFreeSnapMode(int position)253 private boolean shouldApplyFreeSnapMode(int position) { 254 if (!mFreeSnapMode) { 255 return false; 256 } 257 if (!isFirstSplitTargetAvailable() || !isLastSplitTargetAvailable()) { 258 return false; 259 } 260 return mFirstSplitTarget.position < position && position < mLastSplitTarget.position; 261 } 262 263 /** Returns if we are currently on a device/screen that supports split apps going offscreen. */ areOffscreenRatiosSupported()264 public boolean areOffscreenRatiosSupported() { 265 return mAllowOffscreenRatios; 266 } 267 snap(int position, boolean hardDismiss)268 private SnapTarget snap(int position, boolean hardDismiss) { 269 if (shouldApplyFreeSnapMode(position)) { 270 return new SnapTarget(position, SNAP_TO_NONE); 271 } 272 int minIndex = -1; 273 float minDistance = Float.MAX_VALUE; 274 int size = mTargets.size(); 275 for (int i = 0; i < size; i++) { 276 SnapTarget target = mTargets.get(i); 277 float distance = Math.abs(position - target.position); 278 if (hardDismiss) { 279 distance /= target.distanceMultiplier; 280 } 281 if (distance < minDistance) { 282 minIndex = i; 283 minDistance = distance; 284 } 285 } 286 return mTargets.get(minIndex); 287 } 288 calculateTargets()289 private void calculateTargets() { 290 mTargets.clear(); 291 int dividerMax = mIsLeftRightSplit 292 ? mDisplayWidth 293 : mDisplayHeight; 294 int startPos = -mDividerSize; 295 if (mDockSide == DOCKED_RIGHT) { 296 startPos += mInsets.left; 297 } 298 mTargets.add(new SnapTarget(startPos, SNAP_TO_START_AND_DISMISS, 0.35f)); 299 switch (mSnapMode) { 300 case SNAP_MODE_16_9: 301 addRatio16_9Targets(mIsLeftRightSplit, dividerMax); 302 break; 303 case SNAP_FIXED_RATIO: 304 addFixedDivisionTargets(mIsLeftRightSplit, dividerMax); 305 break; 306 case SNAP_ONLY_1_1: 307 addMiddleTarget(mIsLeftRightSplit); 308 break; 309 case SNAP_MODE_MINIMIZED: 310 addMinimizedTarget(mIsLeftRightSplit, mDockSide); 311 break; 312 case SNAP_FLEXIBLE_SPLIT: 313 addFlexSplitTargets(mIsLeftRightSplit, dividerMax); 314 break; 315 } 316 mTargets.add(new SnapTarget(dividerMax, SNAP_TO_END_AND_DISMISS, 0.35f)); 317 } 318 addNonDismissingTargets(boolean isLeftRightSplit, int topPosition, int bottomPosition, int dividerMax)319 private void addNonDismissingTargets(boolean isLeftRightSplit, int topPosition, 320 int bottomPosition, int dividerMax) { 321 @PersistentSnapPosition int firstTarget = 322 areOffscreenRatiosSupported() ? SNAP_TO_2_10_90 : SNAP_TO_2_33_66; 323 @PersistentSnapPosition int lastTarget = 324 areOffscreenRatiosSupported() ? SNAP_TO_2_90_10 : SNAP_TO_2_66_33; 325 maybeAddTarget(topPosition, topPosition - getStartInset(), firstTarget); 326 addMiddleTarget(isLeftRightSplit); 327 maybeAddTarget(bottomPosition, 328 dividerMax - getEndInset() - (bottomPosition + mDividerSize), lastTarget); 329 } 330 addFixedDivisionTargets(boolean isLeftRightSplit, int dividerMax)331 private void addFixedDivisionTargets(boolean isLeftRightSplit, int dividerMax) { 332 int start = isLeftRightSplit ? mInsets.left : mInsets.top; 333 int end = isLeftRightSplit 334 ? mDisplayWidth - mInsets.right 335 : mDisplayHeight - mInsets.bottom; 336 337 int size = (int) (mFixedRatio * (end - start)) - mDividerSize / 2; 338 if (mCalculateRatiosBasedOnAvailableSpace) { 339 size = Math.max(size, mMinimalSizeResizableTask); 340 } 341 342 int topPosition = start + size; 343 int bottomPosition = end - size - mDividerSize; 344 addNonDismissingTargets(isLeftRightSplit, topPosition, bottomPosition, dividerMax); 345 } 346 addFlexSplitTargets(boolean isLeftRightSplit, int dividerMax)347 private void addFlexSplitTargets(boolean isLeftRightSplit, int dividerMax) { 348 int start = 0; 349 int end = isLeftRightSplit ? mDisplayWidth : mDisplayHeight; 350 int pinnedTaskbarShiftStart = isLeftRightSplit 351 ? mPinnedTaskbarInsets.left : mPinnedTaskbarInsets.top; 352 int pinnedTaskbarShiftEnd = isLeftRightSplit 353 ? mPinnedTaskbarInsets.right : mPinnedTaskbarInsets.bottom; 354 355 float ratio = areOffscreenRatiosSupported() 356 ? SplitSpec.OFFSCREEN_ASYMMETRIC_RATIO 357 : SplitSpec.ONSCREEN_ONLY_ASYMMETRIC_RATIO; 358 359 // The intended size of the smaller app, in pixels 360 int size = (int) (ratio * (end - start)) - mDividerSize / 2; 361 362 // If there are insets that interfere with the smaller app (visually or blocking touch 363 // targets), make the smaller app bigger by that amount to compensate. This applies to 364 // pinned taskbar, 3-button nav (both create an opaque bar at bottom) and status bar (blocks 365 // touch targets at top). 366 int extraSpace = IntStream.of( 367 getStartInset(), getEndInset(), pinnedTaskbarShiftStart, pinnedTaskbarShiftEnd 368 ).max().getAsInt(); 369 370 int leftTopPosition = start + extraSpace + size; 371 int rightBottomPosition = end - extraSpace - size - mDividerSize; 372 addNonDismissingTargets(isLeftRightSplit, leftTopPosition, rightBottomPosition, dividerMax); 373 } 374 addRatio16_9Targets(boolean isLeftRightSplit, int dividerMax)375 private void addRatio16_9Targets(boolean isLeftRightSplit, int dividerMax) { 376 int start = isLeftRightSplit ? mInsets.left : mInsets.top; 377 int end = isLeftRightSplit 378 ? mDisplayWidth - mInsets.right 379 : mDisplayHeight - mInsets.bottom; 380 int startOther = isLeftRightSplit ? mInsets.top : mInsets.left; 381 int endOther = isLeftRightSplit 382 ? mDisplayHeight - mInsets.bottom 383 : mDisplayWidth - mInsets.right; 384 float size = 9.0f / 16.0f * (endOther - startOther); 385 int sizeInt = (int) Math.floor(size); 386 int topPosition = start + sizeInt; 387 int bottomPosition = end - sizeInt - mDividerSize; 388 addNonDismissingTargets(isLeftRightSplit, topPosition, bottomPosition, dividerMax); 389 } 390 391 /** 392 * Adds a target at {@param position} but only if the area with size of {@param smallerSize} 393 * meets the minimal size requirement. 394 */ maybeAddTarget(int position, int smallerSize, @SnapPosition int snapPosition)395 private void maybeAddTarget(int position, int smallerSize, @SnapPosition int snapPosition) { 396 if (smallerSize >= mMinimalSizeResizableTask || areOffscreenRatiosSupported()) { 397 mTargets.add(new SnapTarget(position, snapPosition)); 398 } 399 } 400 addMiddleTarget(boolean isLeftRightSplit)401 private void addMiddleTarget(boolean isLeftRightSplit) { 402 int position = DockedDividerUtils.calculateMiddlePosition(isLeftRightSplit, 403 mInsets, mDisplayWidth, mDisplayHeight, mDividerSize); 404 mTargets.add(new SnapTarget(position, SNAP_TO_2_50_50)); 405 } 406 addMinimizedTarget(boolean isLeftRightSplit, int dockedSide)407 private void addMinimizedTarget(boolean isLeftRightSplit, int dockedSide) { 408 // In portrait offset the position by the statusbar height, in landscape add the statusbar 409 // height as well to match portrait offset 410 int position = mTaskHeightInMinimizedMode + mInsets.top; 411 if (isLeftRightSplit) { 412 if (dockedSide == DOCKED_LEFT) { 413 position += mInsets.left; 414 } else if (dockedSide == DOCKED_RIGHT) { 415 position = mDisplayWidth - position - mInsets.right - mDividerSize; 416 } 417 } 418 mTargets.add(new SnapTarget(position, SNAP_TO_MINIMIZE)); 419 } 420 getMiddleTarget()421 public SnapTarget getMiddleTarget() { 422 return mMiddleTarget; 423 } 424 425 /** 426 * @return whether or not there are more than 1 split targets that do not include the two 427 * dismiss targets, used in deciding to display the middle target for accessibility 428 */ showMiddleSplitTargetForAccessibility()429 public boolean showMiddleSplitTargetForAccessibility() { 430 return (mTargets.size() - 2) > 1; 431 } 432 isFirstSplitTargetAvailable()433 public boolean isFirstSplitTargetAvailable() { 434 return mFirstSplitTarget != mMiddleTarget; 435 } 436 isLastSplitTargetAvailable()437 public boolean isLastSplitTargetAvailable() { 438 return mLastSplitTarget != mMiddleTarget; 439 } 440 441 /** 442 * Finds the {@link SnapPosition} nearest to the given position. 443 */ calculateNearestSnapPosition(int currentPosition)444 public int calculateNearestSnapPosition(int currentPosition) { 445 return snap(currentPosition, /* hardDismiss */ true).snapPosition; 446 } 447 448 /** 449 * An object, calculated at boot time, representing a legal position for the split screen 450 * divider (i.e. the divider can be dragged to this spot). 451 */ 452 public static class SnapTarget { 453 /** Position of this snap target. The right/bottom edge of the top/left task snaps here. */ 454 public final int position; 455 456 /** 457 * An int (enum) describing the placement of the divider in this snap target. 458 */ 459 public final @SnapPosition int snapPosition; 460 461 public boolean isMiddleTarget; 462 463 /** 464 * Multiplier used to calculate distance to snap position. The lower this value, the harder 465 * it's to snap on this target 466 */ 467 private final float distanceMultiplier; 468 SnapTarget(int position, @SnapPosition int snapPosition)469 public SnapTarget(int position, @SnapPosition int snapPosition) { 470 this(position, snapPosition, 1f); 471 } 472 SnapTarget(int position, @SnapPosition int snapPosition, float distanceMultiplier)473 public SnapTarget(int position, @SnapPosition int snapPosition, 474 float distanceMultiplier) { 475 this.position = position; 476 this.snapPosition = snapPosition; 477 this.distanceMultiplier = distanceMultiplier; 478 } 479 getPosition()480 public int getPosition() { 481 return position; 482 } 483 } 484 } 485