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