1 /* 2 * Copyright (C) 2016 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.car.cluster.sample; 18 19 import static com.android.car.cluster.sample.BitmapUtils.generateMediaIcon; 20 21 import android.animation.Animator; 22 import android.animation.AnimatorListenerAdapter; 23 import android.content.Context; 24 import android.graphics.Bitmap; 25 import android.graphics.BitmapFactory; 26 import android.os.Handler; 27 import android.os.Looper; 28 import android.util.AttributeSet; 29 import android.util.Log; 30 import android.view.View; 31 import android.view.ViewAnimationUtils; 32 import android.view.ViewGroup; 33 import android.view.animation.AccelerateInterpolator; 34 import android.view.animation.AlphaAnimation; 35 import android.view.animation.Animation; 36 import android.view.animation.Animation.AnimationListener; 37 import android.view.animation.DecelerateInterpolator; 38 import android.view.animation.Interpolator; 39 import android.view.animation.Transformation; 40 import android.view.animation.TranslateAnimation; 41 import android.widget.FrameLayout; 42 import android.widget.RelativeLayout; 43 44 import com.android.car.cluster.sample.cards.CallCard; 45 import com.android.car.cluster.sample.cards.CallCard.CallStatus; 46 import com.android.car.cluster.sample.cards.CardView; 47 import com.android.car.cluster.sample.cards.CardView.CardType; 48 import com.android.car.cluster.sample.cards.MessageCard; 49 import com.android.car.cluster.sample.cards.MediaCard; 50 import com.android.car.cluster.sample.cards.NavCard; 51 import com.android.car.cluster.sample.cards.WeatherCard; 52 53 import java.util.PriorityQueue; 54 55 /** 56 * Class that represents cluster view. It is responsible of ranking cards and play animations 57 * during card transitions. 58 */ 59 public class ClusterView extends FrameLayout implements CardView.PriorityChangedListener{ 60 private static final String TAG = DebugUtil.getTag(ClusterView.class); 61 62 private CardPanel mCardPanel; 63 64 private final Handler mHandler = new Handler(Looper.getMainLooper()); 65 private final PriorityQueue<CardView> mQueue = new PriorityQueue<>(8); 66 67 public ClusterView(Context context) { 68 this(context, null); 69 } 70 71 public ClusterView(Context context, AttributeSet attrs) { 72 super(context, attrs); 73 74 inflate(getContext(), R.layout.cluster_view, this); 75 mCardPanel = (CardPanel) findViewById(R.id.card_panel); 76 } 77 78 private <E extends CardView> E createCard(@CardType int cardType) { 79 CardView card; 80 switch (cardType) 81 { 82 case CardType.WEATHER: 83 card = new WeatherCard(getContext(), this /* priority listener */); 84 break; 85 case CardType.MEDIA: 86 card = new MediaCard(getContext(), this /* priority listener */); 87 break; 88 case CardType.PHONE_CALL: 89 card = new CallCard(getContext(), this /* priority listener */); 90 break; 91 case CardType.NAV: 92 card = new NavCard(getContext(), this /* priority listener */); 93 break; 94 case CardType.HANGOUT: 95 card = new MessageCard(getContext(), this /* priority listener */); 96 break; 97 default: 98 card = new CardView(getContext(), cardType, this /* priority listener */); 99 } 100 RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( 101 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); 102 card.setLayoutParams(params); 103 return (E) card; 104 } 105 106 public void handleIncomingCall(Bitmap contactImage, String contactName) { 107 if (contactImage == null) { 108 contactImage = BitmapFactory.decodeResource(getResources(), 109 R.drawable.unknown_contact_480); 110 } 111 CardView card = createIncomingCallCard(contactImage, contactName); 112 enqueueCard(card); 113 } 114 115 public void handleDialingCall(Bitmap contactImage, String contactName) { 116 if (contactImage == null) { 117 contactImage = BitmapFactory.decodeResource(getResources(), 118 R.drawable.unknown_contact_480); 119 } 120 CardView card = createDialingCallCard(contactImage, contactName); 121 enqueueCard(card); 122 } 123 124 public void handleUpdateContactName(String contactName) { 125 CallCard card = getCallCard(); 126 if (card != null) { 127 card.setContactName(contactName); 128 } 129 } 130 131 public void handleUpdateContactImage(Bitmap image) { 132 CallCard card = getCallCard(); 133 if (card != null) { 134 updateContactImage(card, image); 135 } 136 } 137 138 public void handleHangoutMessage(Bitmap contactImage, String contactName) { 139 if (getCardOrNull(MessageCard.class) != null) { 140 return; // Deduplicate. 141 } 142 143 if (contactImage == null) { 144 contactImage = BitmapFactory.decodeResource(getResources(), 145 R.drawable.unknown_contact_480); 146 } 147 MessageCard card = createCard(CardType.HANGOUT); 148 card.setContactName(contactName); 149 150 int c = getResources().getColor(R.color.hangout_background, null); 151 card.setBackgroundColor(c); 152 card.setLeftIcon(BitmapUtils.circleCropBitmap(contactImage)); 153 154 enqueueCard(card); 155 } 156 157 public void handleCallConnected(long connectedTimestamp) { 158 if (DebugUtil.DEBUG) { 159 Log.d(TAG, "handleCallConnected, connectedTimestamp: " + connectedTimestamp); 160 } 161 CallCard card = getCallCard(); 162 if (card != null) { 163 if (DebugUtil.DEBUG) { 164 Log.d(TAG, "handleCallConnected, call status: " + card.getCallStatus()); 165 } 166 167 if (card.getCallStatus() == CallStatus.INCOMING_OR_DIALING) { 168 card.animateCallConnected(connectedTimestamp); 169 } 170 } 171 } 172 173 public void handleCallDisconnected() { 174 final CallCard callCard = getCallCard(); 175 if (callCard != null) { 176 if (DebugUtil.DEBUG) { 177 Log.d(TAG, "handleCallDisconnected, callCard status: " + callCard.getCallStatus()); 178 } 179 180 if (callCard == getCurrentCard() 181 && callCard.getCallStatus() == CallStatus.ACTIVE) { 182 callCard.animateCallDisconnected(); 183 } else { 184 removeCard(callCard); 185 } 186 } 187 } 188 189 public void runDelayed(long delay, final Runnable task) { 190 mHandler.postDelayed(task, delay); 191 } 192 193 public MediaCard createMediaCard(Bitmap albumCover, String title, String subtitle, 194 int appColor) { 195 MediaCard card = createCard(CardType.MEDIA); 196 int iconSize = card.getIconSize(); 197 198 if (albumCover != null) { 199 Bitmap albumIcon = BitmapUtils.scaleBitmap(albumCover, iconSize, iconSize); 200 albumIcon = BitmapUtils.circleCropBitmap(albumIcon); 201 card.setLeftIcon(albumIcon); 202 203 Bitmap backgroundImage = BitmapUtils.scaleBitmapColors(albumCover, 204 getResources().getColor(R.color.media_background_dark, null), 205 getResources().getColor(R.color.media_background, null)); 206 207 backgroundImage = BitmapUtils.scaleBitmap(backgroundImage, 208 (int) (getResources().getDimension(R.dimen.card_width)), 209 (int) getResources().getDimension(R.dimen.card_height)); 210 211 int c = getResources().getColor(R.color.phone_background, null); 212 card.setBackground(backgroundImage, c); 213 } 214 215 Bitmap mediaApp = generateMediaIcon(iconSize, 216 appColor, 217 getResources().getColor(R.color.media_icon_foreground, null)); 218 card.setRightIcon(mediaApp); 219 card.setProgressColor(appColor); 220 card.setTitle(title); 221 card.setSubtitle(subtitle); 222 return card; 223 } 224 225 public CardView getCurrentCard() { 226 return (CardView) mCardPanel.getTopVisibleChild(); 227 } 228 229 public CardView createWeatherCard() { 230 CardView card; 231 card = createCard(CardType.WEATHER); 232 card.setBackgroundColor(getResources().getColor(R.color.weather_blue_sky, null)); 233 return card; 234 } 235 236 public CallCard createIncomingCallCard(Bitmap contactImage, String contactName) { 237 CallCard card = createCard(CardType.PHONE_CALL); 238 updateContactImage(card, contactImage); 239 card.setContactName(contactName); 240 card.setStatusLabel(getContext().getString(R.string.incoming_call)); 241 return card; 242 } 243 244 public CallCard createDialingCallCard(Bitmap contactImage, String contactName) { 245 CallCard card = createCard(CardType.PHONE_CALL); 246 247 updateContactImage(card, contactImage); 248 card.setContactName(contactName); 249 card.setStatusLabel(getContext().getString(R.string.dialing)); 250 return card; 251 } 252 253 public NavCard createNavCard() { 254 return createCard(CardType.NAV); 255 } 256 257 private void updateContactImage(CardView card, Bitmap contactImage) { 258 int iconSize = (int)getResources().getDimension(R.dimen.card_icon_size); 259 Bitmap contactImageCircle = BitmapUtils.circleCropBitmap( 260 BitmapUtils.scaleBitmap(contactImage, iconSize, iconSize)); 261 card.setLeftIcon(contactImageCircle); 262 263 contactImage = BitmapUtils.scaleBitmapColors(contactImage, 264 getResources().getColor(R.color.phone_background_dark, null), 265 getResources().getColor(R.color.phone_background, null)); 266 267 contactImage = BitmapUtils.scaleBitmap(contactImage, 268 (int) getResources().getDimension(R.dimen.card_width), 269 (int) getResources().getDimension(R.dimen.card_height)); 270 271 int c = getResources().getColor(R.color.phone_background, null); 272 card.setBackground(contactImage, c); 273 } 274 275 public boolean cardExists(CardView card) { 276 return mCardPanel.childViewExists(card); 277 } 278 279 public void removeCard(final CardView card) { 280 if (DebugUtil.DEBUG) { 281 Log.d(TAG, "removeCard, card: " + card); 282 } 283 final CardView currentlyShownCard = getCurrentCard(); 284 if (currentlyShownCard == card) { 285 // Card is on the screen, play nice animation and then remove it. 286 mQueue.remove(card); 287 mCardPanel.markViewToBeRemoved(card); 288 CardView cardToShow = mQueue.peek(); 289 if (cardToShow != null) { 290 Animation animation = cardToShow.getAnimation(); 291 if (DebugUtil.DEBUG) { 292 Log.d(TAG, "card to show: " + cardToShow + ", animation: " + animation); 293 } 294 if (animation != null) { 295 cardToShow.getAnimation().cancel(); 296 } 297 298 cardToShow.setVisibility(VISIBLE); 299 mCardPanel.moveChildBehindTheTop(cardToShow); 300 } 301 playUnrevealAnimation(card, new Runnable() { 302 @Override 303 public void run() { 304 removeCardInternal(card); 305 } 306 }); 307 } else { 308 // Card is not on the screen, just remove it. 309 if (DebugUtil.DEBUG) { 310 Log.d(TAG, "removeCard, card is not on the screen, remove it immediately"); 311 removeCardInternal(card); 312 } 313 } 314 } 315 316 public void enqueueCard(final CardView card) { 317 if (DebugUtil.DEBUG) { 318 Log.d(TAG, "enqueueCard, card: " + card); 319 } 320 final CardView currentCard = getCurrentCard(); 321 boolean cardIsOnTheScreen = card == currentCard; 322 323 boolean cardExisted = mQueue.remove(card); 324 mQueue.offer(card); 325 326 CardView activeCard = mQueue.peek(); 327 boolean shouldDisplayCard = activeCard == card; 328 329 if (DebugUtil.DEBUG) { 330 Log.d(TAG, "enqueueCard, card: " + card + ", onScreen: " 331 + cardIsOnTheScreen + ", cardExisted: " + cardExisted + ", shouldDisplayCard: " 332 + shouldDisplayCard + ", activeCard: " + activeCard 333 + ", currentCard: " + currentCard); 334 } 335 336 if (cardIsOnTheScreen) { 337 if (!shouldDisplayCard) { 338 // Card priority was decreased, but it still active. Need to reverse reveal 339 // animation and show underlying card. 340 showCardWithFadeoutAnimation(activeCard); 341 } 342 } else { 343 // Card is not on the screen right now. 344 if (cardExisted) { 345 if (shouldDisplayCard) { 346 // Card was created in the past and is in the queue, need to show 347 // this card using unreveal animation. 348 showCardWithUnrevealAnimation(card); 349 } 350 } else { 351 if (shouldDisplayCard) { 352 // Card doesn't exist, but we want to show it. 353 showCardWithRevealAnimation(card); 354 } else { 355 // We want to add the card to the panel, but do not want to show it. 356 if (DebugUtil.DEBUG) { 357 Log.d(TAG, "Adding hidden card"); 358 } 359 card.setVisibility(GONE); 360 mCardPanel.addView(card, 0); 361 } 362 } 363 } 364 365 removeInvisibleDuplicatedCard(card); // Remove invisible cards with the same card type. 366 367 dumpCardsToLog(); 368 } 369 370 private void showCardWithRevealAnimation(final CardView cardToShow) { 371 if (DebugUtil.DEBUG) { 372 Log.d(TAG, "showCardWithRevealAnimation, card: " + cardToShow); 373 } 374 375 removeInvisibleDuplicatedCard(cardToShow); 376 377 CardView currentCard = getCurrentCard(); 378 mCardPanel.addView(cardToShow); 379 playRevealAnimation(cardToShow, currentCard, new RemoveOrHideCard(this, currentCard)); 380 } 381 382 private void removeInvisibleDuplicatedCard(CardView card) { 383 Log.d(TAG, "removeInvisibleDuplicatedCard, card: " + card); 384 // Remove cards that has the same card type and not visible on the screen. 385 for (int i = mCardPanel.getChildCount() - 1; i >= 0; i--) { 386 CardView child = (CardView) mCardPanel.getChildAt(i); 387 if (child.getCardType() == card.getCardType() 388 && child != card 389 && child.getVisibility() != VISIBLE) { 390 Log.d(TAG, "removeInvisibleDuplicatedCard, found dup: " + child); 391 removeCardInternal(child); 392 } 393 } 394 } 395 396 private void showCardWithFadeoutAnimation(final CardView cardToShow) { 397 if (DebugUtil.DEBUG) { 398 Log.d(TAG, "showCardWithFadeoutAnimation, card: " + cardToShow); 399 } 400 // Place card behind the top card, it will become visible once fade out animation for the 401 // top card starts to play. 402 mCardPanel.moveChildBehindTheTop(cardToShow); 403 cardToShow.setVisibility(VISIBLE); 404 405 // Hide top card with animation. 406 playFadeOutAndSlideOutAnimation((CardView) mCardPanel.getTopVisibleChild()); 407 } 408 409 private void showCardWithUnrevealAnimation(CardView cardToShow) { 410 if (DebugUtil.DEBUG) { 411 Log.d(TAG, "showCardWithUnrevealAnimation, card: " + cardToShow); 412 } 413 final CardView currentCard = (CardView) mCardPanel.getTopVisibleChild(); 414 // Card was created in the past and is in the queue, need to show reverse reveal 415 // animation to unreveal this card. 416 mCardPanel.moveChildBehindTheTop(cardToShow); 417 cardToShow.setVisibility(VISIBLE); 418 playUnrevealAnimation(currentCard, new RemoveOrHideCard(this, currentCard)); 419 } 420 421 private static class RemoveOrHideCard implements Runnable { 422 private final CardView mCard; 423 424 private final ClusterView mClusterView; 425 426 RemoveOrHideCard(ClusterView clusterView, CardView card) { 427 mCard = card; 428 mClusterView = clusterView; 429 } 430 431 @Override 432 public void run() { 433 if (DebugUtil.DEBUG) { 434 Log.d(TAG, "RemoveOrHideCard: " + mCard); 435 } 436 437 mClusterView.removeInvisibleDuplicatedCard(mCard); 438 439 if (mCard.isGarbage()) { 440 if (DebugUtil.DEBUG) { 441 Log.d(TAG, "RemoveOrHideCard, card has garbage priority"); 442 } 443 mClusterView.removeCardInternal(mCard); 444 } else { 445 if (DebugUtil.DEBUG) { 446 Log.d(TAG, "RemoveOrHideCard, hiding card: " + mCard); 447 } 448 mCard.setVisibility(GONE); 449 mCard.setAlpha(1); // Restore alpha after fade-out animation, it's gone anyway. 450 mClusterView.dumpCardsToLog(); 451 } 452 } 453 } 454 455 private void removeCardInternal(CardView card) { 456 if (DebugUtil.DEBUG) { 457 Log.d(TAG, "removeCardInternal, card: " + card); 458 } 459 mCardPanel.removeView(card); 460 mQueue.remove(card); 461 462 dumpCardsToLog(); 463 } 464 465 @Override 466 public void onPriorityChanged(CardView card, int priority) { 467 if (DebugUtil.DEBUG) { 468 Log.d(TAG, "onPriorityChanged, card: " + card + ", priority: " + priority); 469 } 470 if (cardExists(card)) { 471 enqueueCard(card); 472 } 473 } 474 475 private void playRevealAnimation(final CardView cardToShow, final CardView currentCard, 476 final Runnable oldCardDissapearedAction) { 477 478 if (currentCard == cardToShow) { 479 return; 480 } 481 482 if (currentCard != null) { 483 playAlphaAnimation(currentCard, 484 0, // Target alpha 485 400, // Duration 486 new DecelerateInterpolator(0.5f), 487 oldCardDissapearedAction); 488 } 489 490 cardToShow.addOnLayoutChangeListener(new OnLayoutChangeListener() { 491 @Override 492 public void onLayoutChange(View v, int left, int top, int right, int bottom, 493 int oldLeft, int oldTop, int oldRight, int oldBottom) { 494 cardToShow.removeOnLayoutChangeListener(this); // Just need it once. 495 496 createRevealAnimator(cardToShow, new DecelerateInterpolator(1f), true) 497 .start(); 498 } 499 }); 500 cardToShow.onPlayRevealAnimation(); 501 } 502 503 private void playAlphaAnimation(final CardView card, float targetAlpha, long duration, 504 Interpolator interpolator, final Runnable endAction) { 505 Animation animation = new AlphaAnimation(card.getAlpha(), targetAlpha); 506 animation.setDuration(duration * DebugUtil.ANIMATION_FACTOR); 507 animation.setInterpolator(interpolator); 508 509 animation.setAnimationListener(new AnimationListener() { 510 @Override 511 public void onAnimationStart(Animation animation) { } 512 513 @Override 514 public void onAnimationEnd(Animation animation) { 515 // For some reason, cancelled animation hasEnded() == true here. 516 // Check for start time instead. 517 if (endAction != null && animation.getStartTime() != Long.MIN_VALUE) { 518 endAction.run(); 519 } 520 } 521 522 @Override 523 public void onAnimationRepeat(Animation animation) { } 524 }); 525 card.setAnimation(animation); 526 animation.start(); 527 } 528 529 private static boolean isAnimationCancelled(Animation animation) { 530 return (animation.getStartTime() == Long.MIN_VALUE) || (!animation.hasEnded()); 531 } 532 533 private void playFadeOutAndSlideOutAnimation(final CardView card) { 534 Animation animation = new TranslateAnimation(0, card.getWidth(), 0, 0) { 535 private final float mFromAlpha = card.getAlpha(); 536 private final float mToAlpha = 0; 537 538 @Override 539 protected void applyTransformation(float interpolatedTime, Transformation t) { 540 super.applyTransformation(interpolatedTime, t); 541 542 final float alpha = mFromAlpha; 543 t.setAlpha(alpha + ((mToAlpha - alpha) * interpolatedTime)); 544 } 545 }; 546 animation.setDuration(600 * DebugUtil.ANIMATION_FACTOR); 547 animation.setAnimationListener(new AnimationListener() { 548 @Override 549 public void onAnimationStart(Animation animation) { } 550 551 @Override 552 public void onAnimationEnd(Animation animation) { 553 if (DebugUtil.DEBUG) { 554 Log.d(TAG, "playFadeOutAndSlideOutAnimation, onAnimationEnd " + animation 555 + ", startTime: " + animation.getStartTime()); 556 } 557 if (!isAnimationCancelled(animation)) { 558 new RemoveOrHideCard(ClusterView.this, card) 559 .run(); 560 } 561 // Reset X position. 562 card.setTranslationX(0); 563 } 564 565 @Override 566 public void onAnimationRepeat(Animation animation) { } 567 }); 568 card.setAnimation(animation); 569 animation.start(); 570 } 571 572 /** Hides given card and reveals underlying card */ 573 private void playUnrevealAnimation(final CardView card, final Runnable unrevealCompleteAction) { 574 if (DebugUtil.DEBUG) { 575 Log.d(TAG, "playUnrevealAnimation, card: " + card); 576 } 577 578 final Animator anim = createRevealAnimator(card, 579 new AccelerateInterpolator(2f), false /* hide */); 580 581 anim.addListener(new AnimatorListenerAdapter() { 582 private boolean cancelled = false; 583 584 @Override 585 public void onAnimationCancel(Animator animation) { 586 if (DebugUtil.DEBUG) { 587 Log.d(TAG, "onAnimationCancel, animation: " + animation); 588 } 589 cancelled = true; 590 } 591 592 @Override 593 public void onAnimationEnd(Animator animation) { 594 if (cancelled) { 595 return; 596 } 597 598 if (DebugUtil.DEBUG) { 599 Log.d(TAG, "onAnimationEnd, animation: " + animation); 600 } 601 unrevealCompleteAction.run(); 602 } 603 }); 604 605 anim.start(); 606 card.onPlayUnrevealAnimation(); 607 } 608 609 private Animator createRevealAnimator(CardView card, Interpolator interpolator, boolean show) { 610 int cardWidth = (int) getResources().getDimension(R.dimen.card_width); 611 int radius = (int) (cardWidth * 1.2f); 612 int centerY = (int) (getResources().getDimension(R.dimen.card_height) / 2); 613 614 Animator anim = ViewAnimationUtils.createCircularReveal(card, radius, centerY, 615 show ? 0 : radius /* start radius */, 616 show ? radius : 0 /* end radius */); 617 618 anim.setInterpolator(interpolator); 619 anim.setDuration(600 * DebugUtil.ANIMATION_FACTOR); 620 return anim; 621 } 622 623 public <E> E getCardOrNull(Class<E> clazz) { 624 return mCardPanel.getChildOrNull(clazz); 625 } 626 627 public <E> E getVisibleCardOrNull(Class<E> clazz) { 628 return mCardPanel.getVisibleChildOrNull(clazz); 629 } 630 631 private CallCard getCallCard() { 632 return getCardOrNull(CallCard.class); 633 } 634 635 private void dumpCardsToLog() { 636 if (DebugUtil.DEBUG) { 637 Log.d(TAG, "Cards in layout: " + mCardPanel.getChildCount() + ", cards in queue: " 638 + mQueue.size()); 639 for (int i = 0; i < mCardPanel.getChildCount(); i++) { 640 Log.d(TAG, "child: " + mCardPanel.getChildAt(i)); 641 } 642 } 643 } 644 } 645