com.customdatepicker.time.RadialPickerLayout.java Source code

Java tutorial

Introduction

Here is the source code for com.customdatepicker.time.RadialPickerLayout.java

Source

/*
 * 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 com.customdatepicker.time;

import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.v4.content.ContextCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.FrameLayout;

import com.customdatepicker.R;

import java.util.Calendar;
import java.util.Locale;

/**
 * The primary layout to hold the circular picker, and the am/pm buttons. This view will measure
 * itself to end up as a square. It also handles touches to be passed in to views that need to know
 * when they'd been touched.
 */
public class RadialPickerLayout extends FrameLayout implements OnTouchListener {
    private static final String TAG = "RadialPickerLayout";

    private final int TOUCH_SLOP;
    private final int TAP_TIMEOUT;

    private static final int VISIBLE_DEGREES_STEP_SIZE = 30;
    private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = VISIBLE_DEGREES_STEP_SIZE;
    private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6;
    private static final int SECOND_VALUE_TO_DEGREES_STEP_SIZE = 6;
    private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX;
    private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX;
    private static final int SECOND_INDEX = TimePickerDialog.SECOND_INDEX;
    private static final int AM = TimePickerDialog.AM;
    private static final int PM = TimePickerDialog.PM;

    private Timepoint mLastValueSelected;

    private TimePickerController mController;
    private OnValueSelectedListener mListener;
    private boolean mTimeInitialized;
    private Timepoint mCurrentTime;
    private boolean mIs24HourMode;
    private int mCurrentItemShowing;

    private CircleView mCircleView;
    private AmPmCirclesView mAmPmCirclesView;
    private RadialTextsView mHourRadialTextsView;
    private RadialTextsView mMinuteRadialTextsView;
    private RadialTextsView mSecondRadialTextsView;
    private RadialSelectorView mHourRadialSelectorView;
    private RadialSelectorView mMinuteRadialSelectorView;
    private RadialSelectorView mSecondRadialSelectorView;
    private View mGrayBox;

    private int[] mSnapPrefer30sMap;
    private boolean mInputEnabled;
    private int mIsTouchingAmOrPm = -1;
    private boolean mDoingMove;
    private boolean mDoingTouch;
    private int mDownDegrees;
    private float mDownX;
    private float mDownY;
    private AccessibilityManager mAccessibilityManager;

    private AnimatorSet mTransition;
    private Handler mHandler = new Handler();

    public interface OnValueSelectedListener {
        void onValueSelected(Timepoint newTime);

        void enablePicker();

        void advancePicker(int index);
    }

