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