|  | /* | 
|  | * Copyright (C) 2013 The Android Open Source Project | 
|  | * | 
|  | * Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | * you may not use this file except in compliance with the License. | 
|  | * You may obtain a copy of the License at | 
|  | * | 
|  | *      http://www.apache.org/licenses/LICENSE-2.0 | 
|  | * | 
|  | * Unless required by applicable law or agreed to in writing, software | 
|  | * distributed under the License is distributed on an "AS IS" BASIS, | 
|  | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | * See the License for the specific language governing permissions and | 
|  | * limitations under the License. | 
|  | */ | 
|  |  | 
|  | package android.widget; | 
|  |  | 
|  | import android.animation.ObjectAnimator; | 
|  | import android.annotation.IntDef; | 
|  | import android.content.Context; | 
|  | import android.content.res.ColorStateList; | 
|  | import android.content.res.Resources; | 
|  | import android.content.res.TypedArray; | 
|  | import android.graphics.Canvas; | 
|  | import android.graphics.Color; | 
|  | import android.graphics.Paint; | 
|  | import android.graphics.Path; | 
|  | import android.graphics.Rect; | 
|  | import android.graphics.Region; | 
|  | import android.graphics.Typeface; | 
|  | import android.os.Bundle; | 
|  | import android.util.AttributeSet; | 
|  | import android.util.FloatProperty; | 
|  | import android.util.IntArray; | 
|  | import android.util.Log; | 
|  | import android.util.MathUtils; | 
|  | import android.util.StateSet; | 
|  | import android.util.TypedValue; | 
|  | import android.view.HapticFeedbackConstants; | 
|  | import android.view.MotionEvent; | 
|  | import android.view.PointerIcon; | 
|  | import android.view.View; | 
|  | import android.view.accessibility.AccessibilityEvent; | 
|  | import android.view.accessibility.AccessibilityNodeInfo; | 
|  | import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; | 
|  |  | 
|  | import com.android.internal.R; | 
|  | import com.android.internal.widget.ExploreByTouchHelper; | 
|  |  | 
|  | import java.lang.annotation.Retention; | 
|  | import java.lang.annotation.RetentionPolicy; | 
|  | import java.util.Calendar; | 
|  | import java.util.Locale; | 
|  |  | 
|  | /** | 
|  | * View to show a clock circle picker (with one or two picking circles) | 
|  | * | 
|  | * @hide | 
|  | */ | 
|  | public class RadialTimePickerView extends View { | 
|  | private static final String TAG = "RadialTimePickerView"; | 
|  |  | 
|  | public static final int HOURS = 0; | 
|  | public static final int MINUTES = 1; | 
|  |  | 
|  | /** @hide */ | 
|  | @IntDef({HOURS, MINUTES}) | 
|  | @Retention(RetentionPolicy.SOURCE) | 
|  | @interface PickerType {} | 
|  |  | 
|  | private static final int HOURS_INNER = 2; | 
|  |  | 
|  | private static final int SELECTOR_CIRCLE = 0; | 
|  | private static final int SELECTOR_DOT = 1; | 
|  | private static final int SELECTOR_LINE = 2; | 
|  |  | 
|  | private static final int AM = 0; | 
|  | private static final int PM = 1; | 
|  |  | 
|  | private static final int HOURS_IN_CIRCLE = 12; | 
|  | private static final int MINUTES_IN_CIRCLE = 60; | 
|  | private static final int DEGREES_FOR_ONE_HOUR = 360 / HOURS_IN_CIRCLE; | 
|  | private static final int DEGREES_FOR_ONE_MINUTE = 360 / MINUTES_IN_CIRCLE; | 
|  |  | 
|  | private static final int[] HOURS_NUMBERS = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; | 
|  | private static final int[] HOURS_NUMBERS_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23}; | 
|  | private static final int[] MINUTES_NUMBERS = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}; | 
|  |  | 
|  | private static final int ANIM_DURATION_NORMAL = 500; | 
|  | private static final int ANIM_DURATION_TOUCH = 60; | 
|  |  | 
|  | private static final int[] SNAP_PREFER_30S_MAP = new int[361]; | 
|  |  | 
|  | private static final int NUM_POSITIONS = 12; | 
|  | private static final float[] COS_30 = new float[NUM_POSITIONS]; | 
|  | private static final float[] SIN_30 = new float[NUM_POSITIONS]; | 
|  |  | 
|  | /** "Something is wrong" color used when a color attribute is missing. */ | 
|  | private static final int MISSING_COLOR = Color.MAGENTA; | 
|  |  | 
|  | static { | 
|  | // Prepare mapping to snap touchable degrees to selectable degrees. | 
|  | preparePrefer30sMap(); | 
|  |  | 
|  | final double increment = 2.0 * Math.PI / NUM_POSITIONS; | 
|  | double angle = Math.PI / 2.0; | 
|  | for (int i = 0; i < NUM_POSITIONS; i++) { | 
|  | COS_30[i] = (float) Math.cos(angle); | 
|  | SIN_30[i] = (float) Math.sin(angle); | 
|  | angle += increment; | 
|  | } | 
|  | } | 
|  |  | 
|  | private final FloatProperty<RadialTimePickerView> HOURS_TO_MINUTES = | 
|  | new FloatProperty<RadialTimePickerView>("hoursToMinutes") { | 
|  | @Override | 
|  | public Float get(RadialTimePickerView radialTimePickerView) { | 
|  | return radialTimePickerView.mHoursToMinutes; | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public void setValue(RadialTimePickerView object, float value) { | 
|  | object.mHoursToMinutes = value; | 
|  | object.invalidate(); | 
|  | } | 
|  | }; | 
|  |  | 
|  | private final String[] mHours12Texts = new String[12]; | 
|  | private final String[] mOuterHours24Texts = new String[12]; | 
|  | private final String[] mInnerHours24Texts = new String[12]; | 
|  | private final String[] mMinutesTexts = new String[12]; | 
|  |  | 
|  | private final Paint[] mPaint = new Paint[2]; | 
|  | private final Paint mPaintCenter = new Paint(); | 
|  | private final Paint[] mPaintSelector = new Paint[3]; | 
|  | private final Paint mPaintBackground = new Paint(); | 
|  |  | 
|  | private final Typeface mTypeface; | 
|  |  | 
|  | private final ColorStateList[] mTextColor = new ColorStateList[3]; | 
|  | private final int[] mTextSize = new int[3]; | 
|  | private final int[] mTextInset = new int[3]; | 
|  |  | 
|  | private final float[][] mOuterTextX = new float[2][12]; | 
|  | private final float[][] mOuterTextY = new float[2][12]; | 
|  |  | 
|  | private final float[] mInnerTextX = new float[12]; | 
|  | private final float[] mInnerTextY = new float[12]; | 
|  |  | 
|  | private final int[] mSelectionDegrees = new int[2]; | 
|  |  | 
|  | private final RadialPickerTouchHelper mTouchHelper; | 
|  |  | 
|  | private final Path mSelectorPath = new Path(); | 
|  |  | 
|  | private boolean mIs24HourMode; | 
|  | private boolean mShowHours; | 
|  |  | 
|  | private ObjectAnimator mHoursToMinutesAnimator; | 
|  | private float mHoursToMinutes; | 
|  |  | 
|  | /** | 
|  | * When in 24-hour mode, indicates that the current hour is between | 
|  | * 1 and 12 (inclusive). | 
|  | */ | 
|  | private boolean mIsOnInnerCircle; | 
|  |  | 
|  | private int mSelectorRadius; | 
|  | private int mSelectorStroke; | 
|  | private int mSelectorDotRadius; | 
|  | private int mCenterDotRadius; | 
|  |  | 
|  | private int mSelectorColor; | 
|  | private int mSelectorDotColor; | 
|  |  | 
|  | private int mXCenter; | 
|  | private int mYCenter; | 
|  | private int mCircleRadius; | 
|  |  | 
|  | private int mMinDistForInnerNumber; | 
|  | private int mMaxDistForOuterNumber; | 
|  | private int mHalfwayDist; | 
|  |  | 
|  | private String[] mOuterTextHours; | 
|  | private String[] mInnerTextHours; | 
|  | private String[] mMinutesText; | 
|  |  | 
|  | private int mAmOrPm; | 
|  |  | 
|  | private float mDisabledAlpha; | 
|  |  | 
|  | private OnValueSelectedListener mListener; | 
|  |  | 
|  | private boolean mInputEnabled = true; | 
|  |  | 
|  | interface OnValueSelectedListener { | 
|  | /** | 
|  | * Called when the selected value at a given picker index has changed. | 
|  | * | 
|  | * @param pickerType the type of value that has changed, one of: | 
|  | *                   <ul> | 
|  | *                       <li>{@link #MINUTES} | 
|  | *                       <li>{@link #HOURS} | 
|  | *                   </ul> | 
|  | * @param newValue the new value as minute in hour (0-59) or hour in | 
|  | *                 day (0-23) | 
|  | * @param autoAdvance when the picker type is {@link #HOURS}, | 
|  | *                    {@code true} to switch to the {@link #MINUTES} | 
|  | *                    picker or {@code false} to stay on the current | 
|  | *                    picker. No effect when picker type is | 
|  | *                    {@link #MINUTES}. | 
|  | */ | 
|  | void onValueSelected(@PickerType int pickerType, int newValue, boolean autoAdvance); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger | 
|  | * selectable area to each of the 12 visible values, such that the ratio of space apportioned | 
|  | * to a visible value : space apportioned to a non-visible value will be 14 : 4. | 
|  | * E.g. the output of 30 degrees should have a higher range of input associated with it than | 
|  | * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock | 
|  | * circle (5 on the minutes, 1 or 13 on the hours). | 
|  | */ | 
|  | private static void preparePrefer30sMap() { | 
|  | // We'll split up the visible output and the non-visible output such that each visible | 
|  | // output will correspond to a range of 14 associated input degrees, and each non-visible | 
|  | // output will correspond to a range of 4 associate input degrees, so visible numbers | 
|  | // are more than 3 times easier to get than non-visible numbers: | 
|  | // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc. | 
|  | // | 
|  | // If an output of 30 degrees should correspond to a range of 14 associated degrees, then | 
|  | // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should | 
|  | // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you | 
|  | // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this | 
|  | // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the | 
|  | // ability to aggressively prefer the visible values by a factor of more than 3:1, which | 
|  | // greatly contributes to the selectability of these values. | 
|  |  | 
|  | // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}. | 
|  | int snappedOutputDegrees = 0; | 
|  | // Count of how many inputs we've designated to the specified output. | 
|  | int count = 1; | 
|  | // How many input we expect for a specified output. This will be 14 for output divisible | 
|  | // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so | 
|  | // the caller can decide which they need. | 
|  | int expectedCount = 8; | 
|  | // Iterate through the input. | 
|  | for (int degrees = 0; degrees < 361; degrees++) { | 
|  | // Save the input-output mapping. | 
|  | SNAP_PREFER_30S_MAP[degrees] = snappedOutputDegrees; | 
|  | // If this is the last input for the specified output, calculate the next output and | 
|  | // the next expected count. | 
|  | if (count == expectedCount) { | 
|  | snappedOutputDegrees += 6; | 
|  | if (snappedOutputDegrees == 360) { | 
|  | expectedCount = 7; | 
|  | } else if (snappedOutputDegrees % 30 == 0) { | 
|  | expectedCount = 14; | 
|  | } else { | 
|  | expectedCount = 4; | 
|  | } | 
|  | count = 1; | 
|  | } else { | 
|  | count++; | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees, | 
|  | * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be | 
|  | * weighted heavier than the degrees corresponding to non-visible numbers. | 
|  | * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the | 
|  | * mapping. | 
|  | */ | 
|  | private static int snapPrefer30s(int degrees) { | 
|  | if (SNAP_PREFER_30S_MAP == null) { | 
|  | return -1; | 
|  | } | 
|  | return SNAP_PREFER_30S_MAP[degrees]; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all | 
|  | * multiples of 30), where the input will be "snapped" to the closest visible degrees. | 
|  | * @param degrees The input degrees | 
|  | * @param forceHigherOrLower The output may be forced to either the higher or lower step, or may | 
|  | * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force | 
|  | * strictly lower, and 0 to snap to the closer one. | 
|  | * @return output degrees, will be a multiple of 30 | 
|  | */ | 
|  | private static int snapOnly30s(int degrees, int forceHigherOrLower) { | 
|  | final int stepSize = DEGREES_FOR_ONE_HOUR; | 
|  | int floor = (degrees / stepSize) * stepSize; | 
|  | final int ceiling = floor + stepSize; | 
|  | if (forceHigherOrLower == 1) { | 
|  | degrees = ceiling; | 
|  | } else if (forceHigherOrLower == -1) { | 
|  | if (degrees == floor) { | 
|  | floor -= stepSize; | 
|  | } | 
|  | degrees = floor; | 
|  | } else { | 
|  | if ((degrees - floor) < (ceiling - degrees)) { | 
|  | degrees = floor; | 
|  | } else { | 
|  | degrees = ceiling; | 
|  | } | 
|  | } | 
|  | return degrees; | 
|  | } | 
|  |  | 
|  | @SuppressWarnings("unused") | 
|  | public RadialTimePickerView(Context context)  { | 
|  | this(context, null); | 
|  | } | 
|  |  | 
|  | public RadialTimePickerView(Context context, AttributeSet attrs)  { | 
|  | this(context, attrs, R.attr.timePickerStyle); | 
|  | } | 
|  |  | 
|  | public RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr)  { | 
|  | this(context, attrs, defStyleAttr, 0); | 
|  | } | 
|  |  | 
|  | public RadialTimePickerView( | 
|  | Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)  { | 
|  | super(context, attrs); | 
|  |  | 
|  | applyAttributes(attrs, defStyleAttr, defStyleRes); | 
|  |  | 
|  | // Pull disabled alpha from theme. | 
|  | final TypedValue outValue = new TypedValue(); | 
|  | context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true); | 
|  | mDisabledAlpha = outValue.getFloat(); | 
|  |  | 
|  | mTypeface = Typeface.create("sans-serif", Typeface.NORMAL); | 
|  |  | 
|  | mPaint[HOURS] = new Paint(); | 
|  | mPaint[HOURS].setAntiAlias(true); | 
|  | mPaint[HOURS].setTextAlign(Paint.Align.CENTER); | 
|  |  | 
|  | mPaint[MINUTES] = new Paint(); | 
|  | mPaint[MINUTES].setAntiAlias(true); | 
|  | mPaint[MINUTES].setTextAlign(Paint.Align.CENTER); | 
|  |  | 
|  | mPaintCenter.setAntiAlias(true); | 
|  |  | 
|  | mPaintSelector[SELECTOR_CIRCLE] = new Paint(); | 
|  | mPaintSelector[SELECTOR_CIRCLE].setAntiAlias(true); | 
|  |  | 
|  | mPaintSelector[SELECTOR_DOT] = new Paint(); | 
|  | mPaintSelector[SELECTOR_DOT].setAntiAlias(true); | 
|  |  | 
|  | mPaintSelector[SELECTOR_LINE] = new Paint(); | 
|  | mPaintSelector[SELECTOR_LINE].setAntiAlias(true); | 
|  | mPaintSelector[SELECTOR_LINE].setStrokeWidth(2); | 
|  |  | 
|  | mPaintBackground.setAntiAlias(true); | 
|  |  | 
|  | final Resources res = getResources(); | 
|  | mSelectorRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_radius); | 
|  | mSelectorStroke = res.getDimensionPixelSize(R.dimen.timepicker_selector_stroke); | 
|  | mSelectorDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_dot_radius); | 
|  | mCenterDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_center_dot_radius); | 
|  |  | 
|  | mTextSize[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal); | 
|  | mTextSize[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal); | 
|  | mTextSize[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_inner); | 
|  |  | 
|  | mTextInset[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal); | 
|  | mTextInset[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal); | 
|  | mTextInset[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_inner); | 
|  |  | 
|  | mShowHours = true; | 
|  | mHoursToMinutes = HOURS; | 
|  | mIs24HourMode = false; | 
|  | mAmOrPm = AM; | 
|  |  | 
|  | // Set up accessibility components. | 
|  | mTouchHelper = new RadialPickerTouchHelper(); | 
|  | setAccessibilityDelegate(mTouchHelper); | 
|  |  | 
|  | if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) { | 
|  | setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); | 
|  | } | 
|  |  | 
|  | initHoursAndMinutesText(); | 
|  | initData(); | 
|  |  | 
|  | // Initial values | 
|  | final Calendar calendar = Calendar.getInstance(Locale.getDefault()); | 
|  | final int currentHour = calendar.get(Calendar.HOUR_OF_DAY); | 
|  | final int currentMinute = calendar.get(Calendar.MINUTE); | 
|  |  | 
|  | setCurrentHourInternal(currentHour, false, false); | 
|  | setCurrentMinuteInternal(currentMinute, false); | 
|  |  | 
|  | setHapticFeedbackEnabled(true); | 
|  | } | 
|  |  | 
|  | void applyAttributes(AttributeSet attrs, int defStyleAttr, int defStyleRes) { | 
|  | final Context context = getContext(); | 
|  | final TypedArray a = getContext().obtainStyledAttributes(attrs, | 
|  | R.styleable.TimePicker, defStyleAttr, defStyleRes); | 
|  | saveAttributeDataForStyleable(context, R.styleable.TimePicker, | 
|  | attrs, a, defStyleAttr, defStyleRes); | 
|  |  | 
|  | final ColorStateList numbersTextColor = a.getColorStateList( | 
|  | R.styleable.TimePicker_numbersTextColor); | 
|  | final ColorStateList numbersInnerTextColor = a.getColorStateList( | 
|  | R.styleable.TimePicker_numbersInnerTextColor); | 
|  | mTextColor[HOURS] = numbersTextColor == null ? | 
|  | ColorStateList.valueOf(MISSING_COLOR) : numbersTextColor; | 
|  | mTextColor[HOURS_INNER] = numbersInnerTextColor == null ? | 
|  | ColorStateList.valueOf(MISSING_COLOR) : numbersInnerTextColor; | 
|  | mTextColor[MINUTES] = mTextColor[HOURS]; | 
|  |  | 
|  | // Set up various colors derived from the selector "activated" state. | 
|  | final ColorStateList selectorColors = a.getColorStateList( | 
|  | R.styleable.TimePicker_numbersSelectorColor); | 
|  | final int selectorActivatedColor; | 
|  | if (selectorColors != null) { | 
|  | final int[] stateSetEnabledActivated = StateSet.get( | 
|  | StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED); | 
|  | selectorActivatedColor = selectorColors.getColorForState( | 
|  | stateSetEnabledActivated, 0); | 
|  | }  else { | 
|  | selectorActivatedColor = MISSING_COLOR; | 
|  | } | 
|  |  | 
|  | mPaintCenter.setColor(selectorActivatedColor); | 
|  |  | 
|  | final int[] stateSetActivated = StateSet.get( | 
|  | StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED); | 
|  |  | 
|  | mSelectorColor = selectorActivatedColor; | 
|  | mSelectorDotColor = mTextColor[HOURS].getColorForState(stateSetActivated, 0); | 
|  |  | 
|  | mPaintBackground.setColor(a.getColor(R.styleable.TimePicker_numbersBackgroundColor, | 
|  | context.getColor(R.color.timepicker_default_numbers_background_color_material))); | 
|  |  | 
|  | a.recycle(); | 
|  | } | 
|  |  | 
|  | public void initialize(int hour, int minute, boolean is24HourMode) { | 
|  | if (mIs24HourMode != is24HourMode) { | 
|  | mIs24HourMode = is24HourMode; | 
|  | initData(); | 
|  | } | 
|  |  | 
|  | setCurrentHourInternal(hour, false, false); | 
|  | setCurrentMinuteInternal(minute, false); | 
|  | } | 
|  |  | 
|  | public void setCurrentItemShowing(int item, boolean animate) { | 
|  | switch (item){ | 
|  | case HOURS: | 
|  | showHours(animate); | 
|  | break; | 
|  | case MINUTES: | 
|  | showMinutes(animate); | 
|  | break; | 
|  | default: | 
|  | Log.e(TAG, "ClockView does not support showing item " + item); | 
|  | } | 
|  | } | 
|  |  | 
|  | public int getCurrentItemShowing() { | 
|  | return mShowHours ? HOURS : MINUTES; | 
|  | } | 
|  |  | 
|  | public void setOnValueSelectedListener(OnValueSelectedListener listener) { | 
|  | mListener = listener; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Sets the current hour in 24-hour time. | 
|  | * | 
|  | * @param hour the current hour between 0 and 23 (inclusive) | 
|  | */ | 
|  | public void setCurrentHour(int hour) { | 
|  | setCurrentHourInternal(hour, true, false); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Sets the current hour. | 
|  | * | 
|  | * @param hour The current hour | 
|  | * @param callback Whether the value listener should be invoked | 
|  | * @param autoAdvance Whether the listener should auto-advance to the next | 
|  | *                    selection mode, e.g. hour to minutes | 
|  | */ | 
|  | private void setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance) { | 
|  | final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR; | 
|  | mSelectionDegrees[HOURS] = degrees; | 
|  |  | 
|  | // 0 is 12 AM (midnight) and 12 is 12 PM (noon). | 
|  | final int amOrPm = (hour == 0 || (hour % 24) < 12) ? AM : PM; | 
|  | final boolean isOnInnerCircle = getInnerCircleForHour(hour); | 
|  | if (mAmOrPm != amOrPm || mIsOnInnerCircle != isOnInnerCircle) { | 
|  | mAmOrPm = amOrPm; | 
|  | mIsOnInnerCircle = isOnInnerCircle; | 
|  |  | 
|  | initData(); | 
|  | mTouchHelper.invalidateRoot(); | 
|  | } | 
|  |  | 
|  | invalidate(); | 
|  |  | 
|  | if (callback && mListener != null) { | 
|  | mListener.onValueSelected(HOURS, hour, autoAdvance); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Returns the current hour in 24-hour time. | 
|  | * | 
|  | * @return the current hour between 0 and 23 (inclusive) | 
|  | */ | 
|  | public int getCurrentHour() { | 
|  | return getHourForDegrees(mSelectionDegrees[HOURS], mIsOnInnerCircle); | 
|  | } | 
|  |  | 
|  | private int getHourForDegrees(int degrees, boolean innerCircle) { | 
|  | int hour = (degrees / DEGREES_FOR_ONE_HOUR) % 12; | 
|  | if (mIs24HourMode) { | 
|  | // Convert the 12-hour value into 24-hour time based on where the | 
|  | // selector is positioned. | 
|  | if (!innerCircle && hour == 0) { | 
|  | // Outer circle is 1 through 12. | 
|  | hour = 12; | 
|  | } else if (innerCircle && hour != 0) { | 
|  | // Inner circle is 13 through 23 and 0. | 
|  | hour += 12; | 
|  | } | 
|  | } else if (mAmOrPm == PM) { | 
|  | hour += 12; | 
|  | } | 
|  | return hour; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param hour the hour in 24-hour time or 12-hour time | 
|  | */ | 
|  | private int getDegreesForHour(int hour) { | 
|  | // Convert to be 0-11. | 
|  | if (mIs24HourMode) { | 
|  | if (hour >= 12) { | 
|  | hour -= 12; | 
|  | } | 
|  | } else if (hour == 12) { | 
|  | hour = 0; | 
|  | } | 
|  | return hour * DEGREES_FOR_ONE_HOUR; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param hour the hour in 24-hour time or 12-hour time | 
|  | */ | 
|  | private boolean getInnerCircleForHour(int hour) { | 
|  | return mIs24HourMode && (hour == 0 || hour > 12); | 
|  | } | 
|  |  | 
|  | public void setCurrentMinute(int minute) { | 
|  | setCurrentMinuteInternal(minute, true); | 
|  | } | 
|  |  | 
|  | private void setCurrentMinuteInternal(int minute, boolean callback) { | 
|  | mSelectionDegrees[MINUTES] = (minute % MINUTES_IN_CIRCLE) * DEGREES_FOR_ONE_MINUTE; | 
|  |  | 
|  | invalidate(); | 
|  |  | 
|  | if (callback && mListener != null) { | 
|  | mListener.onValueSelected(MINUTES, minute, false); | 
|  | } | 
|  | } | 
|  |  | 
|  | // Returns minutes in 0-59 range | 
|  | public int getCurrentMinute() { | 
|  | return getMinuteForDegrees(mSelectionDegrees[MINUTES]); | 
|  | } | 
|  |  | 
|  | private int getMinuteForDegrees(int degrees) { | 
|  | return degrees / DEGREES_FOR_ONE_MINUTE; | 
|  | } | 
|  |  | 
|  | private int getDegreesForMinute(int minute) { | 
|  | return minute * DEGREES_FOR_ONE_MINUTE; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Sets whether the picker is showing AM or PM hours. Has no effect when | 
|  | * in 24-hour mode. | 
|  | * | 
|  | * @param amOrPm {@link #AM} or {@link #PM} | 
|  | * @return {@code true} if the value changed from what was previously set, | 
|  | *         or {@code false} otherwise | 
|  | */ | 
|  | public boolean setAmOrPm(int amOrPm) { | 
|  | if (mAmOrPm == amOrPm || mIs24HourMode) { | 
|  | return false; | 
|  | } | 
|  |  | 
|  | mAmOrPm = amOrPm; | 
|  | invalidate(); | 
|  | mTouchHelper.invalidateRoot(); | 
|  | return true; | 
|  | } | 
|  |  | 
|  | public int getAmOrPm() { | 
|  | return mAmOrPm; | 
|  | } | 
|  |  | 
|  | public void showHours(boolean animate) { | 
|  | showPicker(true, animate); | 
|  | } | 
|  |  | 
|  | public void showMinutes(boolean animate) { | 
|  | showPicker(false, animate); | 
|  | } | 
|  |  | 
|  | private void initHoursAndMinutesText() { | 
|  | // Initialize the hours and minutes numbers. | 
|  | for (int i = 0; i < 12; i++) { | 
|  | mHours12Texts[i] = String.format("%d", HOURS_NUMBERS[i]); | 
|  | mInnerHours24Texts[i] = String.format("%02d", HOURS_NUMBERS_24[i]); | 
|  | mOuterHours24Texts[i] = String.format("%d", HOURS_NUMBERS[i]); | 
|  | mMinutesTexts[i] = String.format("%02d", MINUTES_NUMBERS[i]); | 
|  | } | 
|  | } | 
|  |  | 
|  | private void initData() { | 
|  | if (mIs24HourMode) { | 
|  | mOuterTextHours = mOuterHours24Texts; | 
|  | mInnerTextHours = mInnerHours24Texts; | 
|  | } else { | 
|  | mOuterTextHours = mHours12Texts; | 
|  | mInnerTextHours = mHours12Texts; | 
|  | } | 
|  |  | 
|  | mMinutesText = mMinutesTexts; | 
|  | } | 
|  |  | 
|  | @Override | 
|  | protected void onLayout(boolean changed, int left, int top, int right, int bottom) { | 
|  | if (!changed) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | mXCenter = getWidth() / 2; | 
|  | mYCenter = getHeight() / 2; | 
|  | mCircleRadius = Math.min(mXCenter, mYCenter); | 
|  |  | 
|  | mMinDistForInnerNumber = mCircleRadius - mTextInset[HOURS_INNER] - mSelectorRadius; | 
|  | mMaxDistForOuterNumber = mCircleRadius - mTextInset[HOURS] + mSelectorRadius; | 
|  | mHalfwayDist = mCircleRadius - (mTextInset[HOURS] + mTextInset[HOURS_INNER]) / 2; | 
|  |  | 
|  | calculatePositionsHours(); | 
|  | calculatePositionsMinutes(); | 
|  |  | 
|  | mTouchHelper.invalidateRoot(); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public void onDraw(Canvas canvas) { | 
|  | final float alphaMod = mInputEnabled ? 1 : mDisabledAlpha; | 
|  |  | 
|  | drawCircleBackground(canvas); | 
|  |  | 
|  | final Path selectorPath = mSelectorPath; | 
|  | drawSelector(canvas, selectorPath); | 
|  | drawHours(canvas, selectorPath, alphaMod); | 
|  | drawMinutes(canvas, selectorPath, alphaMod); | 
|  | drawCenter(canvas, alphaMod); | 
|  | } | 
|  |  | 
|  | private void showPicker(boolean hours, boolean animate) { | 
|  | if (mShowHours == hours) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | mShowHours = hours; | 
|  |  | 
|  | if (animate) { | 
|  | animatePicker(hours, ANIM_DURATION_NORMAL); | 
|  | } else { | 
|  | // If we have a pending or running animator, cancel it. | 
|  | if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) { | 
|  | mHoursToMinutesAnimator.cancel(); | 
|  | mHoursToMinutesAnimator = null; | 
|  | } | 
|  | mHoursToMinutes = hours ? 0.0f : 1.0f; | 
|  | } | 
|  |  | 
|  | initData(); | 
|  | invalidate(); | 
|  | mTouchHelper.invalidateRoot(); | 
|  | } | 
|  |  | 
|  | private void animatePicker(boolean hoursToMinutes, long duration) { | 
|  | final float target = hoursToMinutes ? HOURS : MINUTES; | 
|  | if (mHoursToMinutes == target) { | 
|  | // If we have a pending or running animator, cancel it. | 
|  | if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) { | 
|  | mHoursToMinutesAnimator.cancel(); | 
|  | mHoursToMinutesAnimator = null; | 
|  | } | 
|  |  | 
|  | // We're already showing the correct picker. | 
|  | return; | 
|  | } | 
|  |  | 
|  | mHoursToMinutesAnimator = ObjectAnimator.ofFloat(this, HOURS_TO_MINUTES, target); | 
|  | mHoursToMinutesAnimator.setAutoCancel(true); | 
|  | mHoursToMinutesAnimator.setDuration(duration); | 
|  | mHoursToMinutesAnimator.start(); | 
|  | } | 
|  |  | 
|  | private void drawCircleBackground(Canvas canvas) { | 
|  | canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaintBackground); | 
|  | } | 
|  |  | 
|  | private void drawHours(Canvas canvas, Path selectorPath, float alphaMod) { | 
|  | final int hoursAlpha = (int) (255f * (1f - mHoursToMinutes) * alphaMod + 0.5f); | 
|  | if (hoursAlpha > 0) { | 
|  | // Exclude the selector region, then draw inner/outer hours with no | 
|  | // activated states. | 
|  | canvas.save(Canvas.CLIP_SAVE_FLAG); | 
|  | canvas.clipPath(selectorPath, Region.Op.DIFFERENCE); | 
|  | drawHoursClipped(canvas, hoursAlpha, false); | 
|  | canvas.restore(); | 
|  |  | 
|  | // Intersect the selector region, then draw minutes with only | 
|  | // activated states. | 
|  | canvas.save(Canvas.CLIP_SAVE_FLAG); | 
|  | canvas.clipPath(selectorPath, Region.Op.INTERSECT); | 
|  | drawHoursClipped(canvas, hoursAlpha, true); | 
|  | canvas.restore(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private void drawHoursClipped(Canvas canvas, int hoursAlpha, boolean showActivated) { | 
|  | // Draw outer hours. | 
|  | drawTextElements(canvas, mTextSize[HOURS], mTypeface, mTextColor[HOURS], mOuterTextHours, | 
|  | mOuterTextX[HOURS], mOuterTextY[HOURS], mPaint[HOURS], hoursAlpha, | 
|  | showActivated && !mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated); | 
|  |  | 
|  | // Draw inner hours (13-00) for 24-hour time. | 
|  | if (mIs24HourMode && mInnerTextHours != null) { | 
|  | drawTextElements(canvas, mTextSize[HOURS_INNER], mTypeface, mTextColor[HOURS_INNER], | 
|  | mInnerTextHours, mInnerTextX, mInnerTextY, mPaint[HOURS], hoursAlpha, | 
|  | showActivated && mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated); | 
|  | } | 
|  | } | 
|  |  | 
|  | private void drawMinutes(Canvas canvas, Path selectorPath, float alphaMod) { | 
|  | final int minutesAlpha = (int) (255f * mHoursToMinutes * alphaMod + 0.5f); | 
|  | if (minutesAlpha > 0) { | 
|  | // Exclude the selector region, then draw minutes with no | 
|  | // activated states. | 
|  | canvas.save(Canvas.CLIP_SAVE_FLAG); | 
|  | canvas.clipPath(selectorPath, Region.Op.DIFFERENCE); | 
|  | drawMinutesClipped(canvas, minutesAlpha, false); | 
|  | canvas.restore(); | 
|  |  | 
|  | // Intersect the selector region, then draw minutes with only | 
|  | // activated states. | 
|  | canvas.save(Canvas.CLIP_SAVE_FLAG); | 
|  | canvas.clipPath(selectorPath, Region.Op.INTERSECT); | 
|  | drawMinutesClipped(canvas, minutesAlpha, true); | 
|  | canvas.restore(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private void drawMinutesClipped(Canvas canvas, int minutesAlpha, boolean showActivated) { | 
|  | drawTextElements(canvas, mTextSize[MINUTES], mTypeface, mTextColor[MINUTES], mMinutesText, | 
|  | mOuterTextX[MINUTES], mOuterTextY[MINUTES], mPaint[MINUTES], minutesAlpha, | 
|  | showActivated, mSelectionDegrees[MINUTES], showActivated); | 
|  | } | 
|  |  | 
|  | private void drawCenter(Canvas canvas, float alphaMod) { | 
|  | mPaintCenter.setAlpha((int) (255 * alphaMod + 0.5f)); | 
|  | canvas.drawCircle(mXCenter, mYCenter, mCenterDotRadius, mPaintCenter); | 
|  | } | 
|  |  | 
|  | private int getMultipliedAlpha(int argb, int alpha) { | 
|  | return (int) (Color.alpha(argb) * (alpha / 255.0) + 0.5); | 
|  | } | 
|  |  | 
|  | private void drawSelector(Canvas canvas, Path selectorPath) { | 
|  | // Determine the current length, angle, and dot scaling factor. | 
|  | final int hoursIndex = mIsOnInnerCircle ? HOURS_INNER : HOURS; | 
|  | final int hoursInset = mTextInset[hoursIndex]; | 
|  | final int hoursAngleDeg = mSelectionDegrees[hoursIndex % 2]; | 
|  | final float hoursDotScale = mSelectionDegrees[hoursIndex % 2] % 30 != 0 ? 1 : 0; | 
|  |  | 
|  | final int minutesIndex = MINUTES; | 
|  | final int minutesInset = mTextInset[minutesIndex]; | 
|  | final int minutesAngleDeg = mSelectionDegrees[minutesIndex]; | 
|  | final float minutesDotScale = mSelectionDegrees[minutesIndex] % 30 != 0 ? 1 : 0; | 
|  |  | 
|  | // Calculate the current radius at which to place the selection circle. | 
|  | final int selRadius = mSelectorRadius; | 
|  | final float selLength = | 
|  | mCircleRadius - MathUtils.lerp(hoursInset, minutesInset, mHoursToMinutes); | 
|  | final double selAngleRad = | 
|  | Math.toRadians(MathUtils.lerpDeg(hoursAngleDeg, minutesAngleDeg, mHoursToMinutes)); | 
|  | final float selCenterX = mXCenter + selLength * (float) Math.sin(selAngleRad); | 
|  | final float selCenterY = mYCenter - selLength * (float) Math.cos(selAngleRad); | 
|  |  | 
|  | // Draw the selection circle. | 
|  | final Paint paint = mPaintSelector[SELECTOR_CIRCLE]; | 
|  | paint.setColor(mSelectorColor); | 
|  | canvas.drawCircle(selCenterX, selCenterY, selRadius, paint); | 
|  |  | 
|  | // If needed, set up the clip path for later. | 
|  | if (selectorPath != null) { | 
|  | selectorPath.reset(); | 
|  | selectorPath.addCircle(selCenterX, selCenterY, selRadius, Path.Direction.CCW); | 
|  | } | 
|  |  | 
|  | // Draw the dot if we're between two items. | 
|  | final float dotScale = MathUtils.lerp(hoursDotScale, minutesDotScale, mHoursToMinutes); | 
|  | if (dotScale > 0) { | 
|  | final Paint dotPaint = mPaintSelector[SELECTOR_DOT]; | 
|  | dotPaint.setColor(mSelectorDotColor); | 
|  | canvas.drawCircle(selCenterX, selCenterY, mSelectorDotRadius * dotScale, dotPaint); | 
|  | } | 
|  |  | 
|  | // Shorten the line to only go from the edge of the center dot to the | 
|  | // edge of the selection circle. | 
|  | final double sin = Math.sin(selAngleRad); | 
|  | final double cos = Math.cos(selAngleRad); | 
|  | final float lineLength = selLength - selRadius; | 
|  | final int centerX = mXCenter + (int) (mCenterDotRadius * sin); | 
|  | final int centerY = mYCenter - (int) (mCenterDotRadius * cos); | 
|  | final float linePointX = centerX + (int) (lineLength * sin); | 
|  | final float linePointY = centerY - (int) (lineLength * cos); | 
|  |  | 
|  | // Draw the line. | 
|  | final Paint linePaint = mPaintSelector[SELECTOR_LINE]; | 
|  | linePaint.setColor(mSelectorColor); | 
|  | linePaint.setStrokeWidth(mSelectorStroke); | 
|  | canvas.drawLine(mXCenter, mYCenter, linePointX, linePointY, linePaint); | 
|  | } | 
|  |  | 
|  | private void calculatePositionsHours() { | 
|  | // Calculate the text positions | 
|  | final float numbersRadius = mCircleRadius - mTextInset[HOURS]; | 
|  |  | 
|  | // Calculate the positions for the 12 numbers in the main circle. | 
|  | calculatePositions(mPaint[HOURS], numbersRadius, mXCenter, mYCenter, | 
|  | mTextSize[HOURS], mOuterTextX[HOURS], mOuterTextY[HOURS]); | 
|  |  | 
|  | // If we have an inner circle, calculate those positions too. | 
|  | if (mIs24HourMode) { | 
|  | final int innerNumbersRadius = mCircleRadius - mTextInset[HOURS_INNER]; | 
|  | calculatePositions(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter, | 
|  | mTextSize[HOURS_INNER], mInnerTextX, mInnerTextY); | 
|  | } | 
|  | } | 
|  |  | 
|  | private void calculatePositionsMinutes() { | 
|  | // Calculate the text positions | 
|  | final float numbersRadius = mCircleRadius - mTextInset[MINUTES]; | 
|  |  | 
|  | // Calculate the positions for the 12 numbers in the main circle. | 
|  | calculatePositions(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter, | 
|  | mTextSize[MINUTES], mOuterTextX[MINUTES], mOuterTextY[MINUTES]); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Using the trigonometric Unit Circle, calculate the positions that the text will need to be | 
|  | * drawn at based on the specified circle radius. Place the values in the textGridHeights and | 
|  | * textGridWidths parameters. | 
|  | */ | 
|  | private static void calculatePositions(Paint paint, float radius, float xCenter, float yCenter, | 
|  | float textSize, float[] x, float[] y) { | 
|  | // Adjust yCenter to account for the text's baseline. | 
|  | paint.setTextSize(textSize); | 
|  | yCenter -= (paint.descent() + paint.ascent()) / 2; | 
|  |  | 
|  | for (int i = 0; i < NUM_POSITIONS; i++) { | 
|  | x[i] = xCenter - radius * COS_30[i]; | 
|  | y[i] = yCenter - radius * SIN_30[i]; | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Draw the 12 text values at the positions specified by the textGrid parameters. | 
|  | */ | 
|  | private void drawTextElements(Canvas canvas, float textSize, Typeface typeface, | 
|  | ColorStateList textColor, String[] texts, float[] textX, float[] textY, Paint paint, | 
|  | int alpha, boolean showActivated, int activatedDegrees, boolean activatedOnly) { | 
|  | paint.setTextSize(textSize); | 
|  | paint.setTypeface(typeface); | 
|  |  | 
|  | // The activated index can touch a range of elements. | 
|  | final float activatedIndex = activatedDegrees / (360.0f / NUM_POSITIONS); | 
|  | final int activatedFloor = (int) activatedIndex; | 
|  | final int activatedCeil = ((int) Math.ceil(activatedIndex)) % NUM_POSITIONS; | 
|  |  | 
|  | for (int i = 0; i < 12; i++) { | 
|  | final boolean activated = (activatedFloor == i || activatedCeil == i); | 
|  | if (activatedOnly && !activated) { | 
|  | continue; | 
|  | } | 
|  |  | 
|  | final int stateMask = StateSet.VIEW_STATE_ENABLED | 
|  | | (showActivated && activated ? StateSet.VIEW_STATE_ACTIVATED : 0); | 
|  | final int color = textColor.getColorForState(StateSet.get(stateMask), 0); | 
|  | paint.setColor(color); | 
|  | paint.setAlpha(getMultipliedAlpha(color, alpha)); | 
|  |  | 
|  | canvas.drawText(texts[i], textX[i], textY[i], paint); | 
|  | } | 
|  | } | 
|  |  | 
|  | private int getDegreesFromXY(float x, float y, boolean constrainOutside) { | 
|  | // Ensure the point is inside the touchable area. | 
|  | final int innerBound; | 
|  | final int outerBound; | 
|  | if (mIs24HourMode && mShowHours) { | 
|  | innerBound = mMinDistForInnerNumber; | 
|  | outerBound = mMaxDistForOuterNumber; | 
|  | } else { | 
|  | final int index = mShowHours ? HOURS : MINUTES; | 
|  | final int center = mCircleRadius - mTextInset[index]; | 
|  | innerBound = center - mSelectorRadius; | 
|  | outerBound = center + mSelectorRadius; | 
|  | } | 
|  |  | 
|  | final double dX = x - mXCenter; | 
|  | final double dY = y - mYCenter; | 
|  | final double distFromCenter = Math.sqrt(dX * dX + dY * dY); | 
|  | if (distFromCenter < innerBound || constrainOutside && distFromCenter > outerBound) { | 
|  | return -1; | 
|  | } | 
|  |  | 
|  | // Convert to degrees. | 
|  | final int degrees = (int) (Math.toDegrees(Math.atan2(dY, dX) + Math.PI / 2) + 0.5); | 
|  | if (degrees < 0) { | 
|  | return degrees + 360; | 
|  | } else { | 
|  | return degrees; | 
|  | } | 
|  | } | 
|  |  | 
|  | private boolean getInnerCircleFromXY(float x, float y) { | 
|  | if (mIs24HourMode && mShowHours) { | 
|  | final double dX = x - mXCenter; | 
|  | final double dY = y - mYCenter; | 
|  | final double distFromCenter = Math.sqrt(dX * dX + dY * dY); | 
|  | return distFromCenter <= mHalfwayDist; | 
|  | } | 
|  | return false; | 
|  | } | 
|  |  | 
|  | boolean mChangedDuringTouch = false; | 
|  |  | 
|  | @Override | 
|  | public boolean onTouchEvent(MotionEvent event) { | 
|  | if (!mInputEnabled) { | 
|  | return true; | 
|  | } | 
|  |  | 
|  | final int action = event.getActionMasked(); | 
|  | if (action == MotionEvent.ACTION_MOVE | 
|  | || action == MotionEvent.ACTION_UP | 
|  | || action == MotionEvent.ACTION_DOWN) { | 
|  | boolean forceSelection = false; | 
|  | boolean autoAdvance = false; | 
|  |  | 
|  | if (action == MotionEvent.ACTION_DOWN) { | 
|  | // This is a new event stream, reset whether the value changed. | 
|  | mChangedDuringTouch = false; | 
|  | } else if (action == MotionEvent.ACTION_UP) { | 
|  | autoAdvance = true; | 
|  |  | 
|  | // If we saw a down/up pair without the value changing, assume | 
|  | // this is a single-tap selection and force a change. | 
|  | if (!mChangedDuringTouch) { | 
|  | forceSelection = true; | 
|  | } | 
|  | } | 
|  |  | 
|  | mChangedDuringTouch |= handleTouchInput( | 
|  | event.getX(), event.getY(), forceSelection, autoAdvance); | 
|  | } | 
|  |  | 
|  | return true; | 
|  | } | 
|  |  | 
|  | private boolean handleTouchInput( | 
|  | float x, float y, boolean forceSelection, boolean autoAdvance) { | 
|  | final boolean isOnInnerCircle = getInnerCircleFromXY(x, y); | 
|  | final int degrees = getDegreesFromXY(x, y, false); | 
|  | if (degrees == -1) { | 
|  | return false; | 
|  | } | 
|  |  | 
|  | // Ensure we're showing the correct picker. | 
|  | animatePicker(mShowHours, ANIM_DURATION_TOUCH); | 
|  |  | 
|  | final @PickerType int type; | 
|  | final int newValue; | 
|  | final boolean valueChanged; | 
|  |  | 
|  | if (mShowHours) { | 
|  | final int snapDegrees = snapOnly30s(degrees, 0) % 360; | 
|  | valueChanged = mIsOnInnerCircle != isOnInnerCircle | 
|  | || mSelectionDegrees[HOURS] != snapDegrees; | 
|  | mIsOnInnerCircle = isOnInnerCircle; | 
|  | mSelectionDegrees[HOURS] = snapDegrees; | 
|  | type = HOURS; | 
|  | newValue = getCurrentHour(); | 
|  | } else { | 
|  | final int snapDegrees = snapPrefer30s(degrees) % 360; | 
|  | valueChanged = mSelectionDegrees[MINUTES] != snapDegrees; | 
|  | mSelectionDegrees[MINUTES] = snapDegrees; | 
|  | type = MINUTES; | 
|  | newValue = getCurrentMinute(); | 
|  | } | 
|  |  | 
|  | if (valueChanged || forceSelection || autoAdvance) { | 
|  | // Fire the listener even if we just need to auto-advance. | 
|  | if (mListener != null) { | 
|  | mListener.onValueSelected(type, newValue, autoAdvance); | 
|  | } | 
|  |  | 
|  | // Only provide feedback if the value actually changed. | 
|  | if (valueChanged || forceSelection) { | 
|  | performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); | 
|  | invalidate(); | 
|  | } | 
|  | return true; | 
|  | } | 
|  |  | 
|  | return false; | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public boolean dispatchHoverEvent(MotionEvent event) { | 
|  | // First right-of-refusal goes the touch exploration helper. | 
|  | if (mTouchHelper.dispatchHoverEvent(event)) { | 
|  | return true; | 
|  | } | 
|  | return super.dispatchHoverEvent(event); | 
|  | } | 
|  |  | 
|  | public void setInputEnabled(boolean inputEnabled) { | 
|  | mInputEnabled = inputEnabled; | 
|  | invalidate(); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) { | 
|  | if (!isEnabled()) { | 
|  | return null; | 
|  | } | 
|  | final int degrees = getDegreesFromXY(event.getX(), event.getY(), false); | 
|  | if (degrees != -1) { | 
|  | return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND); | 
|  | } | 
|  | return super.onResolvePointerIcon(event, pointerIndex); | 
|  | } | 
|  |  | 
|  | private class RadialPickerTouchHelper extends ExploreByTouchHelper { | 
|  | private final Rect mTempRect = new Rect(); | 
|  |  | 
|  | private final int TYPE_HOUR = 1; | 
|  | private final int TYPE_MINUTE = 2; | 
|  |  | 
|  | private final int SHIFT_TYPE = 0; | 
|  | private final int MASK_TYPE = 0xF; | 
|  |  | 
|  | private final int SHIFT_VALUE = 8; | 
|  | private final int MASK_VALUE = 0xFF; | 
|  |  | 
|  | /** Increment in which virtual views are exposed for minutes. */ | 
|  | private final int MINUTE_INCREMENT = 5; | 
|  |  | 
|  | public RadialPickerTouchHelper() { | 
|  | super(RadialTimePickerView.this); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { | 
|  | super.onInitializeAccessibilityNodeInfo(host, info); | 
|  |  | 
|  | info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); | 
|  | info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public boolean performAccessibilityAction(View host, int action, Bundle arguments) { | 
|  | if (super.performAccessibilityAction(host, action, arguments)) { | 
|  | return true; | 
|  | } | 
|  |  | 
|  | switch (action) { | 
|  | case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: | 
|  | adjustPicker(1); | 
|  | return true; | 
|  | case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: | 
|  | adjustPicker(-1); | 
|  | return true; | 
|  | } | 
|  |  | 
|  | return false; | 
|  | } | 
|  |  | 
|  | private void adjustPicker(int step) { | 
|  | final int stepSize; | 
|  | final int initialStep; | 
|  | final int maxValue; | 
|  | final int minValue; | 
|  | if (mShowHours) { | 
|  | stepSize = 1; | 
|  |  | 
|  | final int currentHour24 = getCurrentHour(); | 
|  | if (mIs24HourMode) { | 
|  | initialStep = currentHour24; | 
|  | minValue = 0; | 
|  | maxValue = 23; | 
|  | } else { | 
|  | initialStep = hour24To12(currentHour24); | 
|  | minValue = 1; | 
|  | maxValue = 12; | 
|  | } | 
|  | } else { | 
|  | stepSize = 5; | 
|  | initialStep = getCurrentMinute() / stepSize; | 
|  | minValue = 0; | 
|  | maxValue = 55; | 
|  | } | 
|  |  | 
|  | final int nextValue = (initialStep + step) * stepSize; | 
|  | final int clampedValue = MathUtils.constrain(nextValue, minValue, maxValue); | 
|  | if (mShowHours) { | 
|  | setCurrentHour(clampedValue); | 
|  | } else { | 
|  | setCurrentMinute(clampedValue); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Override | 
|  | protected int getVirtualViewAt(float x, float y) { | 
|  | final int id; | 
|  | final int degrees = getDegreesFromXY(x, y, true); | 
|  | if (degrees != -1) { | 
|  | final int snapDegrees = snapOnly30s(degrees, 0) % 360; | 
|  | if (mShowHours) { | 
|  | final boolean isOnInnerCircle = getInnerCircleFromXY(x, y); | 
|  | final int hour24 = getHourForDegrees(snapDegrees, isOnInnerCircle); | 
|  | final int hour = mIs24HourMode ? hour24 : hour24To12(hour24); | 
|  | id = makeId(TYPE_HOUR, hour); | 
|  | } else { | 
|  | final int current = getCurrentMinute(); | 
|  | final int touched = getMinuteForDegrees(degrees); | 
|  | final int snapped = getMinuteForDegrees(snapDegrees); | 
|  |  | 
|  | // If the touched minute is closer to the current minute | 
|  | // than it is to the snapped minute, return current. | 
|  | final int currentOffset = getCircularDiff(current, touched, MINUTES_IN_CIRCLE); | 
|  | final int snappedOffset = getCircularDiff(snapped, touched, MINUTES_IN_CIRCLE); | 
|  | final int minute; | 
|  | if (currentOffset < snappedOffset) { | 
|  | minute = current; | 
|  | } else { | 
|  | minute = snapped; | 
|  | } | 
|  | id = makeId(TYPE_MINUTE, minute); | 
|  | } | 
|  | } else { | 
|  | id = INVALID_ID; | 
|  | } | 
|  |  | 
|  | return id; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Returns the difference in degrees between two values along a circle. | 
|  | * | 
|  | * @param first value in the range [0,max] | 
|  | * @param second value in the range [0,max] | 
|  | * @param max the maximum value along the circle | 
|  | * @return the difference in between the two values | 
|  | */ | 
|  | private int getCircularDiff(int first, int second, int max) { | 
|  | final int diff = Math.abs(first - second); | 
|  | final int midpoint = max / 2; | 
|  | return (diff > midpoint) ? (max - diff) : diff; | 
|  | } | 
|  |  | 
|  | @Override | 
|  | protected void getVisibleVirtualViews(IntArray virtualViewIds) { | 
|  | if (mShowHours) { | 
|  | final int min = mIs24HourMode ? 0 : 1; | 
|  | final int max = mIs24HourMode ? 23 : 12; | 
|  | for (int i = min; i <= max ; i++) { | 
|  | virtualViewIds.add(makeId(TYPE_HOUR, i)); | 
|  | } | 
|  | } else { | 
|  | final int current = getCurrentMinute(); | 
|  | for (int i = 0; i < MINUTES_IN_CIRCLE; i += MINUTE_INCREMENT) { | 
|  | virtualViewIds.add(makeId(TYPE_MINUTE, i)); | 
|  |  | 
|  | // If the current minute falls between two increments, | 
|  | // insert an extra node for it. | 
|  | if (current > i && current < i + MINUTE_INCREMENT) { | 
|  | virtualViewIds.add(makeId(TYPE_MINUTE, current)); | 
|  | } | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | @Override | 
|  | protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) { | 
|  | event.setClassName(getClass().getName()); | 
|  |  | 
|  | final int type = getTypeFromId(virtualViewId); | 
|  | final int value = getValueFromId(virtualViewId); | 
|  | final CharSequence description = getVirtualViewDescription(type, value); | 
|  | event.setContentDescription(description); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) { | 
|  | node.setClassName(getClass().getName()); | 
|  | node.addAction(AccessibilityAction.ACTION_CLICK); | 
|  |  | 
|  | final int type = getTypeFromId(virtualViewId); | 
|  | final int value = getValueFromId(virtualViewId); | 
|  | final CharSequence description = getVirtualViewDescription(type, value); | 
|  | node.setContentDescription(description); | 
|  |  | 
|  | getBoundsForVirtualView(virtualViewId, mTempRect); | 
|  | node.setBoundsInParent(mTempRect); | 
|  |  | 
|  | final boolean selected = isVirtualViewSelected(type, value); | 
|  | node.setSelected(selected); | 
|  |  | 
|  | final int nextId = getVirtualViewIdAfter(type, value); | 
|  | if (nextId != INVALID_ID) { | 
|  | node.setTraversalBefore(RadialTimePickerView.this, nextId); | 
|  | } | 
|  | } | 
|  |  | 
|  | private int getVirtualViewIdAfter(int type, int value) { | 
|  | if (type == TYPE_HOUR) { | 
|  | final int nextValue = value + 1; | 
|  | final int max = mIs24HourMode ? 23 : 12; | 
|  | if (nextValue <= max) { | 
|  | return makeId(type, nextValue); | 
|  | } | 
|  | } else if (type == TYPE_MINUTE) { | 
|  | final int current = getCurrentMinute(); | 
|  | final int snapValue = value - (value % MINUTE_INCREMENT); | 
|  | final int nextValue = snapValue + MINUTE_INCREMENT; | 
|  | if (value < current && nextValue > current) { | 
|  | // The current value is between two snap values. | 
|  | return makeId(type, current); | 
|  | } else if (nextValue < MINUTES_IN_CIRCLE) { | 
|  | return makeId(type, nextValue); | 
|  | } | 
|  | } | 
|  | return INVALID_ID; | 
|  | } | 
|  |  | 
|  | @Override | 
|  | protected boolean onPerformActionForVirtualView(int virtualViewId, int action, | 
|  | Bundle arguments) { | 
|  | if (action == AccessibilityNodeInfo.ACTION_CLICK) { | 
|  | final int type = getTypeFromId(virtualViewId); | 
|  | final int value = getValueFromId(virtualViewId); | 
|  | if (type == TYPE_HOUR) { | 
|  | final int hour = mIs24HourMode ? value : hour12To24(value, mAmOrPm); | 
|  | setCurrentHour(hour); | 
|  | return true; | 
|  | } else if (type == TYPE_MINUTE) { | 
|  | setCurrentMinute(value); | 
|  | return true; | 
|  | } | 
|  | } | 
|  | return false; | 
|  | } | 
|  |  | 
|  | private int hour12To24(int hour12, int amOrPm) { | 
|  | int hour24 = hour12; | 
|  | if (hour12 == 12) { | 
|  | if (amOrPm == AM) { | 
|  | hour24 = 0; | 
|  | } | 
|  | } else if (amOrPm == PM) { | 
|  | hour24 += 12; | 
|  | } | 
|  | return hour24; | 
|  | } | 
|  |  | 
|  | private int hour24To12(int hour24) { | 
|  | if (hour24 == 0) { | 
|  | return 12; | 
|  | } else if (hour24 > 12) { | 
|  | return hour24 - 12; | 
|  | } else { | 
|  | return hour24; | 
|  | } | 
|  | } | 
|  |  | 
|  | private void getBoundsForVirtualView(int virtualViewId, Rect bounds) { | 
|  | final float radius; | 
|  | final int type = getTypeFromId(virtualViewId); | 
|  | final int value = getValueFromId(virtualViewId); | 
|  | final float centerRadius; | 
|  | final float degrees; | 
|  | if (type == TYPE_HOUR) { | 
|  | final boolean innerCircle = getInnerCircleForHour(value); | 
|  | if (innerCircle) { | 
|  | centerRadius = mCircleRadius - mTextInset[HOURS_INNER]; | 
|  | radius = mSelectorRadius; | 
|  | } else { | 
|  | centerRadius = mCircleRadius - mTextInset[HOURS]; | 
|  | radius = mSelectorRadius; | 
|  | } | 
|  |  | 
|  | degrees = getDegreesForHour(value); | 
|  | } else if (type == TYPE_MINUTE) { | 
|  | centerRadius = mCircleRadius - mTextInset[MINUTES]; | 
|  | degrees = getDegreesForMinute(value); | 
|  | radius = mSelectorRadius; | 
|  | } else { | 
|  | // This should never happen. | 
|  | centerRadius = 0; | 
|  | degrees = 0; | 
|  | radius = 0; | 
|  | } | 
|  |  | 
|  | final double radians = Math.toRadians(degrees); | 
|  | final float xCenter = mXCenter + centerRadius * (float) Math.sin(radians); | 
|  | final float yCenter = mYCenter - centerRadius * (float) Math.cos(radians); | 
|  |  | 
|  | bounds.set((int) (xCenter - radius), (int) (yCenter - radius), | 
|  | (int) (xCenter + radius), (int) (yCenter + radius)); | 
|  | } | 
|  |  | 
|  | private CharSequence getVirtualViewDescription(int type, int value) { | 
|  | final CharSequence description; | 
|  | if (type == TYPE_HOUR || type == TYPE_MINUTE) { | 
|  | description = Integer.toString(value); | 
|  | } else { | 
|  | description = null; | 
|  | } | 
|  | return description; | 
|  | } | 
|  |  | 
|  | private boolean isVirtualViewSelected(int type, int value) { | 
|  | final boolean selected; | 
|  | if (type == TYPE_HOUR) { | 
|  | selected = getCurrentHour() == value; | 
|  | } else if (type == TYPE_MINUTE) { | 
|  | selected = getCurrentMinute() == value; | 
|  | } else { | 
|  | selected = false; | 
|  | } | 
|  | return selected; | 
|  | } | 
|  |  | 
|  | private int makeId(int type, int value) { | 
|  | return type << SHIFT_TYPE | value << SHIFT_VALUE; | 
|  | } | 
|  |  | 
|  | private int getTypeFromId(int id) { | 
|  | return id >>> SHIFT_TYPE & MASK_TYPE; | 
|  | } | 
|  |  | 
|  | private int getValueFromId(int id) { | 
|  | return id >>> SHIFT_VALUE & MASK_VALUE; | 
|  | } | 
|  | } | 
|  | } |