1 /* 2 * Copyright (C) 2017 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.dialershared.bubble; 18 19 import android.content.Context; 20 import android.graphics.Point; 21 import android.support.animation.FloatPropertyCompat; 22 import android.support.animation.SpringAnimation; 23 import android.support.animation.SpringForce; 24 import android.support.annotation.NonNull; 25 import android.support.v4.math.MathUtils; 26 import android.view.Gravity; 27 import android.view.MotionEvent; 28 import android.view.VelocityTracker; 29 import android.view.View; 30 import android.view.View.OnTouchListener; 31 import android.view.ViewConfiguration; 32 import android.view.WindowManager; 33 import android.view.WindowManager.LayoutParams; 34 import android.widget.Scroller; 35 36 /** Handles touches and manages moving the bubble in response */ 37 class MoveHandler implements OnTouchListener { 38 39 // Amount the ViewConfiguration's minFlingVelocity will be scaled by for our own minVelocity 40 private static final int MIN_FLING_VELOCITY_FACTOR = 8; 41 // The friction multiplier to control how slippery the bubble is when flung 42 private static final float SCROLL_FRICTION_MULTIPLIER = 4f; 43 44 private final Context context; 45 private final WindowManager windowManager; 46 private final Bubble bubble; 47 private final int minX; 48 private final int minY; 49 private final int maxX; 50 private final int maxY; 51 private final int bubbleSize; 52 private final int shadowPaddingSize; 53 private final float touchSlopSquared; 54 55 private boolean isMoving; 56 private float firstX; 57 private float firstY; 58 59 private SpringAnimation moveXAnimation; 60 private SpringAnimation moveYAnimation; 61 private VelocityTracker velocityTracker; 62 private Scroller scroller; 63 64 // Handles the left/right gravity conversion and centering 65 private final FloatPropertyCompat<WindowManager.LayoutParams> xProperty = 66 new FloatPropertyCompat<LayoutParams>("xProperty") { 67 @Override 68 public float getValue(LayoutParams windowParams) { 69 int realX = windowParams.x; 70 realX = realX + bubbleSize / 2; 71 realX = realX + shadowPaddingSize; 72 if (relativeToRight(windowParams)) { 73 int displayWidth = context.getResources().getDisplayMetrics().widthPixels; 74 realX = displayWidth - realX; 75 } 76 return MathUtils.clamp(realX, minX, maxX); 77 } 78 79 @Override 80 public void setValue(LayoutParams windowParams, float value) { 81 boolean wasOnRight = (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT; 82 int displayWidth = context.getResources().getDisplayMetrics().widthPixels; 83 boolean onRight; 84 Integer gravityOverride = bubble.getGravityOverride(); 85 if (gravityOverride == null) { 86 onRight = value > displayWidth / 2; 87 } else { 88 onRight = (gravityOverride & Gravity.RIGHT) == Gravity.RIGHT; 89 } 90 int centeringOffset = bubbleSize / 2 + shadowPaddingSize; 91 windowParams.x = 92 (int) (onRight ? (displayWidth - value - centeringOffset) : value - centeringOffset); 93 windowParams.gravity = Gravity.TOP | (onRight ? Gravity.RIGHT : Gravity.LEFT); 94 if (wasOnRight != onRight) { 95 bubble.onLeftRightSwitch(onRight); 96 } 97 if (bubble.isVisible()) { 98 windowManager.updateViewLayout(bubble.getRootView(), windowParams); 99 } 100 } 101 }; 102 103 private final FloatPropertyCompat<WindowManager.LayoutParams> yProperty = 104 new FloatPropertyCompat<LayoutParams>("yProperty") { 105 @Override 106 public float getValue(LayoutParams object) { 107 return MathUtils.clamp(object.y + bubbleSize + shadowPaddingSize, minY, maxY); 108 } 109 110 @Override 111 public void setValue(LayoutParams object, float value) { 112 object.y = (int) value - bubbleSize - shadowPaddingSize; 113 if (bubble.isVisible()) { 114 windowManager.updateViewLayout(bubble.getRootView(), object); 115 } 116 } 117 }; 118 119 public MoveHandler(@NonNull View targetView, @NonNull Bubble bubble) { 120 this.bubble = bubble; 121 context = targetView.getContext(); 122 windowManager = context.getSystemService(WindowManager.class); 123 124 bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size); 125 shadowPaddingSize = 126 context.getResources().getDimensionPixelOffset(R.dimen.bubble_shadow_padding_size); 127 minX = 128 context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_x) 129 + bubbleSize / 2; 130 minY = 131 context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_y) 132 + bubbleSize / 2; 133 maxX = context.getResources().getDisplayMetrics().widthPixels - minX; 134 maxY = context.getResources().getDisplayMetrics().heightPixels - minY; 135 136 // Squared because it will be compared against the square of the touch delta. This is more 137 // efficient than needing to take a square root. 138 touchSlopSquared = (float) Math.pow(ViewConfiguration.get(context).getScaledTouchSlop(), 2); 139 140 targetView.setOnTouchListener(this); 141 } 142 143 public boolean isMoving() { 144 return isMoving; 145 } 146 147 public void undoGravityOverride() { 148 LayoutParams windowParams = bubble.getWindowParams(); 149 xProperty.setValue(windowParams, xProperty.getValue(windowParams)); 150 } 151 152 public void snapToBounds() { 153 ensureSprings(); 154 155 moveXAnimation.animateToFinalPosition(relativeToRight(bubble.getWindowParams()) ? maxX : minX); 156 moveYAnimation.animateToFinalPosition(yProperty.getValue(bubble.getWindowParams())); 157 } 158 159 @Override 160 public boolean onTouch(View v, MotionEvent event) { 161 float eventX = event.getRawX(); 162 float eventY = event.getRawY(); 163 switch (event.getActionMasked()) { 164 case MotionEvent.ACTION_DOWN: 165 firstX = eventX; 166 firstY = eventY; 167 velocityTracker = VelocityTracker.obtain(); 168 break; 169 case MotionEvent.ACTION_MOVE: 170 if (isMoving || hasExceededTouchSlop(event)) { 171 if (!isMoving) { 172 isMoving = true; 173 bubble.onMoveStart(); 174 } 175 176 ensureSprings(); 177 178 moveXAnimation.animateToFinalPosition(MathUtils.clamp(eventX, minX, maxX)); 179 moveYAnimation.animateToFinalPosition(MathUtils.clamp(eventY, minY, maxY)); 180 } 181 182 velocityTracker.addMovement(event); 183 break; 184 case MotionEvent.ACTION_UP: 185 if (isMoving) { 186 ViewConfiguration viewConfiguration = ViewConfiguration.get(context); 187 velocityTracker.computeCurrentVelocity( 188 1000, viewConfiguration.getScaledMaximumFlingVelocity()); 189 float xVelocity = velocityTracker.getXVelocity(); 190 float yVelocity = velocityTracker.getYVelocity(); 191 boolean isFling = isFling(xVelocity, yVelocity); 192 193 if (isFling) { 194 Point target = 195 findTarget( 196 xVelocity, 197 yVelocity, 198 (int) xProperty.getValue(bubble.getWindowParams()), 199 (int) yProperty.getValue(bubble.getWindowParams())); 200 201 moveXAnimation.animateToFinalPosition(target.x); 202 moveYAnimation.animateToFinalPosition(target.y); 203 } else { 204 snapX(); 205 } 206 isMoving = false; 207 bubble.onMoveFinish(); 208 } else { 209 v.performClick(); 210 bubble.primaryButtonClick(); 211 } 212 break; 213 } 214 return true; 215 } 216 217 private void ensureSprings() { 218 if (moveXAnimation == null) { 219 moveXAnimation = new SpringAnimation(bubble.getWindowParams(), xProperty); 220 moveXAnimation.setSpring(new SpringForce()); 221 moveXAnimation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY); 222 } 223 224 if (moveYAnimation == null) { 225 moveYAnimation = new SpringAnimation(bubble.getWindowParams(), yProperty); 226 moveYAnimation.setSpring(new SpringForce()); 227 moveYAnimation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY); 228 } 229 } 230 231 private Point findTarget(float xVelocity, float yVelocity, int startX, int startY) { 232 if (scroller == null) { 233 scroller = new Scroller(context); 234 scroller.setFriction(ViewConfiguration.getScrollFriction() * SCROLL_FRICTION_MULTIPLIER); 235 } 236 237 // Find where a fling would end vertically 238 scroller.fling(startX, startY, (int) xVelocity, (int) yVelocity, minX, maxX, minY, maxY); 239 int targetY = scroller.getFinalY(); 240 scroller.abortAnimation(); 241 242 // If the x component of the velocity is above the minimum fling velocity, use velocity to 243 // determine edge. Otherwise use its starting position 244 boolean pullRight = isFling(xVelocity, 0) ? xVelocity > 0 : isOnRightHalf(startX); 245 return new Point(pullRight ? maxX : minX, targetY); 246 } 247 248 private boolean isFling(float xVelocity, float yVelocity) { 249 int minFlingVelocity = 250 ViewConfiguration.get(context).getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_FACTOR; 251 return getMagnitudeSquared(xVelocity, yVelocity) > minFlingVelocity * minFlingVelocity; 252 } 253 254 private boolean isOnRightHalf(float currentX) { 255 return currentX > (minX + maxX) / 2; 256 } 257 258 private void snapX() { 259 // Check if x value is closer to min or max 260 boolean pullRight = isOnRightHalf(xProperty.getValue(bubble.getWindowParams())); 261 moveXAnimation.animateToFinalPosition(pullRight ? maxX : minX); 262 } 263 264 private boolean relativeToRight(LayoutParams windowParams) { 265 return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT; 266 } 267 268 private boolean hasExceededTouchSlop(MotionEvent event) { 269 return getMagnitudeSquared(event.getRawX() - firstX, event.getRawY() - firstY) 270 > touchSlopSquared; 271 } 272 273 private float getMagnitudeSquared(float deltaX, float deltaY) { 274 return deltaX * deltaX + deltaY * deltaY; 275 } 276 } 277