Selim Cinek | afae4e7 | 2020-06-16 18:21:41 -0700 | [diff] [blame] | 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.systemui.media |
| 18 | |
| 19 | import android.graphics.Outline |
| 20 | import android.util.MathUtils |
| 21 | import android.view.GestureDetector |
| 22 | import android.view.MotionEvent |
| 23 | import android.view.View |
| 24 | import android.view.ViewGroup |
| 25 | import android.view.ViewOutlineProvider |
| 26 | import androidx.core.view.GestureDetectorCompat |
| 27 | import androidx.dynamicanimation.animation.FloatPropertyCompat |
| 28 | import androidx.dynamicanimation.animation.SpringForce |
| 29 | import com.android.settingslib.Utils |
| 30 | import com.android.systemui.Gefingerpoken |
| 31 | import com.android.systemui.qs.PageIndicator |
| 32 | import com.android.systemui.R |
| 33 | import com.android.systemui.plugins.FalsingManager |
| 34 | import com.android.systemui.util.animation.PhysicsAnimator |
| 35 | import com.android.systemui.util.concurrency.DelayableExecutor |
| 36 | |
| 37 | private const val FLING_SLOP = 1000000 |
| 38 | private const val DISMISS_DELAY = 100L |
| 39 | private const val RUBBERBAND_FACTOR = 0.2f |
| 40 | private const val SETTINGS_BUTTON_TRANSLATION_FRACTION = 0.3f |
| 41 | |
| 42 | /** |
| 43 | * Default spring configuration to use for animations where stiffness and/or damping ratio |
| 44 | * were not provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig]. |
| 45 | */ |
| 46 | private val translationConfig = PhysicsAnimator.SpringConfig( |
| 47 | SpringForce.STIFFNESS_MEDIUM, |
| 48 | SpringForce.DAMPING_RATIO_LOW_BOUNCY) |
| 49 | |
| 50 | /** |
| 51 | * A controller class for the media scrollview, responsible for touch handling |
| 52 | */ |
| 53 | class MediaCarouselScrollHandler( |
| 54 | private val scrollView: MediaScrollView, |
| 55 | private val pageIndicator: PageIndicator, |
| 56 | private val mainExecutor: DelayableExecutor, |
| 57 | private val dismissCallback: () -> Unit, |
| 58 | private var translationChangedListener: () -> Unit, |
| 59 | private val falsingManager: FalsingManager |
| 60 | ) { |
| 61 | /** |
| 62 | * Do we need falsing protection? |
| 63 | */ |
| 64 | var falsingProtectionNeeded: Boolean = false |
| 65 | /** |
| 66 | * The width of the carousel |
| 67 | */ |
| 68 | private var carouselWidth: Int = 0 |
| 69 | |
| 70 | /** |
| 71 | * The height of the carousel |
| 72 | */ |
| 73 | private var carouselHeight: Int = 0 |
| 74 | |
| 75 | /** |
| 76 | * How much are we scrolled into the current media? |
| 77 | */ |
| 78 | private var cornerRadius: Int = 0 |
| 79 | |
| 80 | /** |
| 81 | * The content where the players are added |
| 82 | */ |
| 83 | private var mediaContent: ViewGroup |
| 84 | /** |
| 85 | * The gesture detector to detect touch gestures |
| 86 | */ |
| 87 | private val gestureDetector: GestureDetectorCompat |
| 88 | |
| 89 | /** |
| 90 | * The settings button view |
| 91 | */ |
| 92 | private lateinit var settingsButton: View |
| 93 | |
| 94 | /** |
| 95 | * What's the currently active player index? |
| 96 | */ |
| 97 | var activeMediaIndex: Int = 0 |
| 98 | private set |
| 99 | /** |
| 100 | * How much are we scrolled into the current media? |
| 101 | */ |
| 102 | private var scrollIntoCurrentMedia: Int = 0 |
| 103 | |
| 104 | /** |
| 105 | * how much is the content translated in X |
| 106 | */ |
| 107 | var contentTranslation = 0.0f |
| 108 | private set(value) { |
| 109 | field = value |
| 110 | mediaContent.translationX = value |
| 111 | updateSettingsPresentation() |
| 112 | translationChangedListener.invoke() |
| 113 | updateClipToOutline() |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * The width of a player including padding |
| 118 | */ |
| 119 | var playerWidthPlusPadding: Int = 0 |
| 120 | set(value) { |
| 121 | field = value |
| 122 | // The player width has changed, let's update the scroll position to make sure |
| 123 | // it's still at the same place |
| 124 | var newScroll = activeMediaIndex * playerWidthPlusPadding |
| 125 | if (scrollIntoCurrentMedia > playerWidthPlusPadding) { |
| 126 | newScroll += playerWidthPlusPadding - |
| 127 | (scrollIntoCurrentMedia - playerWidthPlusPadding) |
| 128 | } else { |
| 129 | newScroll += scrollIntoCurrentMedia |
| 130 | } |
| 131 | scrollView.scrollX = newScroll |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Does the dismiss currently show the setting cog? |
| 136 | */ |
| 137 | var showsSettingsButton: Boolean = false |
| 138 | |
| 139 | /** |
| 140 | * A utility to detect gestures, used in the touch listener |
| 141 | */ |
| 142 | private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { |
| 143 | override fun onFling( |
| 144 | eStart: MotionEvent?, |
| 145 | eCurrent: MotionEvent?, |
| 146 | vX: Float, |
| 147 | vY: Float |
| 148 | ) = onFling(vX, vY) |
| 149 | |
| 150 | override fun onScroll( |
| 151 | down: MotionEvent?, |
| 152 | lastMotion: MotionEvent?, |
| 153 | distanceX: Float, |
| 154 | distanceY: Float |
| 155 | ) = onScroll(down!!, lastMotion!!, distanceX) |
| 156 | |
| 157 | override fun onDown(e: MotionEvent?): Boolean { |
| 158 | if (falsingProtectionNeeded) { |
| 159 | falsingManager.onNotificationStartDismissing() |
| 160 | } |
| 161 | return false |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * The touch listener for the scroll view |
| 167 | */ |
| 168 | private val touchListener = object : Gefingerpoken { |
| 169 | override fun onTouchEvent(motionEvent: MotionEvent?) = onTouch(motionEvent!!) |
| 170 | override fun onInterceptTouchEvent(ev: MotionEvent?) = onInterceptTouch(ev!!) |
| 171 | } |
| 172 | |
| 173 | /** |
| 174 | * A listener that is invoked when the scrolling changes to update player visibilities |
| 175 | */ |
| 176 | private val scrollChangedListener = object : View.OnScrollChangeListener { |
| 177 | override fun onScrollChange( |
| 178 | v: View?, |
| 179 | scrollX: Int, |
| 180 | scrollY: Int, |
| 181 | oldScrollX: Int, |
| 182 | oldScrollY: Int |
| 183 | ) { |
| 184 | if (playerWidthPlusPadding == 0) { |
| 185 | return |
| 186 | } |
| 187 | onMediaScrollingChanged(scrollX / playerWidthPlusPadding, |
| 188 | scrollX % playerWidthPlusPadding) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | init { |
| 193 | gestureDetector = GestureDetectorCompat(scrollView.context, gestureListener) |
| 194 | scrollView.touchListener = touchListener |
| 195 | scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER) |
| 196 | mediaContent = scrollView.contentContainer |
| 197 | scrollView.setOnScrollChangeListener(scrollChangedListener) |
| 198 | scrollView.outlineProvider = object : ViewOutlineProvider() { |
| 199 | override fun getOutline(view: View?, outline: Outline?) { |
| 200 | outline?.setRoundRect(0, 0, carouselWidth, carouselHeight, cornerRadius.toFloat()) |
| 201 | } |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | fun onSettingsButtonUpdated(button: View) { |
| 206 | settingsButton = button |
| 207 | // We don't have a context to resolve, lets use the settingsbuttons one since that is |
| 208 | // reinflated appropriately |
| 209 | cornerRadius = settingsButton.resources.getDimensionPixelSize( |
| 210 | Utils.getThemeAttr(settingsButton.context, android.R.attr.dialogCornerRadius)) |
| 211 | updateSettingsPresentation() |
| 212 | scrollView.invalidateOutline() |
| 213 | } |
| 214 | |
| 215 | private fun updateSettingsPresentation() { |
| 216 | if (showsSettingsButton) { |
| 217 | val settingsOffset = MathUtils.map( |
| 218 | 0.0f, |
| 219 | getMaxTranslation().toFloat(), |
| 220 | 0.0f, |
| 221 | 1.0f, |
| 222 | Math.abs(contentTranslation)) |
| 223 | val settingsTranslation = (1.0f - settingsOffset) * -settingsButton.width * |
| 224 | SETTINGS_BUTTON_TRANSLATION_FRACTION |
| 225 | val newTranslationX: Float |
| 226 | if (contentTranslation > 0) { |
| 227 | newTranslationX = settingsTranslation |
| 228 | } else { |
| 229 | newTranslationX = scrollView.width - settingsTranslation - settingsButton.width |
| 230 | } |
| 231 | val rotation = (1.0f - settingsOffset) * 50 |
| 232 | settingsButton.rotation = rotation * -Math.signum(contentTranslation) |
| 233 | val alpha = MathUtils.map(0.5f, 1.0f, 0.0f, 1.0f, settingsOffset) |
| 234 | settingsButton.alpha = alpha |
| 235 | settingsButton.visibility = if (alpha != 0.0f) View.VISIBLE else View.INVISIBLE |
| 236 | settingsButton.translationX = newTranslationX |
| 237 | settingsButton.translationY = (scrollView.height - settingsButton.height) / 2.0f |
| 238 | } else { |
| 239 | settingsButton.visibility = View.INVISIBLE |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | private fun onTouch(motionEvent: MotionEvent): Boolean { |
| 244 | val isUp = motionEvent.action == MotionEvent.ACTION_UP |
| 245 | if (isUp && falsingProtectionNeeded) { |
| 246 | falsingManager.onNotificationStopDismissing() |
| 247 | } |
| 248 | if (gestureDetector.onTouchEvent(motionEvent)) { |
| 249 | if (isUp) { |
| 250 | // If this is an up and we're flinging, we don't want to have this touch reach |
| 251 | // the view, otherwise that would scroll, while we are trying to snap to the |
| 252 | // new page. Let's dispatch a cancel instead. |
| 253 | scrollView.cancelCurrentScroll() |
| 254 | return true |
| 255 | } else { |
| 256 | // Pass touches to the scrollView |
| 257 | return false |
| 258 | } |
| 259 | } |
| 260 | if (isUp || motionEvent.action == MotionEvent.ACTION_CANCEL) { |
| 261 | // It's an up and the fling didn't take it above |
| 262 | val pos = scrollView.scrollX % playerWidthPlusPadding |
| 263 | val scollXAmount: Int |
| 264 | if (pos > playerWidthPlusPadding / 2) { |
| 265 | scollXAmount = playerWidthPlusPadding - pos |
| 266 | } else { |
| 267 | scollXAmount = -1 * pos |
| 268 | } |
| 269 | if (scollXAmount != 0) { |
| 270 | // Delay the scrolling since scrollView calls springback which cancels |
| 271 | // the animation again.. |
| 272 | mainExecutor.execute { |
| 273 | scrollView.smoothScrollBy(scollXAmount, 0) |
| 274 | } |
| 275 | } |
| 276 | val currentTranslation = scrollView.getContentTranslation() |
| 277 | if (currentTranslation != 0.0f) { |
| 278 | // We started a Swipe but didn't end up with a fling. Let's either go to the |
| 279 | // dismissed position or go back. |
| 280 | val springBack = Math.abs(currentTranslation) < getMaxTranslation() / 2 |
| 281 | || isFalseTouch() |
| 282 | val newTranslation: Float |
| 283 | if (springBack) { |
| 284 | newTranslation = 0.0f |
| 285 | } else { |
| 286 | newTranslation = getMaxTranslation() * Math.signum(currentTranslation) |
| 287 | if (!showsSettingsButton) { |
| 288 | // Delay the dismiss a bit to avoid too much overlap. Waiting until the |
| 289 | // animation has finished also feels a bit too slow here. |
| 290 | mainExecutor.executeDelayed({ |
| 291 | dismissCallback.invoke() |
| 292 | }, DISMISS_DELAY) |
| 293 | } |
| 294 | } |
| 295 | PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION, |
| 296 | newTranslation, startVelocity = 0.0f, config = translationConfig).start() |
| 297 | scrollView.animationTargetX = newTranslation |
| 298 | } |
| 299 | } |
| 300 | // Always pass touches to the scrollView |
| 301 | return false |
| 302 | } |
| 303 | |
| 304 | private fun isFalseTouch() = falsingProtectionNeeded && falsingManager.isFalseTouch |
| 305 | |
| 306 | private fun getMaxTranslation() = if (showsSettingsButton) { |
| 307 | settingsButton.width |
| 308 | } else { |
| 309 | playerWidthPlusPadding |
| 310 | } |
| 311 | |
| 312 | private fun onInterceptTouch(motionEvent: MotionEvent): Boolean { |
| 313 | return gestureDetector.onTouchEvent(motionEvent) |
| 314 | } |
| 315 | |
| 316 | fun onScroll(down: MotionEvent, |
| 317 | lastMotion: MotionEvent, |
| 318 | distanceX: Float): Boolean { |
| 319 | val totalX = lastMotion.x - down.x |
| 320 | val currentTranslation = scrollView.getContentTranslation() |
| 321 | if (currentTranslation != 0.0f || |
| 322 | !scrollView.canScrollHorizontally((-totalX).toInt())) { |
| 323 | var newTranslation = currentTranslation - distanceX |
| 324 | val absTranslation = Math.abs(newTranslation) |
| 325 | if (absTranslation > getMaxTranslation()) { |
| 326 | // Rubberband all translation above the maximum |
| 327 | if (Math.signum(distanceX) != Math.signum(currentTranslation)) { |
| 328 | // The movement is in the same direction as our translation, |
| 329 | // Let's rubberband it. |
| 330 | if (Math.abs(currentTranslation) > getMaxTranslation()) { |
| 331 | // we were already overshooting before. Let's add the distance |
| 332 | // fully rubberbanded. |
| 333 | newTranslation = currentTranslation - distanceX * RUBBERBAND_FACTOR |
| 334 | } else { |
| 335 | // We just crossed the boundary, let's rubberband it all |
| 336 | newTranslation = Math.signum(newTranslation) * (getMaxTranslation() + |
| 337 | (absTranslation - getMaxTranslation()) * RUBBERBAND_FACTOR) |
| 338 | } |
| 339 | } // Otherwise we don't have do do anything, and will remove the unrubberbanded |
| 340 | // translation |
| 341 | } |
| 342 | if (Math.signum(newTranslation) != Math.signum(currentTranslation) |
| 343 | && currentTranslation != 0.0f) { |
| 344 | // We crossed the 0.0 threshold of the translation. Let's see if we're allowed |
| 345 | // to scroll into the new direction |
| 346 | if (scrollView.canScrollHorizontally(-newTranslation.toInt())) { |
| 347 | // We can actually scroll in the direction where we want to translate, |
| 348 | // Let's make sure to stop at 0 |
| 349 | newTranslation = 0.0f |
| 350 | } |
| 351 | } |
| 352 | val physicsAnimator = PhysicsAnimator.getInstance(this) |
| 353 | if (physicsAnimator.isRunning()) { |
| 354 | physicsAnimator.spring(CONTENT_TRANSLATION, |
| 355 | newTranslation, startVelocity = 0.0f, config = translationConfig).start() |
| 356 | } else { |
| 357 | contentTranslation = newTranslation |
| 358 | } |
| 359 | scrollView.animationTargetX = newTranslation |
| 360 | return true |
| 361 | } |
| 362 | return false |
| 363 | } |
| 364 | |
| 365 | private fun onFling( |
| 366 | vX: Float, |
| 367 | vY: Float |
| 368 | ): Boolean { |
| 369 | if (vX * vX < 0.5 * vY * vY) { |
| 370 | return false |
| 371 | } |
| 372 | if (vX * vX < FLING_SLOP) { |
| 373 | return false |
| 374 | } |
| 375 | val currentTranslation = scrollView.getContentTranslation() |
| 376 | if (currentTranslation != 0.0f) { |
| 377 | // We're translated and flung. Let's see if the fling is in the same direction |
| 378 | val newTranslation: Float |
| 379 | if (Math.signum(vX) != Math.signum(currentTranslation) || isFalseTouch()) { |
| 380 | // The direction of the fling isn't the same as the translation, let's go to 0 |
| 381 | newTranslation = 0.0f |
| 382 | } else { |
| 383 | newTranslation = getMaxTranslation() * Math.signum(currentTranslation) |
| 384 | // Delay the dismiss a bit to avoid too much overlap. Waiting until the animation |
| 385 | // has finished also feels a bit too slow here. |
| 386 | if (!showsSettingsButton) { |
| 387 | mainExecutor.executeDelayed({ |
| 388 | dismissCallback.invoke() |
| 389 | }, DISMISS_DELAY) |
| 390 | } |
| 391 | } |
| 392 | PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION, |
| 393 | newTranslation, startVelocity = vX, config = translationConfig).start() |
| 394 | scrollView.animationTargetX = newTranslation |
| 395 | } else { |
| 396 | // We're flinging the player! Let's go either to the previous or to the next player |
| 397 | val pos = scrollView.scrollX |
| 398 | val currentIndex = if (playerWidthPlusPadding > 0) pos / playerWidthPlusPadding else 0 |
| 399 | var destIndex = if (vX <= 0) currentIndex + 1 else currentIndex |
| 400 | destIndex = Math.max(0, destIndex) |
| 401 | destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex) |
| 402 | val view = mediaContent.getChildAt(destIndex) |
| 403 | // We need to post this since we're dispatching a touch to the underlying view to cancel |
| 404 | // but canceling will actually abort the animation. |
| 405 | mainExecutor.execute { |
| 406 | scrollView.smoothScrollTo(view.left, scrollView.scrollY) |
| 407 | } |
| 408 | } |
| 409 | return true |
| 410 | } |
| 411 | |
| 412 | /** |
| 413 | * Reset the translation of the players when swiped |
| 414 | */ |
| 415 | fun resetTranslation(animate: Boolean = false) { |
| 416 | if (scrollView.getContentTranslation() != 0.0f) { |
| 417 | if (animate) { |
| 418 | PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION, |
| 419 | 0.0f, config = translationConfig).start() |
| 420 | scrollView.animationTargetX = 0.0f |
| 421 | } else { |
| 422 | PhysicsAnimator.getInstance(this).cancel() |
| 423 | contentTranslation = 0.0f |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | private fun updateClipToOutline() { |
| 429 | val clip = contentTranslation != 0.0f || scrollIntoCurrentMedia != 0 |
| 430 | scrollView.clipToOutline = clip |
| 431 | } |
| 432 | |
| 433 | private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) { |
| 434 | val wasScrolledIn = scrollIntoCurrentMedia != 0 |
| 435 | scrollIntoCurrentMedia = scrollInAmount |
| 436 | val nowScrolledIn = scrollIntoCurrentMedia != 0 |
| 437 | if (newIndex != activeMediaIndex || wasScrolledIn != nowScrolledIn) { |
| 438 | activeMediaIndex = newIndex |
| 439 | updatePlayerVisibilities() |
| 440 | } |
| 441 | val location = activeMediaIndex.toFloat() + if (playerWidthPlusPadding > 0) |
| 442 | scrollInAmount.toFloat() / playerWidthPlusPadding else 0f |
| 443 | pageIndicator.setLocation(location) |
| 444 | updateClipToOutline() |
| 445 | } |
| 446 | |
| 447 | /** |
| 448 | * Notified whenever the players or their order has changed |
| 449 | */ |
| 450 | fun onPlayersChanged() { |
| 451 | updatePlayerVisibilities() |
| 452 | updateMediaPaddings() |
| 453 | } |
| 454 | |
| 455 | private fun updateMediaPaddings() { |
| 456 | val padding = scrollView.context.resources.getDimensionPixelSize(R.dimen.qs_media_padding) |
| 457 | val childCount = mediaContent.childCount |
| 458 | for (i in 0 until childCount) { |
| 459 | val mediaView = mediaContent.getChildAt(i) |
| 460 | val desiredPaddingEnd = if (i == childCount - 1) 0 else padding |
| 461 | val layoutParams = mediaView.layoutParams as ViewGroup.MarginLayoutParams |
| 462 | if (layoutParams.marginEnd != desiredPaddingEnd) { |
| 463 | layoutParams.marginEnd = desiredPaddingEnd |
| 464 | mediaView.layoutParams = layoutParams |
| 465 | } |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | private fun updatePlayerVisibilities() { |
| 470 | val scrolledIn = scrollIntoCurrentMedia != 0 |
| 471 | for (i in 0 until mediaContent.childCount) { |
| 472 | val view = mediaContent.getChildAt(i) |
| 473 | val visible = (i == activeMediaIndex) || ((i == (activeMediaIndex + 1)) && scrolledIn) |
| 474 | view.visibility = if (visible) View.VISIBLE else View.INVISIBLE |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | /** |
| 479 | * Notify that a player will be removed right away. This gives us the opporunity to look |
| 480 | * where it was and update our scroll position. |
| 481 | */ |
| 482 | fun onPrePlayerRemoved(removed: MediaControlPanel) { |
| 483 | val beforeActive = mediaContent.indexOfChild(removed.view?.player) <= activeMediaIndex |
| 484 | if (beforeActive) { |
| 485 | // also update the index here since the scroll below might not always lead |
| 486 | // to a scrolling changed |
| 487 | activeMediaIndex = Math.max(0, activeMediaIndex - 1) |
| 488 | scrollView.scrollX = Math.max(scrollView.scrollX - |
| 489 | playerWidthPlusPadding, 0) |
| 490 | } |
| 491 | } |
| 492 | |
| 493 | /** |
| 494 | * Update the bounds of the carousel |
| 495 | */ |
| 496 | fun setCarouselBounds(currentCarouselWidth: Int, currentCarouselHeight: Int) { |
| 497 | if (currentCarouselHeight != carouselHeight || currentCarouselWidth != carouselHeight) { |
| 498 | carouselWidth = currentCarouselWidth |
| 499 | carouselHeight = currentCarouselHeight |
| 500 | scrollView.invalidateOutline() |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | companion object { |
| 505 | private val CONTENT_TRANSLATION = object : FloatPropertyCompat<MediaCarouselScrollHandler>( |
| 506 | "contentTranslation") { |
| 507 | override fun getValue(handler: MediaCarouselScrollHandler): Float { |
| 508 | return handler.contentTranslation |
| 509 | } |
| 510 | |
| 511 | override fun setValue(handler: MediaCarouselScrollHandler, value: Float) { |
| 512 | handler.contentTranslation = value |
| 513 | } |
| 514 | } |
| 515 | } |
| 516 | } |