Home | History | Annotate | Download | only in time
      1 /*
      2  * Copyright (C) 2013 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.datetimepicker.time;
     18 
     19 import android.animation.AnimatorSet;
     20 import android.animation.ObjectAnimator;
     21 import android.annotation.SuppressLint;
     22 import android.content.Context;
     23 import android.content.res.Resources;
     24 import android.os.Bundle;
     25 import android.os.Handler;
     26 import android.os.SystemClock;
     27 import android.text.format.DateUtils;
     28 import android.text.format.Time;
     29 import android.util.AttributeSet;
     30 import android.util.Log;
     31 import android.view.MotionEvent;
     32 import android.view.View;
     33 import android.view.View.OnTouchListener;
     34 import android.view.ViewConfiguration;
     35 import android.view.ViewGroup;
     36 import android.view.accessibility.AccessibilityEvent;
     37 import android.view.accessibility.AccessibilityManager;
     38 import android.view.accessibility.AccessibilityNodeInfo;
     39 import android.widget.FrameLayout;
     40 
     41 import com.android.datetimepicker.HapticFeedbackController;
     42 import com.android.datetimepicker.R;
     43 
     44 /**
     45  * The primary layout to hold the circular picker, and the am/pm buttons. This view well measure
     46  * itself to end up as a square. It also handles touches to be passed in to views that need to know
     47  * when they'd been touched.
     48  */
     49 public class RadialPickerLayout extends FrameLayout implements OnTouchListener {
     50     private static final String TAG = "RadialPickerLayout";
     51 
     52     private final int TOUCH_SLOP;
     53     private final int TAP_TIMEOUT;
     54 
     55     private static final int VISIBLE_DEGREES_STEP_SIZE = 30;
     56     private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = VISIBLE_DEGREES_STEP_SIZE;
     57     private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6;
     58     private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX;
     59     private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX;
     60     private static final int AMPM_INDEX = TimePickerDialog.AMPM_INDEX;
     61     private static final int ENABLE_PICKER_INDEX = TimePickerDialog.ENABLE_PICKER_INDEX;
     62     private static final int AM = TimePickerDialog.AM;
     63     private static final int PM = TimePickerDialog.PM;
     64 
     65     private int mLastValueSelected;
     66 
     67     private HapticFeedbackController mHapticFeedbackController;
     68     private OnValueSelectedListener mListener;
     69     private boolean mTimeInitialized;
     70     private int mCurrentHoursOfDay;
     71     private int mCurrentMinutes;
     72     private boolean mIs24HourMode;
     73     private boolean mHideAmPm;
     74     private int mCurrentItemShowing;
     75 
     76     private CircleView mCircleView;
     77     private AmPmCirclesView mAmPmCirclesView;
     78     private RadialTextsView mHourRadialTextsView;
     79     private RadialTextsView mMinuteRadialTextsView;
     80     private RadialSelectorView mHourRadialSelectorView;
     81     private RadialSelectorView mMinuteRadialSelectorView;
     82     private View mGrayBox;
     83 
     84     private int[] mSnapPrefer30sMap;
     85     private boolean mInputEnabled;
     86     private int mIsTouchingAmOrPm = -1;
     87     private boolean mDoingMove;
     88     private boolean mDoingTouch;
     89     private int mDownDegrees;
     90     private float mDownX;
     91     private float mDownY;
     92     private AccessibilityManager mAccessibilityManager;
     93 
     94     private AnimatorSet mTransition;
     95     private Handler mHandler = new Handler();
     96 
     97     public interface OnValueSelectedListener {
     98         void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance);
     99     }
    100 
    101     public RadialPickerLayout(Context context, AttributeSet attrs) {
    102         super(context, attrs);
    103 
    104         setOnTouchListener(this);
    105         ViewConfiguration vc = ViewConfiguration.get(context);
    106         TOUCH_SLOP = vc.getScaledTouchSlop();
    107         TAP_TIMEOUT = ViewConfiguration.getTapTimeout();
    108         mDoingMove = false;
    109 
    110         mCircleView = new CircleView(context);
    111         addView(mCircleView);
    112 
    113         mAmPmCirclesView = new AmPmCirclesView(context);
    114         addView(mAmPmCirclesView);
    115 
    116         mHourRadialTextsView = new RadialTextsView(context);
    117         addView(mHourRadialTextsView);
    118         mMinuteRadialTextsView = new RadialTextsView(context);
    119         addView(mMinuteRadialTextsView);
    120 
    121         mHourRadialSelectorView = new RadialSelectorView(context);
    122         addView(mHourRadialSelectorView);
    123         mMinuteRadialSelectorView = new RadialSelectorView(context);
    124         addView(mMinuteRadialSelectorView);
    125 
    126         // Prepare mapping to snap touchable degrees to selectable degrees.
    127         preparePrefer30sMap();
    128 
    129         mLastValueSelected = -1;
    130 
    131         mInputEnabled = true;
    132         mGrayBox = new View(context);
    133         mGrayBox.setLayoutParams(new ViewGroup.LayoutParams(
    134                 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
    135         mGrayBox.setBackgroundColor(getResources().getColor(R.color.transparent_black));
    136         mGrayBox.setVisibility(View.INVISIBLE);
    137         addView(mGrayBox);
    138 
    139         mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
    140 
    141         mTimeInitialized = false;
    142     }
    143 
    144     /**
    145      * Measure the view to end up as a square, based on the minimum of the height and width.
    146      */
    147     @Override
    148     public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    149         int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
    150         int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    151         int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
    152         int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    153         int minDimension = Math.min(measuredWidth, measuredHeight);
    154 
    155         super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode),
    156                 MeasureSpec.makeMeasureSpec(minDimension, heightMode));
    157     }
    158 
    159     public void setOnValueSelectedListener(OnValueSelectedListener listener) {
    160         mListener = listener;
    161     }
    162 
    163     /**
    164      * Initialize the Layout with starting values.
    165      * @param context
    166      * @param initialHoursOfDay
    167      * @param initialMinutes
    168      * @param is24HourMode
    169      */
    170     public void initialize(Context context, HapticFeedbackController hapticFeedbackController,
    171             int initialHoursOfDay, int initialMinutes, boolean is24HourMode) {
    172         if (mTimeInitialized) {
    173             Log.e(TAG, "Time has already been initialized.");
    174             return;
    175         }
    176 
    177         mHapticFeedbackController = hapticFeedbackController;
    178         mIs24HourMode = is24HourMode;
    179         mHideAmPm = mAccessibilityManager.isTouchExplorationEnabled()? true : mIs24HourMode;
    180 
    181         // Initialize the circle and AM/PM circles if applicable.
    182         mCircleView.initialize(context, mHideAmPm);
    183         mCircleView.invalidate();
    184         if (!mHideAmPm) {
    185             mAmPmCirclesView.initialize(context, initialHoursOfDay < 12? AM : PM);
    186             mAmPmCirclesView.invalidate();
    187         }
    188 
    189         // Initialize the hours and minutes numbers.
    190         Resources res = context.getResources();
    191         int[] hours = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
    192         int[] hours_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23};
    193         int[] minutes = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55};
    194         String[] hoursTexts = new String[12];
    195         String[] innerHoursTexts = new String[12];
    196         String[] minutesTexts = new String[12];
    197         for (int i = 0; i < 12; i++) {
    198             hoursTexts[i] = is24HourMode?
    199                     String.format("%02d", hours_24[i]) : String.format("%d", hours[i]);
    200             innerHoursTexts[i] = String.format("%d", hours[i]);
    201             minutesTexts[i] = String.format("%02d", minutes[i]);
    202         }
    203         mHourRadialTextsView.initialize(res,
    204                 hoursTexts, (is24HourMode? innerHoursTexts : null), mHideAmPm, true);
    205         mHourRadialTextsView.invalidate();
    206         mMinuteRadialTextsView.initialize(res, minutesTexts, null, mHideAmPm, false);
    207         mMinuteRadialTextsView.invalidate();
    208 
    209         // Initialize the currently-selected hour and minute.
    210         setValueForItem(HOUR_INDEX, initialHoursOfDay);
    211         setValueForItem(MINUTE_INDEX, initialMinutes);
    212         int hourDegrees = (initialHoursOfDay % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
    213         mHourRadialSelectorView.initialize(context, mHideAmPm, is24HourMode, true,
    214                 hourDegrees, isHourInnerCircle(initialHoursOfDay));
    215         int minuteDegrees = initialMinutes * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
    216         mMinuteRadialSelectorView.initialize(context, mHideAmPm, false, false,
    217                 minuteDegrees, false);
    218 
    219         mTimeInitialized = true;
    220     }
    221 
    222     /* package */ void setTheme(Context context, boolean themeDark) {
    223         mCircleView.setTheme(context, themeDark);
    224         mAmPmCirclesView.setTheme(context, themeDark);
    225         mHourRadialTextsView.setTheme(context, themeDark);
    226         mMinuteRadialTextsView.setTheme(context, themeDark);
    227         mHourRadialSelectorView.setTheme(context, themeDark);
    228         mMinuteRadialSelectorView.setTheme(context, themeDark);
    229    }
    230 
    231     public void setTime(int hours, int minutes) {
    232         setItem(HOUR_INDEX, hours);
    233         setItem(MINUTE_INDEX, minutes);
    234     }
    235 
    236     /**
    237      * Set either the hour or the minute. Will set the internal value, and set the selection.
    238      */
    239     private void setItem(int index, int value) {
    240         if (index == HOUR_INDEX) {
    241             setValueForItem(HOUR_INDEX, value);
    242             int hourDegrees = (value % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
    243             mHourRadialSelectorView.setSelection(hourDegrees, isHourInnerCircle(value), false);
    244             mHourRadialSelectorView.invalidate();
    245         } else if (index == MINUTE_INDEX) {
    246             setValueForItem(MINUTE_INDEX, value);
    247             int minuteDegrees = value * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
    248             mMinuteRadialSelectorView.setSelection(minuteDegrees, false, false);
    249             mMinuteRadialSelectorView.invalidate();
    250         }
    251     }
    252 
    253     /**
    254      * Check if a given hour appears in the outer circle or the inner circle
    255      * @return true if the hour is in the inner circle, false if it's in the outer circle.
    256      */
    257     private boolean isHourInnerCircle(int hourOfDay) {
    258         // We'll have the 00 hours on the outside circle.
    259         return mIs24HourMode && (hourOfDay <= 12 && hourOfDay != 0);
    260     }
    261 
    262     public int getHours() {
    263         return mCurrentHoursOfDay;
    264     }
    265 
    266     public int getMinutes() {
    267         return mCurrentMinutes;
    268     }
    269 
    270     /**
    271      * If the hours are showing, return the current hour. If the minutes are showing, return the
    272      * current minute.
    273      */
    274     private int getCurrentlyShowingValue() {
    275         int currentIndex = getCurrentItemShowing();
    276         if (currentIndex == HOUR_INDEX) {
    277             return mCurrentHoursOfDay;
    278         } else if (currentIndex == MINUTE_INDEX) {
    279             return mCurrentMinutes;
    280         } else {
    281             return -1;
    282         }
    283     }
    284 
    285     public int getIsCurrentlyAmOrPm() {
    286         if (mCurrentHoursOfDay < 12) {
    287             return AM;
    288         } else if (mCurrentHoursOfDay < 24) {
    289             return PM;
    290         }
    291         return -1;
    292     }
    293 
    294     /**
    295      * Set the internal value for the hour, minute, or AM/PM.
    296      */
    297     private void setValueForItem(int index, int value) {
    298         if (index == HOUR_INDEX) {
    299             mCurrentHoursOfDay = value;
    300         } else if (index == MINUTE_INDEX){
    301             mCurrentMinutes = value;
    302         } else if (index == AMPM_INDEX) {
    303             if (value == AM) {
    304                 mCurrentHoursOfDay = mCurrentHoursOfDay % 12;
    305             } else if (value == PM) {
    306                 mCurrentHoursOfDay = (mCurrentHoursOfDay % 12) + 12;
    307             }
    308         }
    309     }
    310 
    311     /**
    312      * Set the internal value as either AM or PM, and update the AM/PM circle displays.
    313      * @param amOrPm
    314      */
    315     public void setAmOrPm(int amOrPm) {
    316         mAmPmCirclesView.setAmOrPm(amOrPm);
    317         mAmPmCirclesView.invalidate();
    318         setValueForItem(AMPM_INDEX, amOrPm);
    319     }
    320 
    321     /**
    322      * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger
    323      * selectable area to each of the 12 visible values, such that the ratio of space apportioned
    324      * to a visible value : space apportioned to a non-visible value will be 14 : 4.
    325      * E.g. the output of 30 degrees should have a higher range of input associated with it than
    326      * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock
    327      * circle (5 on the minutes, 1 or 13 on the hours).
    328      */
    329     private void preparePrefer30sMap() {
    330         // We'll split up the visible output and the non-visible output such that each visible
    331         // output will correspond to a range of 14 associated input degrees, and each non-visible
    332         // output will correspond to a range of 4 associate input degrees, so visible numbers
    333         // are more than 3 times easier to get than non-visible numbers:
    334         // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc.
    335         //
    336         // If an output of 30 degrees should correspond to a range of 14 associated degrees, then
    337         // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should
    338         // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you
    339         // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this
    340         // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the
    341         // ability to aggressively prefer the visible values by a factor of more than 3:1, which
    342         // greatly contributes to the selectability of these values.
    343 
    344         // Our input will be 0 through 360.
    345         mSnapPrefer30sMap = new int[361];
    346 
    347         // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}.
    348         int snappedOutputDegrees = 0;
    349         // Count of how many inputs we've designated to the specified output.
    350         int count = 1;
    351         // How many input we expect for a specified output. This will be 14 for output divisible
    352         // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so
    353         // the caller can decide which they need.
    354         int expectedCount = 8;
    355         // Iterate through the input.
    356         for (int degrees = 0; degrees < 361; degrees++) {
    357             // Save the input-output mapping.
    358             mSnapPrefer30sMap[degrees] = snappedOutputDegrees;
    359             // If this is the last input for the specified output, calculate the next output and
    360             // the next expected count.
    361             if (count == expectedCount) {
    362                 snappedOutputDegrees += 6;
    363                 if (snappedOutputDegrees == 360) {
    364                     expectedCount = 7;
    365                 } else if (snappedOutputDegrees % 30 == 0) {
    366                     expectedCount = 14;
    367                 } else {
    368                     expectedCount = 4;
    369                 }
    370                 count = 1;
    371             } else {
    372                 count++;
    373             }
    374         }
    375     }
    376 
    377     /**
    378      * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees,
    379      * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be
    380      * weighted heavier than the degrees corresponding to non-visible numbers.
    381      * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the
    382      * mapping.
    383      */
    384     private int snapPrefer30s(int degrees) {
    385         if (mSnapPrefer30sMap == null) {
    386             return -1;
    387         }
    388         return mSnapPrefer30sMap[degrees];
    389     }
    390 
    391     /**
    392      * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all
    393      * multiples of 30), where the input will be "snapped" to the closest visible degrees.
    394      * @param degrees The input degrees
    395      * @param forceAboveOrBelow The output may be forced to either the higher or lower step, or may
    396      * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force
    397      * strictly lower, and 0 to snap to the closer one.
    398      * @return output degrees, will be a multiple of 30
    399      */
    400     private int snapOnly30s(int degrees, int forceHigherOrLower) {
    401         int stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
    402         int floor = (degrees / stepSize) * stepSize;
    403         int ceiling = floor + stepSize;
    404         if (forceHigherOrLower == 1) {
    405             degrees = ceiling;
    406         } else if (forceHigherOrLower == -1) {
    407             if (degrees == floor) {
    408                 floor -= stepSize;
    409             }
    410             degrees = floor;
    411         } else {
    412             if ((degrees - floor) < (ceiling - degrees)) {
    413                 degrees = floor;
    414             } else {
    415                 degrees = ceiling;
    416             }
    417         }
    418         return degrees;
    419     }
    420 
    421     /**
    422      * For the currently showing view (either hours or minutes), re-calculate the position for the
    423      * selector, and redraw it at that position. The input degrees will be snapped to a selectable
    424      * value.
    425      * @param degrees Degrees which should be selected.
    426      * @param isInnerCircle Whether the selection should be in the inner circle; will be ignored
    427      * if there is no inner circle.
    428      * @param forceToVisibleValue Even if the currently-showing circle allows for fine-grained
    429      * selection (i.e. minutes), force the selection to one of the visibly-showing values.
    430      * @param forceDrawDot The dot in the circle will generally only be shown when the selection
    431      * is on non-visible values, but use this to force the dot to be shown.
    432      * @return The value that was selected, i.e. 0-23 for hours, 0-59 for minutes.
    433      */
    434     private int reselectSelector(int degrees, boolean isInnerCircle,
    435             boolean forceToVisibleValue, boolean forceDrawDot) {
    436         if (degrees == -1) {
    437             return -1;
    438         }
    439         int currentShowing = getCurrentItemShowing();
    440 
    441         int stepSize;
    442         boolean allowFineGrained = !forceToVisibleValue && (currentShowing == MINUTE_INDEX);
    443         if (allowFineGrained) {
    444             degrees = snapPrefer30s(degrees);
    445         } else {
    446             degrees = snapOnly30s(degrees, 0);
    447         }
    448 
    449         RadialSelectorView radialSelectorView;
    450         if (currentShowing == HOUR_INDEX) {
    451             radialSelectorView = mHourRadialSelectorView;
    452             stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
    453         } else {
    454             radialSelectorView = mMinuteRadialSelectorView;
    455             stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
    456         }
    457         radialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot);
    458         radialSelectorView.invalidate();
    459 
    460 
    461         if (currentShowing == HOUR_INDEX) {
    462             if (mIs24HourMode) {
    463                 if (degrees == 0 && isInnerCircle) {
    464                     degrees = 360;
    465                 } else if (degrees == 360 && !isInnerCircle) {
    466                     degrees = 0;
    467                 }
    468             } else if (degrees == 0) {
    469                 degrees = 360;
    470             }
    471         } else if (degrees == 360 && currentShowing == MINUTE_INDEX) {
    472             degrees = 0;
    473         }
    474 
    475         int value = degrees / stepSize;
    476         if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) {
    477             value += 12;
    478         }
    479         return value;
    480     }
    481 
    482     /**
    483      * Calculate the degrees within the circle that corresponds to the specified coordinates, if
    484      * the coordinates are within the range that will trigger a selection.
    485      * @param pointX The x coordinate.
    486      * @param pointY The y coordinate.
    487      * @param forceLegal Force the selection to be legal, regardless of how far the coordinates are
    488      * from the actual numbers.
    489      * @param isInnerCircle If the selection may be in the inner circle, pass in a size-1 boolean
    490      * array here, inside which the value will be true if the selection is in the inner circle,
    491      * and false if in the outer circle.
    492      * @return Degrees from 0 to 360, if the selection was within the legal range. -1 if not.
    493      */
    494     private int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal,
    495             final Boolean[] isInnerCircle) {
    496         int currentItem = getCurrentItemShowing();
    497         if (currentItem == HOUR_INDEX) {
    498             return mHourRadialSelectorView.getDegreesFromCoords(
    499                     pointX, pointY, forceLegal, isInnerCircle);
    500         } else if (currentItem == MINUTE_INDEX) {
    501             return mMinuteRadialSelectorView.getDegreesFromCoords(
    502                     pointX, pointY, forceLegal, isInnerCircle);
    503         } else {
    504             return -1;
    505         }
    506     }
    507 
    508     /**
    509      * Get the item (hours or minutes) that is currently showing.
    510      */
    511     public int getCurrentItemShowing() {
    512         if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX) {
    513             Log.e(TAG, "Current item showing was unfortunately set to "+mCurrentItemShowing);
    514             return -1;
    515         }
    516         return mCurrentItemShowing;
    517     }
    518 
    519     /**
    520      * Set either minutes or hours as showing.
    521      * @param animate True to animate the transition, false to show with no animation.
    522      */
    523     public void setCurrentItemShowing(int index, boolean animate) {
    524         if (index != HOUR_INDEX && index != MINUTE_INDEX) {
    525             Log.e(TAG, "TimePicker does not support view at index "+index);
    526             return;
    527         }
    528 
    529         int lastIndex = getCurrentItemShowing();
    530         mCurrentItemShowing = index;
    531 
    532         if (animate && (index != lastIndex)) {
    533             ObjectAnimator[] anims = new ObjectAnimator[4];
    534             if (index == MINUTE_INDEX) {
    535                 anims[0] = mHourRadialTextsView.getDisappearAnimator();
    536                 anims[1] = mHourRadialSelectorView.getDisappearAnimator();
    537                 anims[2] = mMinuteRadialTextsView.getReappearAnimator();
    538                 anims[3] = mMinuteRadialSelectorView.getReappearAnimator();
    539             } else if (index == HOUR_INDEX){
    540                 anims[0] = mHourRadialTextsView.getReappearAnimator();
    541                 anims[1] = mHourRadialSelectorView.getReappearAnimator();
    542                 anims[2] = mMinuteRadialTextsView.getDisappearAnimator();
    543                 anims[3] = mMinuteRadialSelectorView.getDisappearAnimator();
    544             }
    545 
    546             if (mTransition != null && mTransition.isRunning()) {
    547                 mTransition.end();
    548             }
    549             mTransition = new AnimatorSet();
    550             mTransition.playTogether(anims);
    551             mTransition.start();
    552         } else {
    553             int hourAlpha = (index == HOUR_INDEX) ? 255 : 0;
    554             int minuteAlpha = (index == MINUTE_INDEX) ? 255 : 0;
    555             mHourRadialTextsView.setAlpha(hourAlpha);
    556             mHourRadialSelectorView.setAlpha(hourAlpha);
    557             mMinuteRadialTextsView.setAlpha(minuteAlpha);
    558             mMinuteRadialSelectorView.setAlpha(minuteAlpha);
    559         }
    560 
    561     }
    562 
    563     @Override
    564     public boolean onTouch(View v, MotionEvent event) {
    565         final float eventX = event.getX();
    566         final float eventY = event.getY();
    567         int degrees;
    568         int value;
    569         final Boolean[] isInnerCircle = new Boolean[1];
    570         isInnerCircle[0] = false;
    571 
    572         long millis = SystemClock.uptimeMillis();
    573 
    574         switch(event.getAction()) {
    575             case MotionEvent.ACTION_DOWN:
    576                 if (!mInputEnabled) {
    577                     return true;
    578                 }
    579 
    580                 mDownX = eventX;
    581                 mDownY = eventY;
    582 
    583                 mLastValueSelected = -1;
    584                 mDoingMove = false;
    585                 mDoingTouch = true;
    586                 // If we're showing the AM/PM, check to see if the user is touching it.
    587                 if (!mHideAmPm) {
    588                     mIsTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
    589                 } else {
    590                     mIsTouchingAmOrPm = -1;
    591                 }
    592                 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
    593                     // If the touch is on AM or PM, set it as "touched" after the TAP_TIMEOUT
    594                     // in case the user moves their finger quickly.
    595                     mHapticFeedbackController.tryVibrate();
    596                     mDownDegrees = -1;
    597                     mHandler.postDelayed(new Runnable() {
    598                         @Override
    599                         public void run() {
    600                             mAmPmCirclesView.setAmOrPmPressed(mIsTouchingAmOrPm);
    601                             mAmPmCirclesView.invalidate();
    602                         }
    603                     }, TAP_TIMEOUT);
    604                 } else {
    605                     // If we're in accessibility mode, force the touch to be legal. Otherwise,
    606                     // it will only register within the given touch target zone.
    607                     boolean forceLegal = mAccessibilityManager.isTouchExplorationEnabled();
    608                     // Calculate the degrees that is currently being touched.
    609                     mDownDegrees = getDegreesFromCoords(eventX, eventY, forceLegal, isInnerCircle);
    610                     if (mDownDegrees != -1) {
    611                         // If it's a legal touch, set that number as "selected" after the
    612                         // TAP_TIMEOUT in case the user moves their finger quickly.
    613                         mHapticFeedbackController.tryVibrate();
    614                         mHandler.postDelayed(new Runnable() {
    615                             @Override
    616                             public void run() {
    617                                 mDoingMove = true;
    618                                 int value = reselectSelector(mDownDegrees, isInnerCircle[0],
    619                                         false, true);
    620                                 mLastValueSelected = value;
    621                                 mListener.onValueSelected(getCurrentItemShowing(), value, false);
    622                             }
    623                         }, TAP_TIMEOUT);
    624                     }
    625                 }
    626                 return true;
    627             case MotionEvent.ACTION_MOVE:
    628                 if (!mInputEnabled) {
    629                     // We shouldn't be in this state, because input is disabled.
    630                     Log.e(TAG, "Input was disabled, but received ACTION_MOVE.");
    631                     return true;
    632                 }
    633 
    634                 float dY = Math.abs(eventY - mDownY);
    635                 float dX = Math.abs(eventX - mDownX);
    636 
    637                 if (!mDoingMove && dX <= TOUCH_SLOP && dY <= TOUCH_SLOP) {
    638                     // Hasn't registered down yet, just slight, accidental movement of finger.
    639                     break;
    640                 }
    641 
    642                 // If we're in the middle of touching down on AM or PM, check if we still are.
    643                 // If so, no-op. If not, remove its pressed state. Either way, no need to check
    644                 // for touches on the other circle.
    645                 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
    646                     mHandler.removeCallbacksAndMessages(null);
    647                     int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
    648                     if (isTouchingAmOrPm != mIsTouchingAmOrPm) {
    649                         mAmPmCirclesView.setAmOrPmPressed(-1);
    650                         mAmPmCirclesView.invalidate();
    651                         mIsTouchingAmOrPm = -1;
    652                     }
    653                     break;
    654                 }
    655 
    656                 if (mDownDegrees == -1) {
    657                     // Original down was illegal, so no movement will register.
    658                     break;
    659                 }
    660 
    661                 // We're doing a move along the circle, so move the selection as appropriate.
    662                 mDoingMove = true;
    663                 mHandler.removeCallbacksAndMessages(null);
    664                 degrees = getDegreesFromCoords(eventX, eventY, true, isInnerCircle);
    665                 if (degrees != -1) {
    666                     value = reselectSelector(degrees, isInnerCircle[0], false, true);
    667                     if (value != mLastValueSelected) {
    668                         mHapticFeedbackController.tryVibrate();
    669                         mLastValueSelected = value;
    670                         mListener.onValueSelected(getCurrentItemShowing(), value, false);
    671                     }
    672                 }
    673                 return true;
    674             case MotionEvent.ACTION_UP:
    675                 if (!mInputEnabled) {
    676                     // If our touch input was disabled, tell the listener to re-enable us.
    677                     Log.d(TAG, "Input was disabled, but received ACTION_UP.");
    678                     mListener.onValueSelected(ENABLE_PICKER_INDEX, 1, false);
    679                     return true;
    680                 }
    681 
    682                 mHandler.removeCallbacksAndMessages(null);
    683                 mDoingTouch = false;
    684 
    685                 // If we're touching AM or PM, set it as selected, and tell the listener.
    686                 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
    687                     int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
    688                     mAmPmCirclesView.setAmOrPmPressed(-1);
    689                     mAmPmCirclesView.invalidate();
    690 
    691                     if (isTouchingAmOrPm == mIsTouchingAmOrPm) {
    692                         mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm);
    693                         if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) {
    694                             mListener.onValueSelected(AMPM_INDEX, mIsTouchingAmOrPm, false);
    695                             setValueForItem(AMPM_INDEX, isTouchingAmOrPm);
    696                         }
    697                     }
    698                     mIsTouchingAmOrPm = -1;
    699                     break;
    700                 }
    701 
    702                 // If we have a legal degrees selected, set the value and tell the listener.
    703                 if (mDownDegrees != -1) {
    704                     degrees = getDegreesFromCoords(eventX, eventY, mDoingMove, isInnerCircle);
    705                     if (degrees != -1) {
    706                         value = reselectSelector(degrees, isInnerCircle[0], !mDoingMove, false);
    707                         if (getCurrentItemShowing() == HOUR_INDEX && !mIs24HourMode) {
    708                             int amOrPm = getIsCurrentlyAmOrPm();
    709                             if (amOrPm == AM && value == 12) {
    710                                 value = 0;
    711                             } else if (amOrPm == PM && value != 12) {
    712                                 value += 12;
    713                             }
    714                         }
    715                         setValueForItem(getCurrentItemShowing(), value);
    716                         mListener.onValueSelected(getCurrentItemShowing(), value, true);
    717                     }
    718                 }
    719                 mDoingMove = false;
    720                 return true;
    721             default:
    722                 break;
    723         }
    724         return false;
    725     }
    726 
    727     /**
    728      * Set touch input as enabled or disabled, for use with keyboard mode.
    729      */
    730     public boolean trySettingInputEnabled(boolean inputEnabled) {
    731         if (mDoingTouch && !inputEnabled) {
    732             // If we're trying to disable input, but we're in the middle of a touch event,
    733             // we'll allow the touch event to continue before disabling input.
    734             return false;
    735         }
    736         mInputEnabled = inputEnabled;
    737         mGrayBox.setVisibility(inputEnabled? View.INVISIBLE : View.VISIBLE);
    738         return true;
    739     }
    740 
    741     /**
    742      * Necessary for accessibility, to ensure we support "scrolling" forward and backward
    743      * in the circle.
    744      */
    745     @Override
    746     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
    747       super.onInitializeAccessibilityNodeInfo(info);
    748       info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
    749       info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
    750     }
    751 
    752     /**
    753      * Announce the currently-selected time when launched.
    754      */
    755     @Override
    756     public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
    757         if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
    758             // Clear the event's current text so that only the current time will be spoken.
    759             event.getText().clear();
    760             Time time = new Time();
    761             time.hour = getHours();
    762             time.minute = getMinutes();
    763             long millis = time.normalize(true);
    764             int flags = DateUtils.FORMAT_SHOW_TIME;
    765             if (mIs24HourMode) {
    766                 flags |= DateUtils.FORMAT_24HOUR;
    767             }
    768             String timeString = DateUtils.formatDateTime(getContext(), millis, flags);
    769             event.getText().add(timeString);
    770             return true;
    771         }
    772         return super.dispatchPopulateAccessibilityEvent(event);
    773     }
    774 
    775     /**
    776      * When scroll forward/backward events are received, jump the time to the higher/lower
    777      * discrete, visible value on the circle.
    778      */
    779     @SuppressLint("NewApi")
    780     @Override
    781     public boolean performAccessibilityAction(int action, Bundle arguments) {
    782         if (super.performAccessibilityAction(action, arguments)) {
    783             return true;
    784         }
    785 
    786         int changeMultiplier = 0;
    787         if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
    788             changeMultiplier = 1;
    789         } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
    790             changeMultiplier = -1;
    791         }
    792         if (changeMultiplier != 0) {
    793             int value = getCurrentlyShowingValue();
    794             int stepSize = 0;
    795             int currentItemShowing = getCurrentItemShowing();
    796             if (currentItemShowing == HOUR_INDEX) {
    797                 stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
    798                 value %= 12;
    799             } else if (currentItemShowing == MINUTE_INDEX) {
    800                 stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
    801             }
    802 
    803             int degrees = value * stepSize;
    804             degrees = snapOnly30s(degrees, changeMultiplier);
    805             value = degrees / stepSize;
    806             int maxValue = 0;
    807             int minValue = 0;
    808             if (currentItemShowing == HOUR_INDEX) {
    809                 if (mIs24HourMode) {
    810                     maxValue = 23;
    811                 } else {
    812                     maxValue = 12;
    813                     minValue = 1;
    814                 }
    815             } else {
    816                 maxValue = 55;
    817             }
    818             if (value > maxValue) {
    819                 // If we scrolled forward past the highest number, wrap around to the lowest.
    820                 value = minValue;
    821             } else if (value < minValue) {
    822                 // If we scrolled backward past the lowest number, wrap around to the highest.
    823                 value = maxValue;
    824             }
    825             setItem(currentItemShowing, value);
    826             mListener.onValueSelected(currentItemShowing, value, false);
    827             return true;
    828         }
    829 
    830         return false;
    831     }
    832 }
    833