1 /* 2 * Copyright (C) 2015 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.deskclock.stopwatch; 18 19 import android.annotation.SuppressLint; 20 import android.app.Activity; 21 import android.content.ActivityNotFoundException; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.res.ColorStateList; 25 import android.content.res.Resources; 26 import android.graphics.Canvas; 27 import android.graphics.drawable.GradientDrawable; 28 import android.os.Bundle; 29 import androidx.annotation.ColorInt; 30 import androidx.annotation.NonNull; 31 import androidx.core.graphics.ColorUtils; 32 import androidx.recyclerview.widget.LinearLayoutManager; 33 import androidx.recyclerview.widget.RecyclerView; 34 import androidx.recyclerview.widget.SimpleItemAnimator; 35 import android.transition.TransitionManager; 36 import android.view.LayoutInflater; 37 import android.view.MotionEvent; 38 import android.view.View; 39 import android.view.ViewGroup; 40 import android.view.WindowManager; 41 import android.widget.Button; 42 import android.widget.ImageView; 43 import android.widget.TextView; 44 45 import com.android.deskclock.AnimatorUtils; 46 import com.android.deskclock.DeskClockFragment; 47 import com.android.deskclock.LogUtils; 48 import com.android.deskclock.R; 49 import com.android.deskclock.StopwatchTextController; 50 import com.android.deskclock.ThemeUtils; 51 import com.android.deskclock.Utils; 52 import com.android.deskclock.data.DataModel; 53 import com.android.deskclock.data.Lap; 54 import com.android.deskclock.data.Stopwatch; 55 import com.android.deskclock.data.StopwatchListener; 56 import com.android.deskclock.events.Events; 57 import com.android.deskclock.uidata.TabListener; 58 import com.android.deskclock.uidata.UiDataModel; 59 import com.android.deskclock.uidata.UiDataModel.Tab; 60 61 import static android.R.attr.state_activated; 62 import static android.R.attr.state_pressed; 63 import static android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM; 64 import static android.view.View.GONE; 65 import static android.view.View.INVISIBLE; 66 import static android.view.View.VISIBLE; 67 import static com.android.deskclock.uidata.UiDataModel.Tab.STOPWATCH; 68 69 /** 70 * Fragment that shows the stopwatch and recorded laps. 71 */ 72 public final class StopwatchFragment extends DeskClockFragment { 73 74 /** Milliseconds between redraws while running. */ 75 private static final int REDRAW_PERIOD_RUNNING = 25; 76 77 /** Milliseconds between redraws while paused. */ 78 private static final int REDRAW_PERIOD_PAUSED = 500; 79 80 /** Keep the screen on when this tab is selected. */ 81 private final TabListener mTabWatcher = new TabWatcher(); 82 83 /** Scheduled to update the stopwatch time and current lap time while stopwatch is running. */ 84 private final Runnable mTimeUpdateRunnable = new TimeUpdateRunnable(); 85 86 /** Updates the user interface in response to stopwatch changes. */ 87 private final StopwatchListener mStopwatchWatcher = new StopwatchWatcher(); 88 89 /** Draws a gradient over the bottom of the {@link #mLapsList} to reduce clash with the fab. */ 90 private GradientItemDecoration mGradientItemDecoration; 91 92 /** The data source for {@link #mLapsList}. */ 93 private LapsAdapter mLapsAdapter; 94 95 /** The layout manager for the {@link #mLapsAdapter}. */ 96 private LinearLayoutManager mLapsLayoutManager; 97 98 /** Draws the reference lap while the stopwatch is running. */ 99 private StopwatchCircleView mTime; 100 101 /** The View containing both TextViews of the stopwatch. */ 102 private View mStopwatchWrapper; 103 104 /** Displays the recorded lap times. */ 105 private RecyclerView mLapsList; 106 107 /** Displays the current stopwatch time (seconds and above only). */ 108 private TextView mMainTimeText; 109 110 /** Displays the current stopwatch time (hundredths only). */ 111 private TextView mHundredthsTimeText; 112 113 /** Formats and displays the text in the stopwatch. */ 114 private StopwatchTextController mStopwatchTextController; 115 116 /** The public no-arg constructor required by all fragments. */ StopwatchFragment()117 public StopwatchFragment() { 118 super(STOPWATCH); 119 } 120 121 @Override onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state)122 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state) { 123 mLapsAdapter = new LapsAdapter(getActivity()); 124 mLapsLayoutManager = new LinearLayoutManager(getActivity()); 125 mGradientItemDecoration = new GradientItemDecoration(getActivity()); 126 127 final View v = inflater.inflate(R.layout.stopwatch_fragment, container, false); 128 mTime = (StopwatchCircleView) v.findViewById(R.id.stopwatch_circle); 129 mLapsList = (RecyclerView) v.findViewById(R.id.laps_list); 130 ((SimpleItemAnimator) mLapsList.getItemAnimator()).setSupportsChangeAnimations(false); 131 mLapsList.setLayoutManager(mLapsLayoutManager); 132 mLapsList.addItemDecoration(mGradientItemDecoration); 133 134 // In landscape layouts, the laps list can reach the top of the screen and thus can cause 135 // a drop shadow to appear. The same is not true for portrait landscapes. 136 if (Utils.isLandscape(getActivity())) { 137 final ScrollPositionWatcher scrollPositionWatcher = new ScrollPositionWatcher(); 138 mLapsList.addOnLayoutChangeListener(scrollPositionWatcher); 139 mLapsList.addOnScrollListener(scrollPositionWatcher); 140 } else { 141 setTabScrolledToTop(true); 142 } 143 mLapsList.setAdapter(mLapsAdapter); 144 145 // Timer text serves as a virtual start/stop button. 146 mMainTimeText = (TextView) v.findViewById(R.id.stopwatch_time_text); 147 mHundredthsTimeText = (TextView) v.findViewById(R.id.stopwatch_hundredths_text); 148 mStopwatchTextController = new StopwatchTextController(mMainTimeText, mHundredthsTimeText); 149 mStopwatchWrapper = v.findViewById(R.id.stopwatch_time_wrapper); 150 151 DataModel.getDataModel().addStopwatchListener(mStopwatchWatcher); 152 153 mStopwatchWrapper.setOnClickListener(new TimeClickListener()); 154 if (mTime != null) { 155 mStopwatchWrapper.setOnTouchListener(new CircleTouchListener()); 156 } 157 158 final Context c = mMainTimeText.getContext(); 159 final int colorAccent = ThemeUtils.resolveColor(c, R.attr.colorAccent); 160 final int textColorPrimary = ThemeUtils.resolveColor(c, android.R.attr.textColorPrimary); 161 final ColorStateList timeTextColor = new ColorStateList( 162 new int[][] { { -state_activated, -state_pressed }, {} }, 163 new int[] { textColorPrimary, colorAccent }); 164 mMainTimeText.setTextColor(timeTextColor); 165 mHundredthsTimeText.setTextColor(timeTextColor); 166 167 return v; 168 } 169 170 @Override onStart()171 public void onStart() { 172 super.onStart(); 173 174 final Activity activity = getActivity(); 175 final Intent intent = activity.getIntent(); 176 if (intent != null) { 177 final String action = intent.getAction(); 178 if (StopwatchService.ACTION_START_STOPWATCH.equals(action)) { 179 DataModel.getDataModel().startStopwatch(); 180 // Consume the intent 181 activity.setIntent(null); 182 } else if (StopwatchService.ACTION_PAUSE_STOPWATCH.equals(action)) { 183 DataModel.getDataModel().pauseStopwatch(); 184 // Consume the intent 185 activity.setIntent(null); 186 } 187 } 188 189 // Conservatively assume the data in the adapter has changed while the fragment was paused. 190 mLapsAdapter.notifyDataSetChanged(); 191 192 // Synchronize the user interface with the data model. 193 updateUI(FAB_AND_BUTTONS_IMMEDIATE); 194 195 // Start watching for page changes away from this fragment. 196 UiDataModel.getUiDataModel().addTabListener(mTabWatcher); 197 } 198 199 @Override onStop()200 public void onStop() { 201 super.onStop(); 202 203 // Stop all updates while the fragment is not visible. 204 stopUpdatingTime(); 205 206 // Stop watching for page changes away from this fragment. 207 UiDataModel.getUiDataModel().removeTabListener(mTabWatcher); 208 209 // Release the wake lock if it is currently held. 210 releaseWakeLock(); 211 } 212 213 @Override onDestroyView()214 public void onDestroyView() { 215 super.onDestroyView(); 216 217 DataModel.getDataModel().removeStopwatchListener(mStopwatchWatcher); 218 } 219 220 @Override onFabClick(@onNull ImageView fab)221 public void onFabClick(@NonNull ImageView fab) { 222 toggleStopwatchState(); 223 } 224 225 @Override onLeftButtonClick(@onNull Button left)226 public void onLeftButtonClick(@NonNull Button left) { 227 doReset(); 228 } 229 230 @Override onRightButtonClick(@onNull Button right)231 public void onRightButtonClick(@NonNull Button right) { 232 switch (getStopwatch().getState()) { 233 case RUNNING: 234 doAddLap(); 235 break; 236 case PAUSED: 237 doShare(); 238 break; 239 } 240 } 241 updateFab(@onNull ImageView fab, boolean animate)242 private void updateFab(@NonNull ImageView fab, boolean animate) { 243 if (getStopwatch().isRunning()) { 244 if (animate) { 245 fab.setImageResource(R.drawable.ic_play_pause_animation); 246 } else { 247 fab.setImageResource(R.drawable.ic_play_pause); 248 } 249 fab.setContentDescription(fab.getResources().getString(R.string.sw_pause_button)); 250 } else { 251 if (animate) { 252 fab.setImageResource(R.drawable.ic_pause_play_animation); 253 } else { 254 fab.setImageResource(R.drawable.ic_pause_play); 255 } 256 fab.setContentDescription(fab.getResources().getString(R.string.sw_start_button)); 257 } 258 fab.setVisibility(VISIBLE); 259 } 260 onUpdateFab(@onNull ImageView fab)261 public void onUpdateFab(@NonNull ImageView fab) { 262 updateFab(fab, false); 263 } 264 265 @Override onMorphFab(@onNull ImageView fab)266 public void onMorphFab(@NonNull ImageView fab) { 267 // Update the fab's drawable to match the current timer state. 268 updateFab(fab, Utils.isNOrLater()); 269 // Animate the drawable. 270 AnimatorUtils.startDrawableAnimation(fab); 271 } 272 273 @Override onUpdateFabButtons(@onNull Button left, @NonNull Button right)274 public void onUpdateFabButtons(@NonNull Button left, @NonNull Button right) { 275 final Resources resources = getResources(); 276 left.setClickable(true); 277 left.setText(R.string.sw_reset_button); 278 left.setContentDescription(resources.getString(R.string.sw_reset_button)); 279 280 switch (getStopwatch().getState()) { 281 case RESET: 282 left.setVisibility(INVISIBLE); 283 right.setClickable(true); 284 right.setVisibility(INVISIBLE); 285 break; 286 case RUNNING: 287 left.setVisibility(VISIBLE); 288 final boolean canRecordLaps = canRecordMoreLaps(); 289 right.setText(R.string.sw_lap_button); 290 right.setContentDescription(resources.getString(R.string.sw_lap_button)); 291 right.setClickable(canRecordLaps); 292 right.setVisibility(canRecordLaps ? VISIBLE : INVISIBLE); 293 break; 294 case PAUSED: 295 left.setVisibility(VISIBLE); 296 right.setClickable(true); 297 right.setVisibility(VISIBLE); 298 right.setText(R.string.sw_share_button); 299 right.setContentDescription(resources.getString(R.string.sw_share_button)); 300 break; 301 } 302 } 303 304 /** 305 * @param color the newly installed app window color 306 */ onAppColorChanged(@olorInt int color)307 protected void onAppColorChanged(@ColorInt int color) { 308 if (mGradientItemDecoration != null) { 309 mGradientItemDecoration.updateGradientColors(color); 310 } 311 if (mLapsList != null) { 312 mLapsList.invalidateItemDecorations(); 313 } 314 } 315 316 /** 317 * Start the stopwatch. 318 */ doStart()319 private void doStart() { 320 Events.sendStopwatchEvent(R.string.action_start, R.string.label_deskclock); 321 DataModel.getDataModel().startStopwatch(); 322 } 323 324 /** 325 * Pause the stopwatch. 326 */ doPause()327 private void doPause() { 328 Events.sendStopwatchEvent(R.string.action_pause, R.string.label_deskclock); 329 DataModel.getDataModel().pauseStopwatch(); 330 } 331 332 /** 333 * Reset the stopwatch. 334 */ doReset()335 private void doReset() { 336 final Stopwatch.State priorState = getStopwatch().getState(); 337 Events.sendStopwatchEvent(R.string.action_reset, R.string.label_deskclock); 338 DataModel.getDataModel().resetStopwatch(); 339 mMainTimeText.setAlpha(1f); 340 mHundredthsTimeText.setAlpha(1f); 341 if (priorState == Stopwatch.State.RUNNING) { 342 updateFab(FAB_MORPH); 343 } 344 } 345 346 /** 347 * Send stopwatch time and lap times to an external sharing application. 348 */ doShare()349 private void doShare() { 350 // Disable the fab buttons to avoid double-taps on the share button. 351 updateFab(BUTTONS_DISABLE); 352 353 final String[] subjects = getResources().getStringArray(R.array.sw_share_strings); 354 final String subject = subjects[(int) (Math.random() * subjects.length)]; 355 final String text = mLapsAdapter.getShareText(); 356 357 @SuppressLint("InlinedApi") 358 @SuppressWarnings("deprecation") 359 final Intent shareIntent = new Intent(Intent.ACTION_SEND) 360 .addFlags(Utils.isLOrLater() ? Intent.FLAG_ACTIVITY_NEW_DOCUMENT 361 : Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET) 362 .putExtra(Intent.EXTRA_SUBJECT, subject) 363 .putExtra(Intent.EXTRA_TEXT, text) 364 .setType("text/plain"); 365 366 final Context context = getActivity(); 367 final String title = context.getString(R.string.sw_share_button); 368 final Intent shareChooserIntent = Intent.createChooser(shareIntent, title); 369 try { 370 context.startActivity(shareChooserIntent); 371 } catch (ActivityNotFoundException anfe) { 372 LogUtils.e("Cannot share lap data because no suitable receiving Activity exists"); 373 updateFab(BUTTONS_IMMEDIATE); 374 } 375 } 376 377 /** 378 * Record and add a new lap ending now. 379 */ doAddLap()380 private void doAddLap() { 381 Events.sendStopwatchEvent(R.string.action_lap, R.string.label_deskclock); 382 383 // Record a new lap. 384 final Lap lap = mLapsAdapter.addLap(); 385 if (lap == null) { 386 return; 387 } 388 389 // Update button states. 390 updateFab(BUTTONS_IMMEDIATE); 391 392 if (lap.getLapNumber() == 1) { 393 // Child views from prior lap sets hang around and blit to the screen when adding the 394 // first lap of the subsequent lap set. Remove those superfluous children here manually 395 // to ensure they aren't seen as the first lap is drawn. 396 mLapsList.removeAllViewsInLayout(); 397 398 if (mTime != null) { 399 // Start animating the reference lap. 400 mTime.update(); 401 } 402 403 // Recording the first lap transitions the UI to display the laps list. 404 showOrHideLaps(false); 405 } 406 407 // Ensure the newly added lap is visible on screen. 408 mLapsList.scrollToPosition(0); 409 } 410 411 /** 412 * Show or hide the list of laps. 413 */ showOrHideLaps(boolean clearLaps)414 private void showOrHideLaps(boolean clearLaps) { 415 final ViewGroup sceneRoot = (ViewGroup) getView(); 416 if (sceneRoot == null) { 417 return; 418 } 419 420 TransitionManager.beginDelayedTransition(sceneRoot); 421 422 if (clearLaps) { 423 mLapsAdapter.clearLaps(); 424 } 425 426 final boolean lapsVisible = mLapsAdapter.getItemCount() > 0; 427 mLapsList.setVisibility(lapsVisible ? VISIBLE : GONE); 428 429 if (Utils.isPortrait(getActivity())) { 430 // When the lap list is visible, it includes the bottom padding. When it is absent the 431 // appropriate bottom padding must be applied to the container. 432 final Resources res = getResources(); 433 final int bottom = lapsVisible ? 0 : res.getDimensionPixelSize(R.dimen.fab_height); 434 final int top = sceneRoot.getPaddingTop(); 435 final int left = sceneRoot.getPaddingLeft(); 436 final int right = sceneRoot.getPaddingRight(); 437 sceneRoot.setPadding(left, top, right, bottom); 438 } 439 } 440 adjustWakeLock()441 private void adjustWakeLock() { 442 final boolean appInForeground = DataModel.getDataModel().isApplicationInForeground(); 443 if (getStopwatch().isRunning() && isTabSelected() && appInForeground) { 444 getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); 445 } else { 446 releaseWakeLock(); 447 } 448 } 449 releaseWakeLock()450 private void releaseWakeLock() { 451 getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); 452 } 453 454 /** 455 * Either pause or start the stopwatch based on its current state. 456 */ toggleStopwatchState()457 private void toggleStopwatchState() { 458 if (getStopwatch().isRunning()) { 459 doPause(); 460 } else { 461 doStart(); 462 } 463 } 464 getStopwatch()465 private Stopwatch getStopwatch() { 466 return DataModel.getDataModel().getStopwatch(); 467 } 468 canRecordMoreLaps()469 private boolean canRecordMoreLaps() { 470 return DataModel.getDataModel().canAddMoreLaps(); 471 } 472 473 /** 474 * Post the first runnable to update times within the UI. It will reschedule itself as needed. 475 */ startUpdatingTime()476 private void startUpdatingTime() { 477 // Ensure only one copy of the runnable is ever scheduled by first stopping updates. 478 stopUpdatingTime(); 479 mMainTimeText.post(mTimeUpdateRunnable); 480 } 481 482 /** 483 * Remove the runnable that updates times within the UI. 484 */ stopUpdatingTime()485 private void stopUpdatingTime() { 486 mMainTimeText.removeCallbacks(mTimeUpdateRunnable); 487 } 488 489 /** 490 * Update all time displays based on a single snapshot of the stopwatch progress. This includes 491 * the stopwatch time drawn in the circle, the current lap time and the total elapsed time in 492 * the list of laps. 493 */ updateTime()494 private void updateTime() { 495 // Compute the total time of the stopwatch. 496 final Stopwatch stopwatch = getStopwatch(); 497 final long totalTime = stopwatch.getTotalTime(); 498 mStopwatchTextController.setTimeString(totalTime); 499 500 // Update the current lap. 501 final boolean currentLapIsVisible = mLapsLayoutManager.findFirstVisibleItemPosition() == 0; 502 if (!stopwatch.isReset() && currentLapIsVisible) { 503 mLapsAdapter.updateCurrentLap(mLapsList, totalTime); 504 } 505 } 506 507 /** 508 * Synchronize the UI state with the model data. 509 */ updateUI(@pdateFabFlag int updateTypes)510 private void updateUI(@UpdateFabFlag int updateTypes) { 511 adjustWakeLock(); 512 513 // Draw the latest stopwatch and current lap times. 514 updateTime(); 515 516 if (mTime != null) { 517 mTime.update(); 518 } 519 520 final Stopwatch stopwatch = getStopwatch(); 521 if (!stopwatch.isReset()) { 522 startUpdatingTime(); 523 } 524 525 // Adjust the visibility of the list of laps. 526 showOrHideLaps(stopwatch.isReset()); 527 528 // Update button states. 529 updateFab(updateTypes); 530 } 531 532 /** 533 * This runnable periodically updates times throughout the UI. It stops these updates when the 534 * stopwatch is no longer running. 535 */ 536 private final class TimeUpdateRunnable implements Runnable { 537 @Override run()538 public void run() { 539 final long startTime = Utils.now(); 540 541 updateTime(); 542 543 // Blink text iff the stopwatch is paused and not pressed. 544 final View touchTarget = mTime != null ? mTime : mStopwatchWrapper; 545 final Stopwatch stopwatch = getStopwatch(); 546 final boolean blink = stopwatch.isPaused() 547 && startTime % 1000 < 500 548 && !touchTarget.isPressed(); 549 550 if (blink) { 551 mMainTimeText.setAlpha(0f); 552 mHundredthsTimeText.setAlpha(0f); 553 } else { 554 mMainTimeText.setAlpha(1f); 555 mHundredthsTimeText.setAlpha(1f); 556 } 557 558 if (!stopwatch.isReset()) { 559 final long period = stopwatch.isPaused() 560 ? REDRAW_PERIOD_PAUSED 561 : REDRAW_PERIOD_RUNNING; 562 final long endTime = Utils.now(); 563 final long delay = Math.max(0, startTime + period - endTime); 564 mMainTimeText.postDelayed(this, delay); 565 } 566 } 567 } 568 569 /** 570 * Acquire or release the wake lock based on the tab state. 571 */ 572 private final class TabWatcher implements TabListener { 573 @Override 574 public void selectedTabChanged(Tab oldSelectedTab, Tab newSelectedTab) { 575 adjustWakeLock(); 576 } 577 } 578 579 /** 580 * Update the user interface in response to a stopwatch change. 581 */ 582 private class StopwatchWatcher implements StopwatchListener { 583 @Override 584 public void stopwatchUpdated(Stopwatch before, Stopwatch after) { 585 if (after.isReset()) { 586 // Ensure the drop shadow is hidden when the stopwatch is reset. 587 setTabScrolledToTop(true); 588 if (DataModel.getDataModel().isApplicationInForeground()) { 589 updateUI(BUTTONS_IMMEDIATE); 590 } 591 return; 592 } 593 if (DataModel.getDataModel().isApplicationInForeground()) { 594 updateUI(FAB_MORPH | BUTTONS_IMMEDIATE); 595 } 596 } 597 598 @Override 599 public void lapAdded(Lap lap) { 600 } 601 } 602 603 /** 604 * Toggles stopwatch state when user taps stopwatch. 605 */ 606 private final class TimeClickListener implements View.OnClickListener { 607 @Override 608 public void onClick(View view) { 609 if (getStopwatch().isRunning()) { 610 DataModel.getDataModel().pauseStopwatch(); 611 } else { 612 DataModel.getDataModel().startStopwatch(); 613 } 614 } 615 } 616 617 /** 618 * Checks if the user is pressing inside of the stopwatch circle. 619 */ 620 private final class CircleTouchListener implements View.OnTouchListener { 621 @Override 622 public boolean onTouch(View view, MotionEvent event) { 623 final int actionMasked = event.getActionMasked(); 624 if (actionMasked != MotionEvent.ACTION_DOWN) { 625 return false; 626 } 627 final float rX = view.getWidth() / 2f; 628 final float rY = (view.getHeight() - view.getPaddingBottom()) / 2f; 629 final float r = Math.min(rX, rY); 630 631 final float x = event.getX() - rX; 632 final float y = event.getY() - rY; 633 634 final boolean inCircle = Math.pow(x / r, 2.0) + Math.pow(y / r, 2.0) <= 1.0; 635 636 // Consume the event if it is outside the circle 637 return !inCircle; 638 } 639 } 640 641 /** 642 * Updates the vertical scroll state of this tab in the {@link UiDataModel} as the user scrolls 643 * the recyclerview or when the size/position of elements within the recyclerview changes. 644 */ 645 private final class ScrollPositionWatcher extends RecyclerView.OnScrollListener 646 implements View.OnLayoutChangeListener { 647 @Override 648 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 649 setTabScrolledToTop(Utils.isScrolledToTop(mLapsList)); 650 } 651 652 @Override 653 public void onLayoutChange(View v, int left, int top, int right, int bottom, 654 int oldLeft, int oldTop, int oldRight, int oldBottom) { 655 setTabScrolledToTop(Utils.isScrolledToTop(mLapsList)); 656 } 657 } 658 659 /** 660 * Draws a tinting gradient over the bottom of the stopwatch laps list. This reduces the 661 * contrast between floating buttons and the laps list content. 662 */ 663 private static final class GradientItemDecoration extends RecyclerView.ItemDecoration { 664 665 // 0% - 25% of gradient length -> opacity changes from 0% to 50% 666 // 25% - 90% of gradient length -> opacity changes from 50% to 100% 667 // 90% - 100% of gradient length -> opacity remains at 100% 668 private static final int[] ALPHAS = { 669 0x00, // 0% 670 0x1A, // 10% 671 0x33, // 20% 672 0x4D, // 30% 673 0x66, // 40% 674 0x80, // 50% 675 0x89, // 53.8% 676 0x93, // 57.6% 677 0x9D, // 61.5% 678 0xA7, // 65.3% 679 0xB1, // 69.2% 680 0xBA, // 73.0% 681 0xC4, // 76.9% 682 0xCE, // 80.7% 683 0xD8, // 84.6% 684 0xE2, // 88.4% 685 0xEB, // 92.3% 686 0xF5, // 96.1% 687 0xFF, // 100% 688 0xFF, // 100% 689 0xFF, // 100% 690 }; 691 692 /** 693 * A reusable array of control point colors that define the gradient. It is based on the 694 * background color of the window and thus recomputed each time that color is changed. 695 */ 696 private final int[] mGradientColors = new int[ALPHAS.length]; 697 698 /** The drawable that produces the tinting gradient effect of this decoration. */ 699 private final GradientDrawable mGradient = new GradientDrawable(); 700 701 /** The height of the gradient; sized relative to the fab height. */ 702 private final int mGradientHeight; 703 704 GradientItemDecoration(Context context) { 705 mGradient.setOrientation(TOP_BOTTOM); 706 updateGradientColors(ThemeUtils.resolveColor(context, android.R.attr.windowBackground)); 707 708 final Resources resources = context.getResources(); 709 final float fabHeight = resources.getDimensionPixelSize(R.dimen.fab_height); 710 mGradientHeight = Math.round(fabHeight * 1.2f); 711 } 712 713 @Override 714 public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { 715 super.onDrawOver(c, parent, state); 716 717 final int w = parent.getWidth(); 718 final int h = parent.getHeight(); 719 720 mGradient.setBounds(0, h - mGradientHeight, w, h); 721 mGradient.draw(c); 722 } 723 724 /** 725 * Given a {@code baseColor}, compute a gradient of tinted colors that define the fade 726 * effect to apply to the bottom of the lap list. 727 * 728 * @param baseColor a base color to which the gradient tint should be applied 729 */ 730 void updateGradientColors(@ColorInt int baseColor) { 731 // Compute the tinted colors that form the gradient. 732 for (int i = 0; i < mGradientColors.length; i++) { 733 mGradientColors[i] = ColorUtils.setAlphaComponent(baseColor, ALPHAS[i]); 734 } 735 736 // Set the gradient colors into the drawable. 737 mGradient.setColors(mGradientColors); 738 } 739 } 740 } 741