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