1 /* 2 * Copyright (C) 2020 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.PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW; 20 import static android.view.PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW; 21 import static android.view.WindowManager.LayoutParams.FLAG_SLIPPERY; 22 23 import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.CURSOR_HOVER_STATES_ENABLED; 24 25 import android.animation.Animator; 26 import android.animation.AnimatorListenerAdapter; 27 import android.animation.ObjectAnimator; 28 import android.content.Context; 29 import android.graphics.Canvas; 30 import android.graphics.Paint; 31 import android.graphics.Rect; 32 import android.os.Bundle; 33 import android.provider.DeviceConfig; 34 import android.util.AttributeSet; 35 import android.util.Property; 36 import android.view.GestureDetector; 37 import android.view.InsetsController; 38 import android.view.InsetsSource; 39 import android.view.InsetsState; 40 import android.view.MotionEvent; 41 import android.view.PointerIcon; 42 import android.view.SurfaceControlViewHost; 43 import android.view.VelocityTracker; 44 import android.view.View; 45 import android.view.ViewConfiguration; 46 import android.view.ViewGroup; 47 import android.view.WindowInsets; 48 import android.view.WindowManager; 49 import android.view.accessibility.AccessibilityNodeInfo; 50 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 51 import android.widget.FrameLayout; 52 53 import androidx.annotation.NonNull; 54 import androidx.annotation.Nullable; 55 56 import com.android.internal.annotations.VisibleForTesting; 57 import com.android.internal.protolog.ProtoLog; 58 import com.android.wm.shell.R; 59 import com.android.wm.shell.protolog.ShellProtoLogGroup; 60 import com.android.wm.shell.shared.animation.Interpolators; 61 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus; 62 63 /** 64 * Divider for multi window splits. 65 */ 66 public class DividerView extends FrameLayout implements View.OnTouchListener { 67 public static final long TOUCH_ANIMATION_DURATION = 150; 68 public static final long TOUCH_RELEASE_ANIMATION_DURATION = 200; 69 70 private final Paint mPaint = new Paint(); 71 private final Rect mBackgroundRect = new Rect(); 72 private final int mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); 73 74 private SplitLayout mSplitLayout; 75 private SplitWindowManager mSplitWindowManager; 76 private SurfaceControlViewHost mViewHost; 77 private DividerHandleView mHandle; 78 private DividerRoundedCorner mCorners; 79 private int mTouchElevation; 80 81 private VelocityTracker mVelocityTracker; 82 private boolean mMoving; 83 private int mStartPos; 84 private GestureDetector mDoubleTapDetector; 85 private boolean mInteractive; 86 private boolean mHideHandle; 87 private boolean mSetTouchRegion = true; 88 private int mLastDraggingPosition; 89 private int mHandleRegionWidth; 90 private int mHandleRegionHeight; 91 92 /** 93 * This is not the visible bounds you see on screen, but the actual behind-the-scenes window 94 * bounds, which is larger. 95 */ 96 private final Rect mDividerBounds = new Rect(); 97 private final Rect mTempRect = new Rect(); 98 private FrameLayout mDividerBar; 99 100 static final Property<DividerView, Integer> DIVIDER_HEIGHT_PROPERTY = 101 new Property<DividerView, Integer>(Integer.class, "height") { 102 @Override 103 public Integer get(DividerView object) { 104 return object.mDividerBar.getLayoutParams().height; 105 } 106 107 @Override 108 public void set(DividerView object, Integer value) { 109 ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) 110 object.mDividerBar.getLayoutParams(); 111 lp.height = value; 112 object.mDividerBar.setLayoutParams(lp); 113 } 114 }; 115 116 private AnimatorListenerAdapter mAnimatorListener = new AnimatorListenerAdapter() { 117 @Override 118 public void onAnimationEnd(Animator animation) { 119 mSetTouchRegion = true; 120 } 121 122 @Override 123 public void onAnimationCancel(Animator animation) { 124 mSetTouchRegion = true; 125 } 126 }; 127 128 final AccessibilityDelegate mHandleDelegate = new AccessibilityDelegate() { 129 @Override 130 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 131 super.onInitializeAccessibilityNodeInfo(host, info); 132 final DividerSnapAlgorithm snapAlgorithm = mSplitLayout.mDividerSnapAlgorithm; 133 if (mSplitLayout.isLeftRightSplit()) { 134 info.addAction(new AccessibilityAction(R.id.action_move_tl_full, 135 mContext.getString(R.string.accessibility_action_divider_left_full))); 136 if (snapAlgorithm.isFirstSplitTargetAvailable()) { 137 info.addAction(new AccessibilityAction(R.id.action_move_tl_70, 138 mContext.getString(R.string.accessibility_action_divider_left_70))); 139 } 140 if (snapAlgorithm.showMiddleSplitTargetForAccessibility()) { 141 // Only show the middle target if there are more than 1 split target 142 info.addAction(new AccessibilityAction(R.id.action_move_tl_50, 143 mContext.getString(R.string.accessibility_action_divider_left_50))); 144 } 145 if (snapAlgorithm.isLastSplitTargetAvailable()) { 146 info.addAction(new AccessibilityAction(R.id.action_move_tl_30, 147 mContext.getString(R.string.accessibility_action_divider_left_30))); 148 } 149 info.addAction(new AccessibilityAction(R.id.action_move_rb_full, 150 mContext.getString(R.string.accessibility_action_divider_right_full))); 151 info.addAction(new AccessibilityAction(R.id.action_swap_apps, 152 mContext.getString(R.string.accessibility_action_divider_swap_horizontal))); 153 } else { 154 info.addAction(new AccessibilityAction(R.id.action_move_tl_full, 155 mContext.getString(R.string.accessibility_action_divider_top_full))); 156 if (snapAlgorithm.isFirstSplitTargetAvailable()) { 157 info.addAction(new AccessibilityAction(R.id.action_move_tl_70, 158 mContext.getString(R.string.accessibility_action_divider_top_70))); 159 } 160 if (snapAlgorithm.showMiddleSplitTargetForAccessibility()) { 161 // Only show the middle target if there are more than 1 split target 162 info.addAction(new AccessibilityAction(R.id.action_move_tl_50, 163 mContext.getString(R.string.accessibility_action_divider_top_50))); 164 } 165 if (snapAlgorithm.isLastSplitTargetAvailable()) { 166 info.addAction(new AccessibilityAction(R.id.action_move_tl_30, 167 mContext.getString(R.string.accessibility_action_divider_top_30))); 168 } 169 info.addAction(new AccessibilityAction(R.id.action_move_rb_full, 170 mContext.getString(R.string.accessibility_action_divider_bottom_full))); 171 info.addAction(new AccessibilityAction(R.id.action_swap_apps, 172 mContext.getString(R.string.accessibility_action_divider_swap_vertical))); 173 } 174 } 175 176 @Override 177 public boolean performAccessibilityAction(@NonNull View host, int action, 178 @Nullable Bundle args) { 179 if (action == R.id.action_swap_apps) { 180 mSplitLayout.onDoubleTappedDivider(); 181 return true; 182 } 183 184 DividerSnapAlgorithm.SnapTarget nextTarget = null; 185 DividerSnapAlgorithm snapAlgorithm = mSplitLayout.mDividerSnapAlgorithm; 186 if (action == R.id.action_move_tl_full) { 187 nextTarget = snapAlgorithm.getDismissEndTarget(); 188 } else if (action == R.id.action_move_tl_70) { 189 nextTarget = snapAlgorithm.getLastSplitTarget(); 190 } else if (action == R.id.action_move_tl_50) { 191 nextTarget = snapAlgorithm.getMiddleTarget(); 192 } else if (action == R.id.action_move_tl_30) { 193 nextTarget = snapAlgorithm.getFirstSplitTarget(); 194 } else if (action == R.id.action_move_rb_full) { 195 nextTarget = snapAlgorithm.getDismissStartTarget(); 196 } 197 if (nextTarget != null) { 198 mSplitLayout.snapToTarget(mSplitLayout.getDividerPosition(), nextTarget); 199 return true; 200 } 201 return super.performAccessibilityAction(host, action, args); 202 } 203 }; 204 DividerView(@onNull Context context)205 public DividerView(@NonNull Context context) { 206 super(context); 207 } 208 DividerView(@onNull Context context, @Nullable AttributeSet attrs)209 public DividerView(@NonNull Context context, 210 @Nullable AttributeSet attrs) { 211 super(context, attrs); 212 } 213 DividerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr)214 public DividerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 215 super(context, attrs, defStyleAttr); 216 } 217 DividerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)218 public DividerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, 219 int defStyleRes) { 220 super(context, attrs, defStyleAttr, defStyleRes); 221 } 222 223 /** Sets up essential dependencies of the divider bar. */ setup(SplitLayout layout, SplitWindowManager splitWindowManager, SurfaceControlViewHost viewHost, InsetsState insetsState)224 public void setup(SplitLayout layout, SplitWindowManager splitWindowManager, 225 SurfaceControlViewHost viewHost, InsetsState insetsState) { 226 mSplitLayout = layout; 227 mSplitWindowManager = splitWindowManager; 228 mViewHost = viewHost; 229 layout.getDividerBounds(mDividerBounds); 230 onInsetsChanged(insetsState, false /* animate */); 231 232 final boolean isLeftRightSplit = mSplitLayout.isLeftRightSplit(); 233 mHandle.setIsLeftRightSplit(isLeftRightSplit); 234 mCorners.setIsLeftRightSplit(isLeftRightSplit); 235 236 mHandleRegionWidth = getResources().getDimensionPixelSize(isLeftRightSplit 237 ? R.dimen.split_divider_handle_region_height 238 : R.dimen.split_divider_handle_region_width); 239 mHandleRegionHeight = getResources().getDimensionPixelSize(isLeftRightSplit 240 ? R.dimen.split_divider_handle_region_width 241 : DesktopModeStatus.canEnterDesktopMode(mContext) 242 ? R.dimen.desktop_mode_portrait_split_divider_handle_region_height 243 : R.dimen.split_divider_handle_region_height); 244 } 245 onInsetsChanged(InsetsState insetsState, boolean animate)246 void onInsetsChanged(InsetsState insetsState, boolean animate) { 247 mSplitLayout.getDividerBounds(mTempRect); 248 // Only insets the divider bar with task bar when it's expanded so that the rounded corners 249 // will be drawn against task bar. 250 // But there is no need to do it when IME showing because there are no rounded corners at 251 // the bottom. This also avoids the problem of task bar height not changing when IME 252 // floating. 253 if (!insetsState.isSourceOrDefaultVisible(InsetsSource.ID_IME, WindowInsets.Type.ime())) { 254 for (int i = insetsState.sourceSize() - 1; i >= 0; i--) { 255 final InsetsSource source = insetsState.sourceAt(i); 256 if (source.getType() == WindowInsets.Type.navigationBars() 257 && source.hasFlags(InsetsSource.FLAG_INSETS_ROUNDED_CORNER)) { 258 mTempRect.inset(source.calculateVisibleInsets(mTempRect)); 259 } 260 } 261 } 262 263 if (!mTempRect.equals(mDividerBounds)) { 264 if (animate) { 265 ObjectAnimator animator = ObjectAnimator.ofInt(this, 266 DIVIDER_HEIGHT_PROPERTY, mDividerBounds.height(), mTempRect.height()); 267 animator.setInterpolator(InsetsController.RESIZE_INTERPOLATOR); 268 animator.setDuration(InsetsController.ANIMATION_DURATION_RESIZE); 269 animator.addListener(mAnimatorListener); 270 animator.start(); 271 } else { 272 DIVIDER_HEIGHT_PROPERTY.set(this, mTempRect.height()); 273 mSetTouchRegion = true; 274 } 275 mDividerBounds.set(mTempRect); 276 } 277 } 278 279 @Override onFinishInflate()280 protected void onFinishInflate() { 281 super.onFinishInflate(); 282 mDividerBar = findViewById(R.id.divider_bar); 283 mHandle = findViewById(R.id.docked_divider_handle); 284 mCorners = findViewById(R.id.docked_divider_rounded_corner); 285 mTouchElevation = getResources().getDimensionPixelSize( 286 R.dimen.docked_stack_divider_lift_elevation); 287 mDoubleTapDetector = new GestureDetector(getContext(), new DoubleTapListener()); 288 mInteractive = true; 289 mHideHandle = false; 290 setOnTouchListener(this); 291 mHandle.setAccessibilityDelegate(mHandleDelegate); 292 setWillNotDraw(false); 293 mPaint.setColor(getResources().getColor(R.color.split_divider_background, null)); 294 mPaint.setAntiAlias(true); 295 mPaint.setStyle(Paint.Style.FILL); 296 } 297 298 @Override onLayout(boolean changed, int left, int top, int right, int bottom)299 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 300 super.onLayout(changed, left, top, right, bottom); 301 if (mSetTouchRegion) { 302 int startX = (mDividerBounds.width() - mHandleRegionWidth) / 2; 303 int startY = (mDividerBounds.height() - mHandleRegionHeight) / 2; 304 mTempRect.set(startX, startY, startX + mHandleRegionWidth, 305 startY + mHandleRegionHeight); 306 mSplitWindowManager.setTouchRegion(mTempRect); 307 mSetTouchRegion = false; 308 } 309 310 if (changed) { 311 boolean isHorizontalSplit = mSplitLayout.isLeftRightSplit(); 312 int dividerSize = getResources().getDimensionPixelSize(R.dimen.split_divider_bar_width); 313 left = isHorizontalSplit ? (getWidth() - dividerSize) / 2 : 0; 314 top = isHorizontalSplit ? 0 : (getHeight() - dividerSize) / 2; 315 right = isHorizontalSplit ? left + dividerSize : getWidth(); 316 bottom = isHorizontalSplit ? getHeight() : top + dividerSize; 317 mBackgroundRect.set(left, top, right, bottom); 318 } 319 } 320 321 @Override onResolvePointerIcon(MotionEvent event, int pointerIndex)322 public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) { 323 return PointerIcon.getSystemIcon(getContext(), 324 mSplitLayout.isLeftRightSplit() ? TYPE_HORIZONTAL_DOUBLE_ARROW 325 : TYPE_VERTICAL_DOUBLE_ARROW); 326 } 327 328 @Override onTouch(View v, MotionEvent event)329 public boolean onTouch(View v, MotionEvent event) { 330 if (mSplitLayout == null || !mInteractive) { 331 return false; 332 } 333 334 if (mDoubleTapDetector.onTouchEvent(event)) { 335 return true; 336 } 337 338 // Convert to use screen-based coordinates to prevent lost track of motion events while 339 // moving divider bar and calculating dragging velocity. 340 event.setLocation(event.getRawX(), event.getRawY()); 341 final int action = event.getAction() & MotionEvent.ACTION_MASK; 342 final boolean isLeftRightSplit = mSplitLayout.isLeftRightSplit(); 343 final int touchPos = (int) (isLeftRightSplit ? event.getX() : event.getY()); 344 switch (action) { 345 case MotionEvent.ACTION_DOWN: 346 mVelocityTracker = VelocityTracker.obtain(); 347 mVelocityTracker.addMovement(event); 348 setTouching(); 349 mStartPos = touchPos; 350 mMoving = false; 351 mSplitLayout.onStartDragging(); 352 break; 353 case MotionEvent.ACTION_MOVE: 354 mVelocityTracker.addMovement(event); 355 if (!mMoving && Math.abs(touchPos - mStartPos) > mTouchSlop) { 356 mStartPos = touchPos; 357 mMoving = true; 358 } 359 if (mMoving) { 360 final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos; 361 mLastDraggingPosition = position; 362 mSplitLayout.updateDividerBounds(position, true /* shouldUseParallaxEffect */); 363 } 364 break; 365 case MotionEvent.ACTION_UP: 366 case MotionEvent.ACTION_CANCEL: 367 releaseTouching(); 368 if (!mMoving) { 369 mSplitLayout.onDraggingCancelled(); 370 break; 371 } 372 373 mVelocityTracker.addMovement(event); 374 mVelocityTracker.computeCurrentVelocity(1000 /* units */); 375 final float velocity = isLeftRightSplit 376 ? mVelocityTracker.getXVelocity() 377 : mVelocityTracker.getYVelocity(); 378 final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos; 379 final DividerSnapAlgorithm.SnapTarget snapTarget = 380 mSplitLayout.findSnapTarget(position, velocity, false /* hardDismiss */); 381 mSplitLayout.snapToTarget(position, snapTarget); 382 mMoving = false; 383 break; 384 } 385 386 return true; 387 } 388 setTouching()389 private void setTouching() { 390 setSlippery(false); 391 mHandle.setTouching(true, true); 392 // Lift handle as well so it doesn't get behind the background, even though it doesn't 393 // cast shadow. 394 mHandle.animate() 395 .setInterpolator(Interpolators.TOUCH_RESPONSE) 396 .setDuration(TOUCH_ANIMATION_DURATION) 397 .translationZ(mTouchElevation) 398 .start(); 399 } 400 releaseTouching()401 private void releaseTouching() { 402 setSlippery(true); 403 mHandle.setTouching(false, true); 404 mHandle.animate() 405 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN) 406 .setDuration(TOUCH_RELEASE_ANIMATION_DURATION) 407 .translationZ(0) 408 .start(); 409 } 410 setSlippery(boolean slippery)411 private void setSlippery(boolean slippery) { 412 if (mViewHost == null) { 413 return; 414 } 415 416 final WindowManager.LayoutParams lp = (WindowManager.LayoutParams) getLayoutParams(); 417 final boolean isSlippery = (lp.flags & FLAG_SLIPPERY) != 0; 418 if (isSlippery == slippery) { 419 return; 420 } 421 422 if (slippery) { 423 lp.flags |= FLAG_SLIPPERY; 424 } else { 425 lp.flags &= ~FLAG_SLIPPERY; 426 } 427 mViewHost.relayout(lp); 428 } 429 430 @Override onHoverEvent(MotionEvent event)431 public boolean onHoverEvent(MotionEvent event) { 432 if (!DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI, CURSOR_HOVER_STATES_ENABLED, 433 /* defaultValue = */ false)) { 434 return false; 435 } 436 437 if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { 438 setHovering(); 439 return true; 440 } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { 441 releaseHovering(); 442 return true; 443 } 444 return false; 445 } 446 447 @VisibleForTesting setHovering()448 void setHovering() { 449 mHandle.setHovering(true, true); 450 mHandle.animate() 451 .setInterpolator(Interpolators.TOUCH_RESPONSE) 452 .setDuration(TOUCH_ANIMATION_DURATION) 453 .translationZ(mTouchElevation) 454 .start(); 455 } 456 457 @Override onDraw(@onNull Canvas canvas)458 protected void onDraw(@NonNull Canvas canvas) { 459 canvas.drawRect(mBackgroundRect, mPaint); 460 } 461 462 @VisibleForTesting releaseHovering()463 void releaseHovering() { 464 mHandle.setHovering(false, true); 465 mHandle.animate() 466 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN) 467 .setDuration(TOUCH_RELEASE_ANIMATION_DURATION) 468 .translationZ(0) 469 .start(); 470 } 471 472 /** 473 * Set divider should interactive to user or not. 474 * 475 * @param interactive divider interactive. 476 * @param hideHandle divider handle hidden or not, only work when interactive is false. 477 * @param from caller from where. 478 */ setInteractive(boolean interactive, boolean hideHandle, String from)479 void setInteractive(boolean interactive, boolean hideHandle, String from) { 480 if (interactive == mInteractive) return; 481 ProtoLog.d(ShellProtoLogGroup.WM_SHELL_SPLIT_SCREEN, 482 "Set divider bar %s hide handle=%b from %s", 483 interactive ? "interactive" : "non-interactive", hideHandle, from); 484 mInteractive = interactive; 485 mHideHandle = hideHandle; 486 if (!mInteractive && mHideHandle && mMoving) { 487 final int position = mSplitLayout.getDividerPosition(); 488 mSplitLayout.flingDividerPosition( 489 mLastDraggingPosition, 490 position, 491 mSplitLayout.FLING_RESIZE_DURATION, 492 Interpolators.FAST_OUT_SLOW_IN, 493 () -> mSplitLayout.setDividerPosition(position, true /* applyLayoutChange */)); 494 mMoving = false; 495 } 496 releaseTouching(); 497 mHandle.setVisibility(!mInteractive && mHideHandle ? View.INVISIBLE : View.VISIBLE); 498 } 499 isInteractive()500 boolean isInteractive() { 501 return mInteractive; 502 } 503 isHandleHidden()504 boolean isHandleHidden() { 505 return mHideHandle; 506 } 507 508 /** Returns true if the divider is currently being physically controlled by the user. */ isMoving()509 boolean isMoving() { 510 return mMoving; 511 } 512 513 private class DoubleTapListener extends GestureDetector.SimpleOnGestureListener { 514 @Override onDoubleTap(MotionEvent e)515 public boolean onDoubleTap(MotionEvent e) { 516 if (mSplitLayout != null) { 517 mSplitLayout.onDoubleTappedDivider(); 518 } 519 return true; 520 } 521 522 @Override onDoubleTapEvent(@onNull MotionEvent e)523 public boolean onDoubleTapEvent(@NonNull MotionEvent e) { 524 return true; 525 } 526 } 527 } 528