Home | History | Annotate | Download | only in stopwatch
      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 android.support.annotation.ColorInt;
     30 import android.support.annotation.NonNull;
     31 import android.support.v4.graphics.ColorUtils;
     32 import android.support.v7.widget.LinearLayoutManager;
     33 import android.support.v7.widget.RecyclerView;
     34 import android.support.v7.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. */
    117     public StopwatchFragment() {
    118         super(STOPWATCH);
    119     }
    120 
    121     @Override
    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
    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
    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
    214     public void onDestroyView() {
    215         super.onDestroyView();
    216 
    217         DataModel.getDataModel().removeStopwatchListener(mStopwatchWatcher);
    218     }
    219 
    220     @Override
    221     public void onFabClick(@NonNull ImageView fab) {
    222         toggleStopwatchState();
    223     }
    224 
    225     @Override
    226     public void onLeftButtonClick(@NonNull Button left) {
    227         doReset();
    228     }
    229 
    230     @Override
    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 
    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 
    261     public void onUpdateFab(@NonNull ImageView fab) {
    262         updateFab(fab, false);
    263     }
    264 
    265     @Override
    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
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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 
    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 
    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      */
    457     private void toggleStopwatchState() {
    458         if (getStopwatch().isRunning()) {
    459             doPause();
    460         } else {
    461             doStart();
    462         }
    463     }
    464 
    465     private Stopwatch getStopwatch() {
    466         return DataModel.getDataModel().getStopwatch();
    467     }
    468 
    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      */
    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      */
    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      */
    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      */
    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
    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