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;
public class RadialTimePickerView extends View {
private static final String TAG = "RadialTimePickerView";
public static final int HOURS = 0;
public static final int MINUTES = 1;
@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];
private static final int MISSING_COLOR = Color.MAGENTA;
static {
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;
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 {
void onValueSelected(@PickerType int pickerType, int newValue, boolean autoAdvance);
}
private static void preparePrefer30sMap() {
int snappedOutputDegrees = 0;
int count = 1;
int expectedCount = 8;
for (int degrees = 0; degrees < 361; degrees++) {
SNAP_PREFER_30S_MAP[degrees] = snappedOutputDegrees;
if (count == expectedCount) {
snappedOutputDegrees += 6;
if (snappedOutputDegrees == 360) {
expectedCount = 7;
} else if (snappedOutputDegrees % 30 == 0) {
expectedCount = 14;
} else {
expectedCount = 4;
}
count = 1;
} else {
count++;
}
}
}
private static int snapPrefer30s(int degrees) {
if (SNAP_PREFER_30S_MAP == null) {
return -1;
}
return SNAP_PREFER_30S_MAP[degrees];
}
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);
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;
mTouchHelper = new RadialPickerTouchHelper();
setAccessibilityDelegate(mTouchHelper);
if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
}
initHoursAndMinutesText();
initData();
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);
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];
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;
}
public void setCurrentHour(int hour) {
setCurrentHourInternal(hour, true, false);
}
private void setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance) {
final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR;
mSelectionDegrees[HOURS] = degrees;
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);
}
}
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) {
if (!innerCircle && hour == 0) {
hour = 12;
} else if (innerCircle && hour != 0) {
hour += 12;
}
} else if (mAmOrPm == PM) {
hour += 12;
}
return hour;
}
private int getDegreesForHour(int hour) {
if (mIs24HourMode) {
if (hour >= 12) {
hour -= 12;
}
} else if (hour == 12) {
hour = 0;
}
return hour * DEGREES_FOR_ONE_HOUR;
}
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);
}
}
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;
}
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() {
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 (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 (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) {
mHoursToMinutesAnimator.cancel();
mHoursToMinutesAnimator = null;
}
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) {
canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipPath(selectorPath, Region.Op.DIFFERENCE);
drawHoursClipped(canvas, hoursAlpha, false);
canvas.restore();
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) {
drawTextElements(canvas, mTextSize[HOURS], mTypeface, mTextColor[HOURS], mOuterTextHours,
mOuterTextX[HOURS], mOuterTextY[HOURS], mPaint[HOURS], hoursAlpha,
showActivated && !mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated);
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) {
canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipPath(selectorPath, Region.Op.DIFFERENCE);
drawMinutesClipped(canvas, minutesAlpha, false);
canvas.restore();
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) {
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;
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);
final Paint paint = mPaintSelector[SELECTOR_CIRCLE];
paint.setColor(mSelectorColor);
canvas.drawCircle(selCenterX, selCenterY, selRadius, paint);
if (selectorPath != null) {
selectorPath.reset();
selectorPath.addCircle(selCenterX, selCenterY, selRadius, Path.Direction.CCW);
}
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);
}
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);
final Paint linePaint = mPaintSelector[SELECTOR_LINE];
linePaint.setColor(mSelectorColor);
linePaint.setStrokeWidth(mSelectorStroke);
canvas.drawLine(mXCenter, mYCenter, linePointX, linePointY, linePaint);
}
private void calculatePositionsHours() {
final float numbersRadius = mCircleRadius - mTextInset[HOURS];
calculatePositions(mPaint[HOURS], numbersRadius, mXCenter, mYCenter,
mTextSize[HOURS], mOuterTextX[HOURS], mOuterTextY[HOURS]);
if (mIs24HourMode) {
final int innerNumbersRadius = mCircleRadius - mTextInset[HOURS_INNER];
calculatePositions(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter,
mTextSize[HOURS_INNER], mInnerTextX, mInnerTextY);
}
}
private void calculatePositionsMinutes() {
final float numbersRadius = mCircleRadius - mTextInset[MINUTES];
calculatePositions(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter,
mTextSize[MINUTES], mOuterTextX[MINUTES], mOuterTextY[MINUTES]);
}
private static void calculatePositions(Paint paint, float radius, float xCenter, float yCenter,
float textSize, float[] x, float[] y) {
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];
}
}
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);
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) {
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;
}
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) {
mChangedDuringTouch = false;
} else if (action == MotionEvent.ACTION_UP) {
autoAdvance = true;
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;
}
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) {
if (mListener != null) {
mListener.onValueSelected(type, newValue, autoAdvance);
}
if (valueChanged || forceSelection) {
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
invalidate();
}
return true;
}
return false;
}
@Override
public boolean dispatchHoverEvent(MotionEvent event) {
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;
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);
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;
}
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 (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) {
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 {
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;
}
}
}