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