1 /* 2 * Copyright (C) 2012 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 package com.android.dreams.phototable; 17 18 import android.content.Context; 19 import android.content.res.Resources; 20 import android.graphics.Bitmap; 21 import android.graphics.BitmapFactory; 22 import android.graphics.PointF; 23 import android.graphics.PorterDuff; 24 import android.graphics.Rect; 25 import android.graphics.drawable.BitmapDrawable; 26 import android.graphics.drawable.Drawable; 27 import android.graphics.drawable.LayerDrawable; 28 import android.os.AsyncTask; 29 import android.service.dreams.DreamService; 30 import android.util.AttributeSet; 31 import android.util.Log; 32 import android.view.KeyEvent; 33 import android.view.LayoutInflater; 34 import android.view.MotionEvent; 35 import android.view.View; 36 import android.view.ViewGroup; 37 import android.view.ViewParent; 38 import android.view.ViewPropertyAnimator; 39 import android.view.animation.DecelerateInterpolator; 40 import android.view.animation.Interpolator; 41 import android.widget.FrameLayout; 42 import android.widget.ImageView; 43 44 import java.util.ArrayList; 45 import java.util.Formatter; 46 import java.util.HashSet; 47 import java.util.LinkedList; 48 import java.util.Random; 49 import java.util.Set; 50 51 /** 52 * A surface where photos sit. 53 */ 54 public class PhotoTable extends FrameLayout { 55 private static final String TAG = "PhotoTable"; 56 private static final boolean DEBUG = false; 57 58 class Launcher implements Runnable { 59 @Override run()60 public void run() { 61 PhotoTable.this.scheduleNext(mDropPeriod); 62 PhotoTable.this.launch(); 63 } 64 } 65 66 class FocusReaper implements Runnable { 67 @Override run()68 public void run() { 69 PhotoTable.this.clearFocus(); 70 } 71 } 72 73 class SelectionReaper implements Runnable { 74 @Override run()75 public void run() { 76 PhotoTable.this.clearSelection(); 77 } 78 } 79 80 private static final int NEXT = 1; 81 private static final int PREV = 0; 82 private static Random sRNG = new Random(); 83 84 private final Launcher mLauncher; 85 private final FocusReaper mFocusReaper; 86 private final SelectionReaper mSelectionReaper; 87 private final LinkedList<View> mOnTable; 88 private final int mDropPeriod; 89 private final int mFastDropPeriod; 90 private final int mNowDropDelay; 91 private final float mImageRatio; 92 private final float mTableRatio; 93 private final float mImageRotationLimit; 94 private final float mThrowRotation; 95 private final float mThrowSpeed; 96 private final boolean mTapToExit; 97 private final int mTableCapacity; 98 private final int mRedealCount; 99 private final int mInset; 100 private final PhotoSource mPhotoSource; 101 private final Resources mResources; 102 private final Interpolator mThrowInterpolator; 103 private final Interpolator mDropInterpolator; 104 private final DragGestureDetector mDragGestureDetector; 105 private final EdgeSwipeDetector mEdgeSwipeDetector; 106 private final KeyboardInterpreter mKeyboardInterpreter; 107 private final boolean mStoryModeEnabled; 108 private final boolean mBackgroudOptimization; 109 private final long mPickUpDuration; 110 private final int mMaxSelectionTime; 111 private final int mMaxFocusTime; 112 private DreamService mDream; 113 private PhotoLaunchTask mPhotoLaunchTask; 114 private LoadNaturalSiblingTask mLoadOnDeckTasks[]; 115 private boolean mStarted; 116 private boolean mIsLandscape; 117 private int mLongSide; 118 private int mShortSide; 119 private int mWidth; 120 private int mHeight; 121 private View mSelection; 122 private View mOnDeck[]; 123 private View mFocus; 124 private int mHighlightColor; 125 private ViewGroup mBackground; 126 private ViewGroup mStageLeft; 127 private View mScrim; 128 private final Set<View> mWaitingToJoinBackground; 129 PhotoTable(Context context, AttributeSet as)130 public PhotoTable(Context context, AttributeSet as) { 131 super(context, as); 132 mResources = getResources(); 133 mInset = mResources.getDimensionPixelSize(R.dimen.photo_inset); 134 mDropPeriod = mResources.getInteger(R.integer.table_drop_period); 135 mFastDropPeriod = mResources.getInteger(R.integer.fast_drop); 136 mNowDropDelay = mResources.getInteger(R.integer.now_drop); 137 mImageRatio = mResources.getInteger(R.integer.image_ratio) / 1000000f; 138 mTableRatio = mResources.getInteger(R.integer.table_ratio) / 1000000f; 139 mImageRotationLimit = (float) mResources.getInteger(R.integer.max_image_rotation); 140 mThrowSpeed = mResources.getDimension(R.dimen.image_throw_speed); 141 mPickUpDuration = mResources.getInteger(R.integer.photo_pickup_duration); 142 mThrowRotation = (float) mResources.getInteger(R.integer.image_throw_rotatioan); 143 mTableCapacity = mResources.getInteger(R.integer.table_capacity); 144 mRedealCount = mResources.getInteger(R.integer.redeal_count); 145 mTapToExit = mResources.getBoolean(R.bool.enable_tap_to_exit); 146 mStoryModeEnabled = mResources.getBoolean(R.bool.enable_story_mode); 147 mBackgroudOptimization = mResources.getBoolean(R.bool.enable_background_optimization); 148 mHighlightColor = mResources.getColor(R.color.highlight_color); 149 mMaxSelectionTime = mResources.getInteger(R.integer.max_selection_time); 150 mMaxFocusTime = mResources.getInteger(R.integer.max_focus_time); 151 mThrowInterpolator = new SoftLandingInterpolator( 152 mResources.getInteger(R.integer.soft_landing_time) / 1000000f, 153 mResources.getInteger(R.integer.soft_landing_distance) / 1000000f); 154 mDropInterpolator = new DecelerateInterpolator( 155 (float) mResources.getInteger(R.integer.drop_deceleration_exponent)); 156 mOnTable = new LinkedList<View>(); 157 mPhotoSource = new PhotoSourcePlexor(getContext(), 158 getContext().getSharedPreferences(PhotoTableDreamSettings.PREFS_NAME, 0)); 159 mWaitingToJoinBackground = new HashSet<View>(); 160 mLauncher = new Launcher(); 161 mFocusReaper = new FocusReaper(); 162 mSelectionReaper = new SelectionReaper(); 163 mDragGestureDetector = new DragGestureDetector(context, this); 164 mEdgeSwipeDetector = new EdgeSwipeDetector(context, this); 165 mKeyboardInterpreter = new KeyboardInterpreter(this); 166 mLoadOnDeckTasks = new LoadNaturalSiblingTask[2]; 167 mOnDeck = new View[2]; 168 mStarted = false; 169 } 170 171 @Override onFinishInflate()172 public void onFinishInflate() { 173 mBackground = (ViewGroup) findViewById(R.id.background); 174 mStageLeft = (ViewGroup) findViewById(R.id.stageleft); 175 mScrim = findViewById(R.id.scrim); 176 } 177 setDream(DreamService dream)178 public void setDream(DreamService dream) { 179 mDream = dream; 180 } 181 hasSelection()182 public boolean hasSelection() { 183 return mSelection != null; 184 } 185 getSelection()186 public View getSelection() { 187 return mSelection; 188 } 189 clearSelection()190 public void clearSelection() { 191 if (hasSelection()) { 192 dropOnTable(mSelection); 193 mPhotoSource.donePaging(getBitmap(mSelection)); 194 if (mStoryModeEnabled) { 195 fadeInBackground(mSelection); 196 } 197 mSelection = null; 198 } 199 for (int slot = 0; slot < mOnDeck.length; slot++) { 200 if (mOnDeck[slot] != null) { 201 fadeAway(mOnDeck[slot], false); 202 mOnDeck[slot] = null; 203 } 204 if (mLoadOnDeckTasks[slot] != null && 205 mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) { 206 mLoadOnDeckTasks[slot].cancel(true); 207 mLoadOnDeckTasks[slot] = null; 208 } 209 } 210 } 211 setSelection(View selected)212 public void setSelection(View selected) { 213 if (selected != null) { 214 clearSelection(); 215 mSelection = selected; 216 promoteSelection(); 217 if (mStoryModeEnabled) { 218 fadeOutBackground(mSelection); 219 } 220 } 221 } 222 selectNext()223 public void selectNext() { 224 if (mStoryModeEnabled) { 225 log("selectNext"); 226 if (hasSelection() && mOnDeck[NEXT] != null) { 227 placeOnDeck(mSelection, PREV); 228 mSelection = mOnDeck[NEXT]; 229 mOnDeck[NEXT] = null; 230 promoteSelection(); 231 } 232 } else { 233 clearSelection(); 234 } 235 } 236 selectPrevious()237 public void selectPrevious() { 238 if (mStoryModeEnabled) { 239 log("selectPrevious"); 240 if (hasSelection() && mOnDeck[PREV] != null) { 241 placeOnDeck(mSelection, NEXT); 242 mSelection = mOnDeck[PREV]; 243 mOnDeck[PREV] = null; 244 promoteSelection(); 245 } 246 } else { 247 clearSelection(); 248 } 249 } 250 promoteSelection()251 private void promoteSelection() { 252 if (hasSelection()) { 253 scheduleSelectionReaper(mMaxSelectionTime); 254 mSelection.animate().cancel(); 255 mSelection.setAlpha(1f); 256 moveToTopOfPile(mSelection); 257 pickUp(mSelection); 258 if (mStoryModeEnabled) { 259 for (int slot = 0; slot < mOnDeck.length; slot++) { 260 if (mLoadOnDeckTasks[slot] != null && 261 mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) { 262 mLoadOnDeckTasks[slot].cancel(true); 263 } 264 if (mOnDeck[slot] == null) { 265 mLoadOnDeckTasks[slot] = new LoadNaturalSiblingTask(slot); 266 mLoadOnDeckTasks[slot].execute(mSelection); 267 } 268 } 269 } 270 } 271 } 272 hasFocus()273 public boolean hasFocus() { 274 return mFocus != null; 275 } 276 getFocus()277 public View getFocus() { 278 return mFocus; 279 } 280 clearFocus()281 public void clearFocus() { 282 if (hasFocus()) { 283 setHighlight(getFocus(), false); 284 } 285 mFocus = null; 286 } 287 setDefaultFocus()288 public void setDefaultFocus() { 289 if (mOnTable.size() > 0) { 290 setFocus(mOnTable.getLast()); 291 } 292 } 293 setFocus(View focus)294 public void setFocus(View focus) { 295 assert(focus != null); 296 clearFocus(); 297 mFocus = focus; 298 moveToTopOfPile(focus); 299 setHighlight(focus, true); 300 scheduleFocusReaper(mMaxFocusTime); 301 } 302 lerp(float a, float b, float f)303 static float lerp(float a, float b, float f) { 304 return (b-a)*f + a; 305 } 306 randfrange(float a, float b)307 static float randfrange(float a, float b) { 308 return lerp(a, b, sRNG.nextFloat()); 309 } 310 randFromCurve(float t, PointF[] v)311 static PointF randFromCurve(float t, PointF[] v) { 312 PointF p = new PointF(); 313 if (v.length == 4 && t >= 0f && t <= 1f) { 314 float a = (float) Math.pow(1f-t, 3f); 315 float b = (float) Math.pow(1f-t, 2f) * t; 316 float c = (1f-t) * (float) Math.pow(t, 2f); 317 float d = (float) Math.pow(t, 3f); 318 319 p.x = a * v[0].x + 3 * b * v[1].x + 3 * c * v[2].x + d * v[3].x; 320 p.y = a * v[0].y + 3 * b * v[1].y + 3 * c * v[2].y + d * v[3].y; 321 } 322 return p; 323 } 324 randMultiDrop(int n, float i, float j, int width, int height)325 private static PointF randMultiDrop(int n, float i, float j, int width, int height) { 326 log("randMultiDrop (%d, %f, %f, %d, %d)", n, i, j, width, height); 327 final float[] cx = {0.3f, 0.3f, 0.5f, 0.7f, 0.7f}; 328 final float[] cy = {0.3f, 0.7f, 0.5f, 0.3f, 0.7f}; 329 n = Math.abs(n); 330 float x = cx[n % cx.length]; 331 float y = cy[n % cx.length]; 332 PointF p = new PointF(); 333 p.x = x * width + 0.05f * width * i; 334 p.y = y * height + 0.05f * height * j; 335 log("randInCenter returning %f, %f", p.x, p.y); 336 return p; 337 } 338 cross(double[] a, double[] b)339 private double cross(double[] a, double[] b) { 340 return a[0] * b[1] - a[1] * b[0]; 341 } 342 norm(double[] a)343 private double norm(double[] a) { 344 return Math.hypot(a[0], a[1]); 345 } 346 getCenter(View photo)347 private double[] getCenter(View photo) { 348 float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue(); 349 float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue(); 350 double[] center = { photo.getX() + width / 2f, 351 - (photo.getY() + height / 2f) }; 352 return center; 353 } 354 moveFocus(View focus, float direction)355 public View moveFocus(View focus, float direction) { 356 return moveFocus(focus, direction, 90f); 357 } 358 moveFocus(View focus, float direction, float angle)359 public View moveFocus(View focus, float direction, float angle) { 360 if (focus == null) { 361 if (mOnTable.size() > 0) { 362 setFocus(mOnTable.getLast()); 363 } 364 } else { 365 final double alpha = Math.toRadians(direction); 366 final double beta = Math.toRadians(Math.min(angle, 180f) / 2f); 367 final double[] left = { Math.sin(alpha - beta), 368 Math.cos(alpha - beta) }; 369 final double[] right = { Math.sin(alpha + beta), 370 Math.cos(alpha + beta) }; 371 final double[] a = getCenter(focus); 372 View bestFocus = null; 373 double bestDistance = Double.MAX_VALUE; 374 for (View candidate: mOnTable) { 375 if (candidate != focus) { 376 final double[] b = getCenter(candidate); 377 final double[] delta = { b[0] - a[0], 378 b[1] - a[1] }; 379 if (cross(delta, left) > 0.0 && cross(delta, right) < 0.0) { 380 final double distance = norm(delta); 381 if (bestDistance > distance) { 382 bestDistance = distance; 383 bestFocus = candidate; 384 } 385 } 386 } 387 } 388 if (bestFocus == null) { 389 if (angle < 180f) { 390 return moveFocus(focus, direction, 180f); 391 } 392 } else { 393 setFocus(bestFocus); 394 } 395 } 396 return getFocus(); 397 } 398 399 @Override onKeyDown(int keyCode, KeyEvent event)400 public boolean onKeyDown(int keyCode, KeyEvent event) { 401 return mKeyboardInterpreter.onKeyDown(keyCode, event); 402 } 403 404 @Override onGenericMotionEvent(MotionEvent event)405 public boolean onGenericMotionEvent(MotionEvent event) { 406 return mEdgeSwipeDetector.onTouchEvent(event) || mDragGestureDetector.onTouchEvent(event); 407 } 408 409 @Override onTouchEvent(MotionEvent event)410 public boolean onTouchEvent(MotionEvent event) { 411 if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { 412 if (hasSelection()) { 413 clearSelection(); 414 } else { 415 if (mTapToExit && mDream != null) { 416 mDream.finish(); 417 } 418 } 419 return true; 420 } 421 return false; 422 } 423 424 @Override onLayout(boolean changed, int left, int top, int right, int bottom)425 public void onLayout(boolean changed, int left, int top, int right, int bottom) { 426 super.onLayout(changed, left, top, right, bottom); 427 log("onLayout (%d, %d, %d, %d)", left, top, right, bottom); 428 429 mHeight = bottom - top; 430 mWidth = right - left; 431 432 mLongSide = (int) (mImageRatio * Math.max(mWidth, mHeight)); 433 mShortSide = (int) (mImageRatio * Math.min(mWidth, mHeight)); 434 435 boolean isLandscape = mWidth > mHeight; 436 if (mIsLandscape != isLandscape) { 437 for (View photo: mOnTable) { 438 if (photo != getSelection()) { 439 dropOnTable(photo); 440 } 441 } 442 if (hasSelection()) { 443 pickUp(getSelection()); 444 for (int slot = 0; slot < mOnDeck.length; slot++) { 445 if (mOnDeck[slot] != null) { 446 placeOnDeck(mOnDeck[slot], slot); 447 } 448 } 449 } 450 mIsLandscape = isLandscape; 451 } 452 start(); 453 } 454 455 @Override isOpaque()456 public boolean isOpaque() { 457 return true; 458 } 459 460 /** Put a nice border on the bitmap. */ applyFrame(final PhotoTable table, final BitmapFactory.Options options, Bitmap decodedPhoto)461 private static View applyFrame(final PhotoTable table, final BitmapFactory.Options options, 462 Bitmap decodedPhoto) { 463 LayoutInflater inflater = (LayoutInflater) table.getContext() 464 .getSystemService(Context.LAYOUT_INFLATER_SERVICE); 465 View photo = inflater.inflate(R.layout.photo, null); 466 ImageView image = (ImageView) photo; 467 Drawable[] layers = new Drawable[2]; 468 int photoWidth = options.outWidth; 469 int photoHeight = options.outHeight; 470 if (decodedPhoto == null || options.outWidth <= 0 || options.outHeight <= 0) { 471 photo = null; 472 } else { 473 decodedPhoto.setHasMipMap(true); 474 layers[0] = new BitmapDrawable(table.mResources, decodedPhoto); 475 layers[1] = table.mResources.getDrawable(R.drawable.frame); 476 LayerDrawable layerList = new LayerDrawable(layers); 477 layerList.setLayerInset(0, table.mInset, table.mInset, 478 table.mInset, table.mInset); 479 image.setImageDrawable(layerList); 480 481 photo.setTag(R.id.photo_width, Integer.valueOf(photoWidth)); 482 photo.setTag(R.id.photo_height, Integer.valueOf(photoHeight)); 483 484 photo.setOnTouchListener(new PhotoTouchListener(table.getContext(), 485 table)); 486 } 487 return photo; 488 } 489 490 private class LoadNaturalSiblingTask extends AsyncTask<View, Void, View> { 491 private final BitmapFactory.Options mOptions; 492 private final int mSlot; 493 private View mParent; 494 LoadNaturalSiblingTask(int slot)495 public LoadNaturalSiblingTask (int slot) { 496 mOptions = new BitmapFactory.Options(); 497 mOptions.inTempStorage = new byte[32768]; 498 mSlot = slot; 499 } 500 501 @Override doInBackground(View... views)502 public View doInBackground(View... views) { 503 log("load natural %s", (mSlot == NEXT ? "next" : "previous")); 504 final PhotoTable table = PhotoTable.this; 505 mParent = views[0]; 506 final Bitmap current = getBitmap(mParent); 507 Bitmap decodedPhoto; 508 if (mSlot == NEXT) { 509 decodedPhoto = table.mPhotoSource.naturalNext(current, 510 mOptions, table.mLongSide, table.mShortSide); 511 } else { 512 decodedPhoto = table.mPhotoSource.naturalPrevious(current, 513 mOptions, table.mLongSide, table.mShortSide); 514 } 515 return applyFrame(PhotoTable.this, mOptions, decodedPhoto); 516 } 517 518 @Override onPostExecute(View photo)519 public void onPostExecute(View photo) { 520 if (photo != null) { 521 if (hasSelection() && getSelection() == mParent) { 522 log("natural %s being rendered", (mSlot == NEXT ? "next" : "previous")); 523 PhotoTable.this.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 524 LayoutParams.WRAP_CONTENT)); 525 PhotoTable.this.mOnDeck[mSlot] = photo; 526 float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue(); 527 float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue(); 528 photo.setX(mSlot == PREV ? -2 * width : mWidth + 2 * width); 529 photo.setY((mHeight - height) / 2); 530 photo.addOnLayoutChangeListener(new OnLayoutChangeListener() { 531 @Override 532 public void onLayoutChange(View v, int left, int top, int right, int bottom, 533 int oldLeft, int oldTop, int oldRight, int oldBottom) { 534 PhotoTable.this.placeOnDeck(v, mSlot); 535 v.removeOnLayoutChangeListener(this); 536 } 537 }); 538 } else { 539 recycle(photo); 540 } 541 } else { 542 log("natural, %s was null!", (mSlot == NEXT ? "next" : "previous")); 543 } 544 } 545 }; 546 547 private class PhotoLaunchTask extends AsyncTask<Void, Void, View> { 548 private final BitmapFactory.Options mOptions; 549 PhotoLaunchTask()550 public PhotoLaunchTask () { 551 mOptions = new BitmapFactory.Options(); 552 mOptions.inTempStorage = new byte[32768]; 553 } 554 555 @Override doInBackground(Void... unused)556 public View doInBackground(Void... unused) { 557 log("load a new photo"); 558 final PhotoTable table = PhotoTable.this; 559 return applyFrame(PhotoTable.this, mOptions, 560 table.mPhotoSource.next(mOptions, 561 table.mLongSide, table.mShortSide)); 562 } 563 564 @Override onPostExecute(View photo)565 public void onPostExecute(View photo) { 566 if (photo != null) { 567 final PhotoTable table = PhotoTable.this; 568 569 table.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 570 LayoutParams.WRAP_CONTENT)); 571 if (table.hasSelection()) { 572 for (int slot = 0; slot < mOnDeck.length; slot++) { 573 if (mOnDeck[slot] != null) { 574 table.moveToTopOfPile(mOnDeck[slot]); 575 } 576 } 577 table.moveToTopOfPile(table.getSelection()); 578 } 579 580 log("drop it"); 581 table.throwOnTable(photo); 582 583 if (mOnTable.size() > mTableCapacity) { 584 int targetSize = Math.max(0, mOnTable.size() - mRedealCount); 585 while (mOnTable.size() > targetSize) { 586 fadeAway(mOnTable.poll(), false); 587 } 588 } 589 590 if(table.mOnTable.size() < table.mTableCapacity) { 591 table.scheduleNext(table.mFastDropPeriod); 592 } 593 } 594 } 595 }; 596 597 /** Bring a new photo onto the table. */ launch()598 public void launch() { 599 log("launching"); 600 setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE); 601 if (!hasSelection()) { 602 log("inflate it"); 603 if (mPhotoLaunchTask == null || 604 mPhotoLaunchTask.getStatus() == AsyncTask.Status.FINISHED) { 605 mPhotoLaunchTask = new PhotoLaunchTask(); 606 mPhotoLaunchTask.execute(); 607 } 608 } 609 } 610 611 /** De-emphasize the other photos on the table. */ fadeOutBackground(final View photo)612 public void fadeOutBackground(final View photo) { 613 resolveBackgroundQueue(); 614 if (mBackgroudOptimization) { 615 mBackground.animate() 616 .withLayer() 617 .setDuration(mPickUpDuration) 618 .alpha(0f); 619 } else { 620 mScrim.setAlpha(0f); 621 mScrim.setVisibility(View.VISIBLE); 622 bringChildToFront(mScrim); 623 bringChildToFront(photo); 624 mScrim.animate() 625 .withLayer() 626 .setDuration(mPickUpDuration) 627 .alpha(1f); 628 } 629 } 630 631 632 /** Return the other photos to foreground status. */ fadeInBackground(final View photo)633 public void fadeInBackground(final View photo) { 634 if (mBackgroudOptimization) { 635 mWaitingToJoinBackground.add(photo); 636 mBackground.animate() 637 .withLayer() 638 .setDuration(mPickUpDuration) 639 .alpha(1f) 640 .withEndAction(new Runnable() { 641 @Override 642 public void run() { 643 resolveBackgroundQueue(); 644 } 645 }); 646 } else { 647 bringChildToFront(mScrim); 648 bringChildToFront(photo); 649 mScrim.animate() 650 .withLayer() 651 .setDuration(mPickUpDuration) 652 .alpha(0f) 653 .withEndAction(new Runnable() { 654 @Override 655 public void run() { 656 mScrim.setVisibility(View.GONE); 657 } 658 }); 659 } 660 } 661 resolveBackgroundQueue()662 private void resolveBackgroundQueue() { 663 for(View photo: mWaitingToJoinBackground) { 664 moveToBackground(photo); 665 } 666 mWaitingToJoinBackground.clear(); 667 } 668 669 /** Dispose of the photo gracefully, in case we can see some of it. */ fadeAway(final View photo, final boolean replace)670 public void fadeAway(final View photo, final boolean replace) { 671 // fade out of view 672 mOnTable.remove(photo); 673 exitStageLeft(photo); 674 photo.setOnTouchListener(null); 675 photo.animate().cancel(); 676 photo.animate() 677 .withLayer() 678 .alpha(0f) 679 .setDuration(mPickUpDuration) 680 .withEndAction(new Runnable() { 681 @Override 682 public void run() { 683 if (photo == getFocus()) { 684 clearFocus(); 685 } 686 mStageLeft.removeView(photo); 687 recycle(photo); 688 if (replace) { 689 scheduleNext(mNowDropDelay); 690 } 691 } 692 }); 693 } 694 695 /** Visually on top, and also freshest, for the purposes of timeouts. */ moveToTopOfPile(View photo)696 public void moveToTopOfPile(View photo) { 697 // make this photo the last to be removed. 698 if (isInBackground(photo)) { 699 mBackground.bringChildToFront(photo); 700 } else { 701 bringChildToFront(photo); 702 } 703 invalidate(); 704 mOnTable.remove(photo); 705 mOnTable.offer(photo); 706 } 707 708 /** On deck is to the left or right of the selected photo. */ placeOnDeck(final View photo, final int slot )709 private void placeOnDeck(final View photo, final int slot ) { 710 if (slot < mOnDeck.length) { 711 if (mOnDeck[slot] != null && mOnDeck[slot] != photo) { 712 fadeAway(mOnDeck[slot], false); 713 } 714 mOnDeck[slot] = photo; 715 float photoWidth = photo.getWidth(); 716 float photoHeight = photo.getHeight(); 717 float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth); 718 719 float x = (getWidth() - photoWidth) / 2f; 720 float y = (getHeight() - photoHeight) / 2f; 721 722 float offset = (((float) mWidth + scale * (photoWidth - 2f * mInset)) / 2f); 723 x += (slot == NEXT? 1f : -1f) * offset; 724 725 photo.animate() 726 .withLayer() 727 .rotation(0f) 728 .rotationY(0f) 729 .scaleX(scale) 730 .scaleY(scale) 731 .x(x) 732 .y(y) 733 .setDuration(mPickUpDuration) 734 .setInterpolator(new DecelerateInterpolator(2f)); 735 } 736 } 737 738 /** Move in response to touch. */ move(final View photo, float x, float y, float a)739 public void move(final View photo, float x, float y, float a) { 740 photo.animate().cancel(); 741 photo.setAlpha(1f); 742 photo.setX((int) x); 743 photo.setY((int) y); 744 photo.setRotation((int) a); 745 } 746 747 /** Wind up off screen, so we can animate in. */ throwOnTable(final View photo)748 private void throwOnTable(final View photo) { 749 mOnTable.offer(photo); 750 log("start offscreen"); 751 photo.setRotation(mThrowRotation); 752 photo.setX(-mLongSide); 753 photo.setY(-mLongSide); 754 755 dropOnTable(photo, mThrowInterpolator); 756 } 757 move(final View photo, float dx, float dy, boolean drop)758 public void move(final View photo, float dx, float dy, boolean drop) { 759 if (photo != null) { 760 final float x = photo.getX() + dx; 761 final float y = photo.getY() + dy; 762 photo.setX(x); 763 photo.setY(y); 764 Log.d(TAG, "[" + photo.getX() + ", " + photo.getY() + "] + (" + dx + "," + dy + ")"); 765 if (drop && photoOffTable(photo)) { 766 fadeAway(photo, true); 767 } 768 } 769 } 770 771 /** Fling with no touch hints, then land off screen. */ fling(final View photo)772 public void fling(final View photo) { 773 final float[] o = { mWidth + mLongSide / 2f, 774 mHeight + mLongSide / 2f }; 775 final float[] a = { photo.getX(), photo.getY() }; 776 final float[] b = { o[0], a[1] + o[0] - a[0] }; 777 final float[] c = { a[0] + o[1] - a[1], o[1] }; 778 float[] delta = { 0f, 0f }; 779 if (Math.hypot(b[0] - a[0], b[1] - a[1]) < Math.hypot(c[0] - a[0], c[1] - a[1])) { 780 delta[0] = b[0] - a[0]; 781 delta[1] = b[1] - a[1]; 782 } else { 783 delta[0] = c[0] - a[0]; 784 delta[1] = c[1] - a[1]; 785 } 786 787 final float dist = (float) Math.hypot(delta[0], delta[1]); 788 final int duration = (int) (1000f * dist / mThrowSpeed); 789 fling(photo, delta[0], delta[1], duration, true); 790 } 791 792 /** Continue dynamically after a fling gesture, possibly off the screen. */ fling(final View photo, float dx, float dy, int duration, boolean spin)793 public void fling(final View photo, float dx, float dy, int duration, boolean spin) { 794 if (photo == getFocus()) { 795 if (moveFocus(photo, 0f) == null) { 796 moveFocus(photo, 180f); 797 } 798 } 799 moveToForeground(photo); 800 ViewPropertyAnimator animator = photo.animate() 801 .withLayer() 802 .xBy(dx) 803 .yBy(dy) 804 .setDuration(duration) 805 .setInterpolator(new DecelerateInterpolator(2f)); 806 807 if (spin) { 808 animator.rotation(mThrowRotation); 809 } 810 811 if (photoOffTable(photo, (int) dx, (int) dy)) { 812 log("fling away"); 813 animator.withEndAction(new Runnable() { 814 @Override 815 public void run() { 816 fadeAway(photo, true); 817 } 818 }); 819 } 820 } photoOffTable(final View photo)821 public boolean photoOffTable(final View photo) { 822 return photoOffTable(photo, 0, 0); 823 } 824 photoOffTable(final View photo, final int dx, final int dy)825 public boolean photoOffTable(final View photo, final int dx, final int dy) { 826 Rect hit = new Rect(); 827 photo.getHitRect(hit); 828 hit.offset(dx, dy); 829 return (hit.bottom < 0f || hit.top > getHeight() || 830 hit.right < 0f || hit.left > getWidth()); 831 } 832 833 /** Animate to a random place and orientation, down on the table (visually small). */ dropOnTable(final View photo)834 public void dropOnTable(final View photo) { 835 dropOnTable(photo, mDropInterpolator); 836 } 837 838 /** Animate to a random place and orientation, down on the table (visually small). */ dropOnTable(final View photo, final Interpolator interpolator)839 public void dropOnTable(final View photo, final Interpolator interpolator) { 840 float angle = randfrange(-mImageRotationLimit, mImageRotationLimit); 841 PointF p = randMultiDrop(sRNG.nextInt(), 842 (float) sRNG.nextGaussian(), (float) sRNG.nextGaussian(), 843 mWidth, mHeight); 844 float x = p.x; 845 float y = p.y; 846 847 log("drop it at %f, %f", x, y); 848 849 float x0 = photo.getX(); 850 float y0 = photo.getY(); 851 852 x -= mLongSide / 2f; 853 y -= mShortSide / 2f; 854 log("fixed offset is %f, %f ", x, y); 855 856 float dx = x - x0; 857 float dy = y - y0; 858 859 float dist = (float) Math.hypot(dx, dy); 860 int duration = (int) (1000f * dist / mThrowSpeed); 861 duration = Math.max(duration, 1000); 862 863 log("animate it"); 864 // toss onto table 865 resolveBackgroundQueue(); 866 photo.animate() 867 .withLayer() 868 .scaleX(mTableRatio / mImageRatio) 869 .scaleY(mTableRatio / mImageRatio) 870 .rotation(angle) 871 .x(x) 872 .y(y) 873 .setDuration(duration) 874 .setInterpolator(interpolator) 875 .withEndAction(new Runnable() { 876 @Override 877 public void run() { 878 mWaitingToJoinBackground.add(photo); 879 } 880 }); 881 } 882 moveToBackground(View photo)883 private void moveToBackground(View photo) { 884 if (mBackgroudOptimization && !isInBackground(photo)) { 885 removeViewFromParent(photo); 886 mBackground.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 887 LayoutParams.WRAP_CONTENT)); 888 } 889 } 890 exitStageLeft(View photo)891 private void exitStageLeft(View photo) { 892 removeViewFromParent(photo); 893 mStageLeft.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 894 LayoutParams.WRAP_CONTENT)); 895 } 896 removeViewFromParent(View photo)897 private void removeViewFromParent(View photo) { 898 ViewParent parent = photo.getParent(); 899 if (parent != null) { // should never be null, just being paranoid 900 ((ViewGroup) parent).removeView(photo); 901 } 902 } 903 moveToForeground(View photo)904 private void moveToForeground(View photo) { 905 if (mBackgroudOptimization && isInBackground(photo)) { 906 mBackground.removeView(photo); 907 addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 908 LayoutParams.WRAP_CONTENT)); 909 } 910 } 911 isInBackground(View photo)912 private boolean isInBackground(View photo) { 913 return mBackgroudOptimization && mBackground.indexOfChild(photo) != -1; 914 } 915 916 /** wrap all orientations to the interval [-180, 180). */ wrapAngle(float angle)917 private float wrapAngle(float angle) { 918 float result = angle + 180; 919 result = ((result % 360) + 360) % 360; // catch negative numbers 920 result -= 180; 921 return result; 922 } 923 924 /** Animate the selected photo to the foreground: zooming in to bring it forward. */ pickUp(final View photo)925 private void pickUp(final View photo) { 926 float photoWidth = photo.getWidth(); 927 float photoHeight = photo.getHeight(); 928 929 float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth); 930 931 log("scale is %f", scale); 932 log("target it"); 933 float x = (getWidth() - photoWidth) / 2f; 934 float y = (getHeight() - photoHeight) / 2f; 935 936 photo.setRotation(wrapAngle(photo.getRotation())); 937 938 log("animate it"); 939 // lift up to the glass for a good look 940 mWaitingToJoinBackground.remove(photo); 941 moveToForeground(photo); 942 photo.animate() 943 .withLayer() 944 .rotation(0f) 945 .rotationY(0f) 946 .alpha(1f) 947 .scaleX(scale) 948 .scaleY(scale) 949 .x(x) 950 .y(y) 951 .setDuration(mPickUpDuration) 952 .setInterpolator(new DecelerateInterpolator(2f)) 953 .withEndAction(new Runnable() { 954 @Override 955 public void run() { 956 log("endtimes: %f", photo.getX()); 957 } 958 }); 959 } 960 getBitmap(View photo)961 private Bitmap getBitmap(View photo) { 962 if (photo == null) { 963 return null; 964 } 965 ImageView image = (ImageView) photo; 966 LayerDrawable layers = (LayerDrawable) image.getDrawable(); 967 if (layers == null) { 968 return null; 969 } 970 BitmapDrawable bitmap = (BitmapDrawable) layers.getDrawable(0); 971 if (bitmap == null) { 972 return null; 973 } 974 return bitmap.getBitmap(); 975 } 976 recycle(View photo)977 private void recycle(View photo) { 978 if (photo != null) { 979 removeViewFromParent(photo); 980 mPhotoSource.recycle(getBitmap(photo)); 981 } 982 } 983 setHighlight(View photo, boolean highlighted)984 public void setHighlight(View photo, boolean highlighted) { 985 ImageView image = (ImageView) photo; 986 LayerDrawable layers = (LayerDrawable) image.getDrawable(); 987 if (highlighted) { 988 layers.getDrawable(1).setColorFilter(mHighlightColor, PorterDuff.Mode.SRC_IN); 989 } else { 990 layers.getDrawable(1).clearColorFilter(); 991 } 992 } 993 994 /** Schedule the first launch. Idempotent. */ start()995 public void start() { 996 if (!mStarted) { 997 log("kick it"); 998 mStarted = true; 999 scheduleNext(0); 1000 } 1001 } 1002 refreshSelection()1003 public void refreshSelection() { 1004 scheduleSelectionReaper(mMaxFocusTime); 1005 } 1006 scheduleSelectionReaper(int delay)1007 public void scheduleSelectionReaper(int delay) { 1008 removeCallbacks(mSelectionReaper); 1009 postDelayed(mSelectionReaper, delay); 1010 } 1011 refreshFocus()1012 public void refreshFocus() { 1013 scheduleFocusReaper(mMaxFocusTime); 1014 } 1015 scheduleFocusReaper(int delay)1016 public void scheduleFocusReaper(int delay) { 1017 removeCallbacks(mFocusReaper); 1018 postDelayed(mFocusReaper, delay); 1019 } 1020 scheduleNext(int delay)1021 public void scheduleNext(int delay) { 1022 removeCallbacks(mLauncher); 1023 postDelayed(mLauncher, delay); 1024 } 1025 log(String message, Object... args)1026 private static void log(String message, Object... args) { 1027 if (DEBUG) { 1028 Formatter formatter = new Formatter(); 1029 formatter.format(message, args); 1030 Log.i(TAG, formatter.toString()); 1031 } 1032 } 1033 } 1034