/*
* 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.wdullaer.materialdatetimepicker.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.wdullaer.materialdatetimepicker.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;
}
}