    public RadialPickerLayout(Context context, AttributeSet attrs) {
        super(context, attrs);

        setOnTouchListener(this);
        ViewConfiguration vc = ViewConfiguration.get(context);
        TOUCH_SLOP = vc.getScaledTouchSlop();
        TAP_TIMEOUT = ViewConfiguration.getTapTimeout();
        mDoingMove = false;

        mCircleView = new CircleView(context);
        addView(mCircleView);

        mAmPmCirclesView = new AmPmCirclesView(context);
        addView(mAmPmCirclesView);

        mHourRadialSelectorView = new RadialSelectorView(context);
        addView(mHourRadialSelectorView);
        mMinuteRadialSelectorView = new RadialSelectorView(context);
        addView(mMinuteRadialSelectorView);
        mSecondRadialSelectorView = new RadialSelectorView(context);
        addView(mSecondRadialSelectorView);

        mHourRadialTextsView = new RadialTextsView(context);
        addView(mHourRadialTextsView);
        mMinuteRadialTextsView = new RadialTextsView(context);
        addView(mMinuteRadialTextsView);
        mSecondRadialTextsView = new RadialTextsView(context);
        addView(mSecondRadialTextsView);

        // Prepare mapping to snap touchable degrees to selectable degrees.
        preparePrefer30sMap();

        mLastValueSelected = null;

        mInputEnabled = true;

        mGrayBox = new View(context);
        mGrayBox.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT));
        mGrayBox.setBackgroundColor(ContextCompat.getColor(context, R.color.mdtp_transparent_black));
        mGrayBox.setVisibility(View.INVISIBLE);
        addView(mGrayBox);

        mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);

        mTimeInitialized = false;
    }

    public void setOnValueSelectedListener(OnValueSelectedListener listener) {
        mListener = listener;
    }

    /**
     * Initialize the Layout with starting values.
     * @param context A context needed to inflate resources
     * @param initialTime The initial selection of the Timepicker
     * @param is24HourMode Indicates whether we should render in 24hour mode or with AM/PM selectors
     */
    public void initialize(Context context, TimePickerController timePickerController, Timepoint initialTime,
            boolean is24HourMode) {
        if (mTimeInitialized) {
            Log.e(TAG, "Time has already been initialized.");
            return;
        }

        mController = timePickerController;
        mIs24HourMode = mAccessibilityManager.isTouchExplorationEnabled() || is24HourMode;

        // Initialize the circle and AM/PM circles if applicable.
        mCircleView.initialize(context, mController);
        mCircleView.invalidate();
        if (!mIs24HourMode && mController.getVersion() == TimePickerDialog.Version.VERSION_1) {
            mAmPmCirclesView.initialize(context, mController, initialTime.isAM() ? AM : PM);
            mAmPmCirclesView.invalidate();
        }

        // Create the selection validators
        RadialTextsView.SelectionValidator secondValidator = new RadialTextsView.SelectionValidator() {
            @Override
            public boolean isValidSelection(int selection) {
                Timepoint newTime = new Timepoint(mCurrentTime.getHour(), mCurrentTime.getMinute(), selection);
                return !mController.isOutOfRange(newTime, SECOND_INDEX);
            }
        };
        RadialTextsView.SelectionValidator minuteValidator = new RadialTextsView.SelectionValidator() {
            @Override
            public boolean isValidSelection(int selection) {
                Timepoint newTime = new Timepoint(mCurrentTime.getHour(), selection, mCurrentTime.getSecond());
                return !mController.isOutOfRange(newTime, MINUTE_INDEX);
            }
        };
        RadialTextsView.SelectionValidator hourValidator = new RadialTextsView.SelectionValidator() {
            @Override
            public boolean isValidSelection(int selection) {
                Timepoint newTime = new Timepoint(selection, mCurrentTime.getMinute(), mCurrentTime.getSecond());
                if (!mIs24HourMode && getIsCurrentlyAmOrPm() == PM)
                    newTime.setPM();
                if (!mIs24HourMode && getIsCurrentlyAmOrPm() == AM)
                    newTime.setAM();
                return !mController.isOutOfRange(newTime, HOUR_INDEX);
            }
        };

        // Initialize the hours and minutes numbers.
        int[] hours = { 12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
        int[] hours_24 = { 0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 };
        int[] minutes = { 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55 };
        int[] seconds = { 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55 };
        String[] hoursTexts = new String[12];
        String[] innerHoursTexts = new String[12];
        String[] minutesTexts = new String[12];
        String[] secondsTexts = new String[12];
        for (int i = 0; i < 12; i++) {
            hoursTexts[i] = is24HourMode ? String.format(Locale.getDefault(), "%02d", hours_24[i])
                    : String.format(Locale.getDefault(), "%d", hours[i]);
            innerHoursTexts[i] = String.format(Locale.getDefault(), "%d", hours[i]);
            minutesTexts[i] = String.format(Locale.getDefault(), "%02d", minutes[i]);
            secondsTexts[i] = String.format(Locale.getDefault(), "%02d", seconds[i]);
        }
        mHourRadialTextsView.initialize(context, hoursTexts, (is24HourMode ? innerHoursTexts : null), mController,
                hourValidator, true);
        mHourRadialTextsView.setSelection(is24HourMode ? initialTime.getHour() : hours[initialTime.getHour() % 12]);
        mHourRadialTextsView.invalidate();
        mMinuteRadialTextsView.initialize(context, minutesTexts, null, mController, minuteValidator, false);
        mMinuteRadialTextsView.setSelection(initialTime.getMinute());
        mMinuteRadialTextsView.invalidate();
        mSecondRadialTextsView.initialize(context, secondsTexts, null, mController, secondValidator, false);
        mSecondRadialTextsView.setSelection(initialTime.getSecond());
        mSecondRadialTextsView.invalidate();

        // Initialize the currently-selected hour and minute.
        mCurrentTime = initialTime;
        int hourDegrees = (initialTime.getHour() % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
        mHourRadialSelectorView.initialize(context, mController, is24HourMode, true, hourDegrees,
                isHourInnerCircle(initialTime.getHour()));
        int minuteDegrees = initialTime.getMinute() * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
        mMinuteRadialSelectorView.initialize(context, mController, false, false, minuteDegrees, false);
        int secondDegrees = initialTime.getSecond() * SECOND_VALUE_TO_DEGREES_STEP_SIZE;
        mSecondRadialSelectorView.initialize(context, mController, false, false, secondDegrees, false);

        mTimeInitialized = true;
    }

    public void setTime(Timepoint time) {
        setItem(HOUR_INDEX, time);
    }

    /**
     * Set either the hour, the minute or the second. Will set the internal value, and set the selection.
     */
    private void setItem(int index, Timepoint time) {
        time = roundToValidTime(time, index);
        mCurrentTime = time;
        reselectSelector(time, false, index);
    }

    /**
     * Check if a given hour appears in the outer circle or the inner circle
     * @return true if the hour is in the inner circle, false if it's in the outer circle.
     */
    private boolean isHourInnerCircle(int hourOfDay) {
        // We'll have the 00 hours on the outside circle.
        return mIs24HourMode && (hourOfDay <= 12 && hourOfDay != 0);
    }

    public int getHours() {
        return mCurrentTime.getHour();
    }

    public int getMinutes() {
        return mCurrentTime.getMinute();
    }

    public int getSeconds() {
        return mCurrentTime.getSecond();
    }

    public Timepoint getTime() {
        return mCurrentTime;
    }

    /**
     * If the hours are showing, return the current hour. If the minutes are showing, return the
     * current minute.
     */
    private int getCurrentlyShowingValue() {
        int currentIndex = getCurrentItemShowing();
        switch (currentIndex) {
        case HOUR_INDEX:
            return mCurrentTime.getHour();
        case MINUTE_INDEX:
            return mCurrentTime.getMinute();
        case SECOND_INDEX:
            return mCurrentTime.getSecond();
        default:
            return -1;
        }
    }

    public int getIsCurrentlyAmOrPm() {
        if (mCurrentTime.isAM()) {
            return AM;
        } else if (mCurrentTime.isPM()) {
            return PM;
        }
        return -1;
    }

    /**
     * Set the internal value as either AM or PM, and update the AM/PM circle displays.
     * @param amOrPm Integer representing AM of PM (use the supplied constants)
     */
    public void setAmOrPm(int amOrPm) {
        mAmPmCirclesView.setAmOrPm(amOrPm);
        mAmPmCirclesView.invalidate();
        Timepoint newSelection = new Timepoint(mCurrentTime);
        if (amOrPm == AM)
            newSelection.setAM();
        else if (amOrPm == PM)
            newSelection.setPM();
        newSelection = roundToValidTime(newSelection, HOUR_INDEX);
        reselectSelector(newSelection, false, HOUR_INDEX);
        mCurrentTime = newSelection;
        mListener.onValueSelected(newSelection);
    }

    /**
     * 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 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.

        // Our input will be 0 through 360.
        mSnapPrefer30sMap = new int[361];

        // 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.
            mSnapPrefer30sMap[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 int snapPrefer30s(int degrees) {
        if (mSnapPrefer30sMap == null) {
            return -1;
        }
        return mSnapPrefer30sMap[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) {
        int stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
        int floor = (degrees / stepSize) * stepSize;
        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;
    }

    /**
     * Snap the input to a selectable value
     * @param newSelection Timepoint - Time which should be rounded
     * @param currentItemShowing int - The index of the current view
     * @return Timepoint - the rounded value
     */
    private Timepoint roundToValidTime(Timepoint newSelection, int currentItemShowing) {
        switch (currentItemShowing) {
        case HOUR_INDEX:
            newSelection = mController.roundToNearest(newSelection, Timepoint.TYPE.HOUR);
            break;
        case MINUTE_INDEX:
            newSelection = mController.roundToNearest(newSelection, Timepoint.TYPE.MINUTE);
            break;
        case SECOND_INDEX:
            newSelection = mController.roundToNearest(newSelection, Timepoint.TYPE.SECOND);
            break;
        default:
            newSelection = mCurrentTime;
        }
        return newSelection;
    }

    /**
     * For the currently showing view (either hours, minutes or seconds), re-calculate the position
     * for the selector, and redraw it at that position. The text representing the currently
     * selected value will be redrawn if required.
     * @param newSelection Timpoint - Time which should be selected.
     * @param forceDrawDot The dot in the circle will generally only be shown when the selection
     * @param index The picker to use as a reference. Will be getCurrentItemShow() except when AM/PM is changed
     * is on non-visible values, but use this to force the dot to be shown.
     */
    private void reselectSelector(Timepoint newSelection, boolean forceDrawDot, int index) {
        switch (index) {
        case HOUR_INDEX:
            // The selection might have changed, recalculate the degrees and innerCircle values
            int hour = newSelection.getHour();
            boolean isInnerCircle = isHourInnerCircle(hour);
            int degrees = (hour % 12) * 360 / 12;
            if (!mIs24HourMode)
                hour = hour % 12;
            if (!mIs24HourMode && hour == 0)
                hour += 12;

            mHourRadialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot);
            mHourRadialTextsView.setSelection(hour);
            // If we rounded the minutes, reposition the minuteSelector too.
            if (newSelection.getMinute() != mCurrentTime.getMinute()) {
                int minDegrees = newSelection.getMinute() * 360 / 60;
                mMinuteRadialSelectorView.setSelection(minDegrees, isInnerCircle, forceDrawDot);
                mMinuteRadialTextsView.setSelection(newSelection.getMinute());
            }
            // If we rounded the seconds, reposition the secondSelector too.
            if (newSelection.getSecond() != mCurrentTime.getSecond()) {
                int secDegrees = newSelection.getSecond() * 360 / 60;
                mSecondRadialSelectorView.setSelection(secDegrees, isInnerCircle, forceDrawDot);
                mSecondRadialTextsView.setSelection(newSelection.getSecond());
            }
            break;
        case MINUTE_INDEX:
            // The selection might have changed, recalculate the degrees
            degrees = newSelection.getMinute() * 360 / 60;

            mMinuteRadialSelectorView.setSelection(degrees, false, forceDrawDot);
            mMinuteRadialTextsView.setSelection(newSelection.getMinute());
            // If we rounded the seconds, reposition the secondSelector too.
            if (newSelection.getSecond() != mCurrentTime.getSecond()) {
                int secDegrees = newSelection.getSecond() * 360 / 60;
                mSecondRadialSelectorView.setSelection(secDegrees, false, forceDrawDot);
                mSecondRadialTextsView.setSelection(newSelection.getSecond());
            }
            break;
        case SECOND_INDEX:
            // The selection might have changed, recalculate the degrees
            degrees = newSelection.getSecond() * 360 / 60;
            mSecondRadialSelectorView.setSelection(degrees, false, forceDrawDot);
            mSecondRadialTextsView.setSelection(newSelection.getSecond());
        }

        // Invalidate the currently showing picker to force a redraw
        switch (getCurrentItemShowing()) {
        case HOUR_INDEX:
            mHourRadialSelectorView.invalidate();
            mHourRadialTextsView.invalidate();
            break;
        case MINUTE_INDEX:
            mMinuteRadialSelectorView.invalidate();
            mMinuteRadialTextsView.invalidate();
            break;
        case SECOND_INDEX:
            mSecondRadialSelectorView.invalidate();
            mSecondRadialTextsView.invalidate();
        }
    }

    private Timepoint getTimeFromDegrees(int degrees, boolean isInnerCircle, boolean forceToVisibleValue) {
        if (degrees == -1) {
            return null;
        }
        int currentShowing = getCurrentItemShowing();

        int stepSize;
        boolean allowFineGrained = !forceToVisibleValue
                && (currentShowing == MINUTE_INDEX || currentShowing == SECOND_INDEX);
        if (allowFineGrained) {
            degrees = snapPrefer30s(degrees);
        } else {
            degrees = snapOnly30s(degrees, 0);
        }

        switch (currentShowing) {
        case HOUR_INDEX:
            stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
            break;
        case MINUTE_INDEX:
            stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
            break;
        default:
            stepSize = SECOND_VALUE_TO_DEGREES_STEP_SIZE;
        }

        if (currentShowing == HOUR_INDEX) {
            if (mIs24HourMode) {
                if (degrees == 0 && isInnerCircle) {
                    degrees = 360;
                } else if (degrees == 360 && !isInnerCircle) {
                    degrees = 0;
                }
            } else if (degrees == 0) {
                degrees = 360;
            }
        } else if (degrees == 360 && (currentShowing == MINUTE_INDEX || currentShowing == SECOND_INDEX)) {
            degrees = 0;
        }

        int value = degrees / stepSize;

        if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) {
            value += 12;
        }

        Timepoint newSelection;
        switch (currentShowing) {
        case HOUR_INDEX:
            int hour = value;
            if (!mIs24HourMode && getIsCurrentlyAmOrPm() == PM && degrees != 360)
                hour += 12;
            if (!mIs24HourMode && getIsCurrentlyAmOrPm() == AM && degrees == 360)
                hour = 0;
            newSelection = new Timepoint(hour, mCurrentTime.getMinute(), mCurrentTime.getSecond());
            break;
        case MINUTE_INDEX:
            newSelection = new Timepoint(mCurrentTime.getHour(), value, mCurrentTime.getSecond());
            break;
        case SECOND_INDEX:
            newSelection = new Timepoint(mCurrentTime.getHour(), mCurrentTime.getMinute(), value);
            break;
        default:
            newSelection = mCurrentTime;
        }

        return newSelection;
    }

    /**
     * Calculate the degrees within the circle that corresponds to the specified coordinates, if
     * the coordinates are within the range that will trigger a selection.
     * @param pointX The x coordinate.
     * @param pointY The y coordinate.
     * @param forceLegal Force the selection to be legal, regardless of how far the coordinates are
     * from the actual numbers.
     * @param isInnerCircle If the selection may be in the inner circle, pass in a size-1 boolean
     * array here, inside which the value will be true if the selection is in the inner circle,
     * and false if in the outer circle.
     * @return Degrees from 0 to 360, if the selection was within the legal range. -1 if not.
     */
    private int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal,
            final Boolean[] isInnerCircle) {
        switch (getCurrentItemShowing()) {
        case HOUR_INDEX:
            return mHourRadialSelectorView.getDegreesFromCoords(pointX, pointY, forceLegal, isInnerCircle);
        case MINUTE_INDEX:
            return mMinuteRadialSelectorView.getDegreesFromCoords(pointX, pointY, forceLegal, isInnerCircle);
        case SECOND_INDEX:
            return mSecondRadialSelectorView.getDegreesFromCoords(pointX, pointY, forceLegal, isInnerCircle);
        default:
            return -1;
        }
    }

    /**
     * Get the item (hours, minutes or seconds) that is currently showing.
     */
    public int getCurrentItemShowing() {
        if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX
                && mCurrentItemShowing != SECOND_INDEX) {
            Log.e(TAG, "Current item showing was unfortunately set to " + mCurrentItemShowing);
            return -1;
        }
        return mCurrentItemShowing;
    }

    /**
     * Set either seconds, minutes or hours as showing.
     * @param animate True to animate the transition, false to show with no animation.
     */
    public void setCurrentItemShowing(int index, boolean animate) {
        if (index != HOUR_INDEX && index != MINUTE_INDEX && index != SECOND_INDEX) {
            Log.e(TAG, "TimePicker does not support view at index " + index);
            return;
        }

        int lastIndex = getCurrentItemShowing();
        mCurrentItemShowing = index;
        reselectSelector(getTime(), true, index);

        if (animate && (index != lastIndex)) {
            ObjectAnimator[] anims = new ObjectAnimator[4];
            if (index == MINUTE_INDEX && lastIndex == HOUR_INDEX) {
                anims[0] = mHourRadialTextsView.getDisappearAnimator();
                anims[1] = mHourRadialSelectorView.getDisappearAnimator();
                anims[2] = mMinuteRadialTextsView.getReappearAnimator();
                anims[3] = mMinuteRadialSelectorView.getReappearAnimator();
            } else if (index == HOUR_INDEX && lastIndex == MINUTE_INDEX) {
                anims[0] = mHourRadialTextsView.getReappearAnimator();
                anims[1] = mHourRadialSelectorView.getReappearAnimator();
                anims[2] = mMinuteRadialTextsView.getDisappearAnimator();
                anims[3] = mMinuteRadialSelectorView.getDisappearAnimator();
            } else if (index == MINUTE_INDEX && lastIndex == SECOND_INDEX) {
                anims[0] = mSecondRadialTextsView.getDisappearAnimator();
                anims[1] = mSecondRadialSelectorView.getDisappearAnimator();
                anims[2] = mMinuteRadialTextsView.getReappearAnimator();
                anims[3] = mMinuteRadialSelectorView.getReappearAnimator();
            } else if (index == HOUR_INDEX && lastIndex == SECOND_INDEX) {
                anims[0] = mSecondRadialTextsView.getDisappearAnimator();
                anims[1] = mSecondRadialSelectorView.getDisappearAnimator();
                anims[2] = mHourRadialTextsView.getReappearAnimator();
                anims[3] = mHourRadialSelectorView.getReappearAnimator();
            } else if (index == SECOND_INDEX && lastIndex == MINUTE_INDEX) {
                anims[0] = mSecondRadialTextsView.getReappearAnimator();
                anims[1] = mSecondRadialSelectorView.getReappearAnimator();
                anims[2] = mMinuteRadialTextsView.getDisappearAnimator();
                anims[3] = mMinuteRadialSelectorView.getDisappearAnimator();
            } else if (index == SECOND_INDEX && lastIndex == HOUR_INDEX) {
                anims[0] = mSecondRadialTextsView.getReappearAnimator();
                anims[1] = mSecondRadialSelectorView.getReappearAnimator();
                anims[2] = mHourRadialTextsView.getDisappearAnimator();
                anims[3] = mHourRadialSelectorView.getDisappearAnimator();
            }

            if (anims[0] != null && anims[1] != null && anims[2] != null && anims[3] != null) {
                if (mTransition != null && mTransition.isRunning()) {
                    mTransition.end();
                }
                mTransition = new AnimatorSet();
                mTransition.playTogether(anims);
                mTransition.start();
            } else {
                transitionWithoutAnimation(index);
            }
        } else {
            transitionWithoutAnimation(index);
        }
    }

    private void transitionWithoutAnimation(int index) {
        int hourAlpha = (index == HOUR_INDEX) ? 1 : 0;
        int minuteAlpha = (index == MINUTE_INDEX) ? 1 : 0;
        int secondAlpha = (index == SECOND_INDEX) ? 1 : 0;
        mHourRadialTextsView.setAlpha(hourAlpha);
        mHourRadialSelectorView.setAlpha(hourAlpha);
        mMinuteRadialTextsView.setAlpha(minuteAlpha);
        mMinuteRadialSelectorView.setAlpha(minuteAlpha);
        mSecondRadialTextsView.setAlpha(secondAlpha);
        mSecondRadialSelectorView.setAlpha(secondAlpha);
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        final float eventX = event.getX();
        final float eventY = event.getY();
        int degrees;
        Timepoint value;
        final Boolean[] isInnerCircle = new Boolean[1];
        isInnerCircle[0] = false;

        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if (!mInputEnabled) {
                return true;
            }

            mDownX = eventX;
            mDownY = eventY;

            mLastValueSelected = null;
            mDoingMove = false;
            mDoingTouch = true;
            // If we're showing the AM/PM, check to see if the user is touching it.
            if (!mIs24HourMode && mController.getVersion() == TimePickerDialog.Version.VERSION_1) {
                mIsTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
            } else {
                mIsTouchingAmOrPm = -1;
            }
            if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
                // If the touch is on AM or PM, set it as "touched" after the TAP_TIMEOUT
                // in case the user moves their finger quickly.
                mController.tryVibrate();
                mDownDegrees = -1;
                mHandler.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        mAmPmCirclesView.setAmOrPmPressed(mIsTouchingAmOrPm);
                        mAmPmCirclesView.invalidate();
                    }
                }, TAP_TIMEOUT);
            } else {
                // If we're in accessibility mode, force the touch to be legal. Otherwise,
                // it will only register within the given touch target zone.
                boolean forceLegal = mAccessibilityManager.isTouchExplorationEnabled();
                // Calculate the degrees that is currently being touched.
                mDownDegrees = getDegreesFromCoords(eventX, eventY, forceLegal, isInnerCircle);
                Timepoint selectedTime = getTimeFromDegrees(mDownDegrees, isInnerCircle[0], false);
                if (mController.isOutOfRange(selectedTime, getCurrentItemShowing()))
                    mDownDegrees = -1;
                if (mDownDegrees != -1) {
                    // If it's a legal touch, set that number as "selected" after the
                    // TAP_TIMEOUT in case the user moves their finger quickly.
                    mController.tryVibrate();
                    mHandler.postDelayed(new Runnable() {
                        @Override
                        public void run() {
                            mDoingMove = true;
                            mLastValueSelected = getTimeFromDegrees(mDownDegrees, isInnerCircle[0], false);
                            mLastValueSelected = roundToValidTime(mLastValueSelected, getCurrentItemShowing());
                            // Redraw
                            reselectSelector(mLastValueSelected, true, getCurrentItemShowing());
                            mListener.onValueSelected(mLastValueSelected);
                        }
                    }, TAP_TIMEOUT);
                }
            }
            return true;
        case MotionEvent.ACTION_MOVE:
            if (!mInputEnabled) {
                // We shouldn't be in this state, because input is disabled.
                Log.e(TAG, "Input was disabled, but received ACTION_MOVE.");
                return true;
            }

            float dY = Math.abs(eventY - mDownY);
            float dX = Math.abs(eventX - mDownX);

            if (!mDoingMove && dX <= TOUCH_SLOP && dY <= TOUCH_SLOP) {
                // Hasn't registered down yet, just slight, accidental movement of finger.
                break;
            }

            // If we're in the middle of touching down on AM or PM, check if we still are.
            // If so, no-op. If not, remove its pressed state. Either way, no need to check
            // for touches on the other circle.
            if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
                mHandler.removeCallbacksAndMessages(null);
                int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
                if (isTouchingAmOrPm != mIsTouchingAmOrPm) {
                    mAmPmCirclesView.setAmOrPmPressed(-1);
                    mAmPmCirclesView.invalidate();
                    mIsTouchingAmOrPm = -1;
                }
                break;
            }

            if (mDownDegrees == -1) {
                // Original down was illegal, so no movement will register.
                break;
            }

            // We're doing a move along the circle, so move the selection as appropriate.
            mDoingMove = true;
            mHandler.removeCallbacksAndMessages(null);
            degrees = getDegreesFromCoords(eventX, eventY, true, isInnerCircle);
            if (degrees != -1) {
                switch (getCurrentItemShowing()) {
                case HOUR_INDEX:
                    value = mController.roundToNearest(getTimeFromDegrees(degrees, isInnerCircle[0], false), null);
                    break;
                case MINUTE_INDEX:
                    value = mController.roundToNearest(getTimeFromDegrees(degrees, isInnerCircle[0], false),
                            Timepoint.TYPE.HOUR);
                    break;
                default:
                    value = mController.roundToNearest(getTimeFromDegrees(degrees, isInnerCircle[0], false),
                            Timepoint.TYPE.MINUTE);
                    break;
                }
                reselectSelector(value, true, getCurrentItemShowing());
                if (value != null && (mLastValueSelected == null || !mLastValueSelected.equals(value))) {
                    mController.tryVibrate();
                    mLastValueSelected = value;
                    mListener.onValueSelected(value);
                }
            }
            return true;
        case MotionEvent.ACTION_UP:
            if (!mInputEnabled) {
                // If our touch input was disabled, tell the listener to re-enable us.
                Log.d(TAG, "Input was disabled, but received ACTION_UP.");
                mListener.enablePicker();
                return true;
            }

            mHandler.removeCallbacksAndMessages(null);
            mDoingTouch = false;

            // If we're touching AM or PM, set it as selected, and tell the listener.
            if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
                int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
                mAmPmCirclesView.setAmOrPmPressed(-1);
                mAmPmCirclesView.invalidate();

                if (isTouchingAmOrPm == mIsTouchingAmOrPm) {
                    mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm);
                    if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) {
                        Timepoint newSelection = new Timepoint(mCurrentTime);
                        if (mIsTouchingAmOrPm == AM)
                            newSelection.setAM();
                        else if (mIsTouchingAmOrPm == PM)
                            newSelection.setPM();
                        newSelection = roundToValidTime(newSelection, HOUR_INDEX);
                        reselectSelector(newSelection, false, HOUR_INDEX);
                        mCurrentTime = newSelection;
                        mListener.onValueSelected(newSelection);

                    }
                }
                mIsTouchingAmOrPm = -1;
                break;
            }

            // If we have a legal degrees selected, set the value and tell the listener.
            if (mDownDegrees != -1) {
                degrees = getDegreesFromCoords(eventX, eventY, mDoingMove, isInnerCircle);
                if (degrees != -1) {
                    value = getTimeFromDegrees(degrees, isInnerCircle[0], !mDoingMove);
                    value = roundToValidTime(value, getCurrentItemShowing());
                    reselectSelector(value, false, getCurrentItemShowing());
                    mCurrentTime = value;
                    mListener.onValueSelected(value);
                    mListener.advancePicker(getCurrentItemShowing());
                }
            }
            mDoingMove = false;
            return true;
        default:
            break;
        }
        return false;
    }

    /**
     * Set touch input as enabled or disabled, for use with keyboard mode.
     */
    public boolean trySettingInputEnabled(boolean inputEnabled) {
        if (mDoingTouch && !inputEnabled) {
            // If we're trying to disable input, but we're in the middle of a touch event,
            // we'll allow the touch event to continue before disabling input.
            return false;
        }

        mInputEnabled = inputEnabled;
        mGrayBox.setVisibility(inputEnabled ? View.INVISIBLE : View.VISIBLE);
        return true;
    }

    /**
     * Necessary for accessibility, to ensure we support "scrolling" forward and backward
     * in the circle.
     */
    @Override
    @SuppressWarnings("deprecation")
    public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) {
        super.onInitializeAccessibilityNodeInfo(info);
        if (Build.VERSION.SDK_INT >= 21) {
            info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
            info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
        } else if (Build.VERSION.SDK_INT >= 16) {
            info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
            info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
        } else {
            info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
            info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
        }
    }

    /**
     * Announce the currently-selected time when launched.
     */
    @Override
    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
        if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
            // Clear the event's current text so that only the current time will be spoken.
            event.getText().clear();
            Calendar time = Calendar.getInstance();
            time.set(Calendar.HOUR, getHours());
            time.set(Calendar.MINUTE, getMinutes());
            time.set(Calendar.SECOND, getSeconds());
            long millis = time.getTimeInMillis();
            int flags = DateUtils.FORMAT_SHOW_TIME;
            if (mIs24HourMode) {
                flags |= DateUtils.FORMAT_24HOUR;
            }
            String timeString = DateUtils.formatDateTime(getContext(), millis, flags);
            event.getText().add(timeString);
            return true;
        }
        return super.dispatchPopulateAccessibilityEvent(event);
    }

    /**
     * When scroll forward/backward events are received, jump the time to the higher/lower
     * discrete, visible value on the circle.
     */
    @Override
    public boolean performAccessibilityAction(int action, Bundle arguments) {
        if (super.performAccessibilityAction(action, arguments)) {
            return true;
        }

        int changeMultiplier = 0;
        int forward;
        int backward;
        if (Build.VERSION.SDK_INT >= 16) {
            forward = AccessibilityNodeInfo.ACTION_SCROLL_FORWARD;
            backward = AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD;
        } else {
            forward = AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD;
            backward = AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD;
        }
        if (action == forward) {
            changeMultiplier = 1;
        } else if (action == backward) {
            changeMultiplier = -1;
        }
        if (changeMultiplier != 0) {
            int value = getCurrentlyShowingValue();
            int stepSize = 0;
            int currentItemShowing = getCurrentItemShowing();
            if (currentItemShowing == HOUR_INDEX) {
                stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
                value %= 12;
            } else if (currentItemShowing == MINUTE_INDEX) {
                stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
            } else if (currentItemShowing == SECOND_INDEX) {
                stepSize = SECOND_VALUE_TO_DEGREES_STEP_SIZE;
            }

            int degrees = value * stepSize;
            degrees = snapOnly30s(degrees, changeMultiplier);
            value = degrees / stepSize;
            int maxValue = 0;
            int minValue = 0;
            if (currentItemShowing == HOUR_INDEX) {
                if (mIs24HourMode) {
                    maxValue = 23;
                } else {
                    maxValue = 12;
                    minValue = 1;
                }
            } else {
                maxValue = 55;
            }
            if (value > maxValue) {
                // If we scrolled forward past the highest number, wrap around to the lowest.
                value = minValue;
            } else if (value < minValue) {
                // If we scrolled backward past the lowest number, wrap around to the highest.
                value = maxValue;
            }

            Timepoint newSelection;
            switch (currentItemShowing) {
            case HOUR_INDEX:
                newSelection = new Timepoint(value, mCurrentTime.getMinute(), mCurrentTime.getSecond());
                break;
            case MINUTE_INDEX:
                newSelection = new Timepoint(mCurrentTime.getHour(), value, mCurrentTime.getSecond());
                break;
            case SECOND_INDEX:
                newSelection = new Timepoint(mCurrentTime.getHour(), mCurrentTime.getMinute(), value);
                break;
            default:
                newSelection = mCurrentTime;
            }

            setItem(currentItemShowing, newSelection);
            mListener.onValueSelected(newSelection);
            return true;
        }

        return false;
    }
}