/* * 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.Animator; import android.animation.AnimatorSet; import android.animation.Keyframe; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; 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.Typeface; import android.graphics.RectF; import android.os.Bundle; import android.text.format.DateUtils; import android.text.format.Time; import android.util.AttributeSet; import android.util.Log; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import com.android.internal.R; import java.text.DateFormatSymbols; import java.util.ArrayList; 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 implements View.OnTouchListener { private static final String TAG = "ClockView"; private static final boolean DEBUG = false; private static final int DEBUG_COLOR = 0x20FF0000; private static final int DEBUG_TEXT_COLOR = 0x60FF0000; private static final int DEBUG_STROKE_WIDTH = 2; private static final int HOURS = 0; private static final int MINUTES = 1; private static final int HOURS_INNER = 2; private static final int AMPM = 3; 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; // Opaque alpha level private static final int ALPHA_OPAQUE = 255; // Transparent alpha level private static final int ALPHA_TRANSPARENT = 0; // Alpha level of color for selector. private static final int ALPHA_SELECTOR = 51; // Alpha level of color for selected circle. private static final int ALPHA_AMPM_SELECTED = ALPHA_SELECTOR; // Alpha level of color for pressed circle. private static final int ALPHA_AMPM_PRESSED = 175; private static final float COSINE_30_DEGREES = ((float) Math.sqrt(3)) * 0.5f; private static final float SINE_30_DEGREES = 0.5f; private static final int DEGREES_FOR_ONE_HOUR = 30; private static final int DEGREES_FOR_ONE_MINUTE = 6; 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 CENTER_RADIUS = 2; private static int[] sSnapPrefer30sMap = new int[361]; 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 String[] mAmPmText = new String[2]; private final Paint[] mPaint = new Paint[2]; private final Paint mPaintCenter = new Paint(); private final Paint[][] mPaintSelector = new Paint[2][3]; private final Paint mPaintAmPmText = new Paint(); private final Paint[] mPaintAmPmCircle = new Paint[2]; private final Paint mPaintBackground = new Paint(); private final Paint mPaintDisabled = new Paint(); private final Paint mPaintDebug = new Paint(); private Typeface mTypeface; private boolean mIs24HourMode; private boolean mShowHours; private boolean mIsOnInnerCircle; private int mXCenter; private int mYCenter; private float[] mCircleRadius = new float[3]; private int mMinHypotenuseForInnerNumber; private int mMaxHypotenuseForOuterNumber; private int mHalfwayHypotenusePoint; private float[] mTextSize = new float[2]; private float mInnerTextSize; private float[][] mTextGridHeights = new float[2][7]; private float[][] mTextGridWidths = new float[2][7]; private float[] mInnerTextGridHeights = new float[7]; private float[] mInnerTextGridWidths = new float[7]; private String[] mOuterTextHours; private String[] mInnerTextHours; private String[] mOuterTextMinutes; private float[] mCircleRadiusMultiplier = new float[2]; private float[] mNumbersRadiusMultiplier = new float[3]; private float[] mTextSizeMultiplier = new float[3]; private float[] mAnimationRadiusMultiplier = new float[3]; private float mTransitionMidRadiusMultiplier; private float mTransitionEndRadiusMultiplier; private AnimatorSet mTransition; private InvalidateUpdateListener mInvalidateUpdateListener = new InvalidateUpdateListener(); private int[] mLineLength = new int[3]; private int[] mSelectionRadius = new int[3]; private float mSelectionRadiusMultiplier; private int[] mSelectionDegrees = new int[3]; private int mAmPmCircleRadius; private float mAmPmYCenter; private float mAmPmCircleRadiusMultiplier; private int mAmPmTextColor; private float mLeftIndicatorXCenter; private float mRightIndicatorXCenter; private int mAmPmUnselectedColor; private int mAmPmSelectedColor; private int mAmOrPm; private int mAmOrPmPressed; private RectF mRectF = new RectF(); private boolean mInputEnabled = true; private OnValueSelectedListener mListener; private final ArrayList mHoursToMinutesAnims = new ArrayList(); private final ArrayList mMinuteToHoursAnims = new ArrayList(); public interface OnValueSelectedListener { void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance); } static { // Prepare mapping to snap touchable degrees to selectable degrees. preparePrefer30sMap(); } /** * 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. sSnapPrefer30sMap[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 (sSnapPrefer30sMap == null) { return -1; } return sSnapPrefer30sMap[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; } public RadialTimePickerView(Context context, AttributeSet attrs) { this(context, attrs, R.attr.timePickerStyle); } public RadialTimePickerView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs); // process style attributes final TypedArray a = mContext.obtainStyledAttributes(attrs, R.styleable.TimePicker, defStyle, 0); final Resources res = getResources(); mAmPmUnselectedColor = a.getColor(R.styleable.TimePicker_amPmUnselectedBackgroundColor, res.getColor( R.color.timepicker_default_ampm_unselected_background_color_holo_light)); mAmPmSelectedColor = a.getColor(R.styleable.TimePicker_amPmSelectedBackgroundColor, res.getColor(R.color.timepicker_default_ampm_selected_background_color_holo_light)); mAmPmTextColor = a.getColor(R.styleable.TimePicker_amPmTextColor, res.getColor(R.color.timepicker_default_text_color_holo_light)); final int numbersTextColor = a.getColor(R.styleable.TimePicker_numbersTextColor, res.getColor(R.color.timepicker_default_text_color_holo_light)); mTypeface = Typeface.create("sans-serif", Typeface.NORMAL); mPaint[HOURS] = new Paint(); mPaint[HOURS].setColor(numbersTextColor); mPaint[HOURS].setAntiAlias(true); mPaint[HOURS].setTextAlign(Paint.Align.CENTER); mPaint[MINUTES] = new Paint(); mPaint[MINUTES].setColor(numbersTextColor); mPaint[MINUTES].setAntiAlias(true); mPaint[MINUTES].setTextAlign(Paint.Align.CENTER); mPaintCenter.setColor(numbersTextColor); mPaintCenter.setAntiAlias(true); mPaintCenter.setTextAlign(Paint.Align.CENTER); mPaintSelector[HOURS][SELECTOR_CIRCLE] = new Paint(); mPaintSelector[HOURS][SELECTOR_CIRCLE].setColor( a.getColor(R.styleable.TimePicker_numbersSelectorColor, R.color.holo_blue_light)); mPaintSelector[HOURS][SELECTOR_CIRCLE].setAntiAlias(true); mPaintSelector[HOURS][SELECTOR_DOT] = new Paint(); mPaintSelector[HOURS][SELECTOR_DOT].setColor( a.getColor(R.styleable.TimePicker_numbersSelectorColor, R.color.holo_blue_light)); mPaintSelector[HOURS][SELECTOR_DOT].setAntiAlias(true); mPaintSelector[HOURS][SELECTOR_LINE] = new Paint(); mPaintSelector[HOURS][SELECTOR_LINE].setColor( a.getColor(R.styleable.TimePicker_numbersSelectorColor, R.color.holo_blue_light)); mPaintSelector[HOURS][SELECTOR_LINE].setAntiAlias(true); mPaintSelector[HOURS][SELECTOR_LINE].setStrokeWidth(2); mPaintSelector[MINUTES][SELECTOR_CIRCLE] = new Paint(); mPaintSelector[MINUTES][SELECTOR_CIRCLE].setColor( a.getColor(R.styleable.TimePicker_numbersSelectorColor, R.color.holo_blue_light)); mPaintSelector[MINUTES][SELECTOR_CIRCLE].setAntiAlias(true); mPaintSelector[MINUTES][SELECTOR_DOT] = new Paint(); mPaintSelector[MINUTES][SELECTOR_DOT].setColor( a.getColor(R.styleable.TimePicker_numbersSelectorColor, R.color.holo_blue_light)); mPaintSelector[MINUTES][SELECTOR_DOT].setAntiAlias(true); mPaintSelector[MINUTES][SELECTOR_LINE] = new Paint(); mPaintSelector[MINUTES][SELECTOR_LINE].setColor( a.getColor(R.styleable.TimePicker_numbersSelectorColor, R.color.holo_blue_light)); mPaintSelector[MINUTES][SELECTOR_LINE].setAntiAlias(true); mPaintSelector[MINUTES][SELECTOR_LINE].setStrokeWidth(2); mPaintAmPmText.setColor(mAmPmTextColor); mPaintAmPmText.setTypeface(mTypeface); mPaintAmPmText.setAntiAlias(true); mPaintAmPmText.setTextAlign(Paint.Align.CENTER); mPaintAmPmCircle[AM] = new Paint(); mPaintAmPmCircle[AM].setAntiAlias(true); mPaintAmPmCircle[PM] = new Paint(); mPaintAmPmCircle[PM].setAntiAlias(true); mPaintBackground.setColor( a.getColor(R.styleable.TimePicker_numbersBackgroundColor, Color.WHITE)); mPaintBackground.setAntiAlias(true); final int disabledColor = a.getColor(R.styleable.TimePicker_disabledColor, res.getColor(R.color.timepicker_default_disabled_color_holo_light)); mPaintDisabled.setColor(disabledColor); mPaintDisabled.setAntiAlias(true); if (DEBUG) { mPaintDebug.setColor(DEBUG_COLOR); mPaintDebug.setAntiAlias(true); mPaintDebug.setStrokeWidth(DEBUG_STROKE_WIDTH); mPaintDebug.setStyle(Paint.Style.STROKE); mPaintDebug.setTextAlign(Paint.Align.CENTER); } mShowHours = true; mIs24HourMode = false; mAmOrPm = AM; mAmOrPmPressed = -1; initHoursAndMinutesText(); initData(); mTransitionMidRadiusMultiplier = Float.parseFloat( res.getString(R.string.timepicker_transition_mid_radius_multiplier)); mTransitionEndRadiusMultiplier = Float.parseFloat( res.getString(R.string.timepicker_transition_end_radius_multiplier)); mTextGridHeights[HOURS] = new float[7]; mTextGridHeights[MINUTES] = new float[7]; mSelectionRadiusMultiplier = Float.parseFloat( res.getString(R.string.timepicker_selection_radius_multiplier)); setOnTouchListener(this); // 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); setCurrentHour(currentHour); setCurrentMinute(currentMinute); setHapticFeedbackEnabled(true); } /** * Measure the view to end up as a square, based on the minimum of the height and width. */ @Override public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int measuredWidth = MeasureSpec.getSize(widthMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int measuredHeight = MeasureSpec.getSize(heightMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int minDimension = Math.min(measuredWidth, measuredHeight); super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode), MeasureSpec.makeMeasureSpec(minDimension, heightMode)); } public void initialize(int hour, int minute, boolean is24HourMode) { mIs24HourMode = is24HourMode; setCurrentHour(hour); setCurrentMinute(minute); } 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) { final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR; mSelectionDegrees[HOURS] = degrees; mSelectionDegrees[HOURS_INNER] = degrees; mAmOrPm = ((hour % 24) < 12) ? AM : PM; if (mIs24HourMode) { mIsOnInnerCircle = (mAmOrPm == AM); } else { mIsOnInnerCircle = false; } initData(); updateLayoutData(); invalidate(); } // Return hours in 0-23 range public int getCurrentHour() { int hours = mSelectionDegrees[mIsOnInnerCircle ? HOURS_INNER : HOURS] / DEGREES_FOR_ONE_HOUR; if (mIs24HourMode) { if (mIsOnInnerCircle) { hours = hours % 12; } else { if (hours != 0) { hours += 12; } } } else { hours = hours % 12; if (hours == 0) { if (mAmOrPm == PM) { hours = 12; } } else { if (mAmOrPm == PM) { hours += 12; } } } return hours; } public void setCurrentMinute(int minute) { mSelectionDegrees[MINUTES] = (minute % 60) * DEGREES_FOR_ONE_MINUTE; invalidate(); } // Returns minutes in 0-59 range public int getCurrentMinute() { return (mSelectionDegrees[MINUTES] / DEGREES_FOR_ONE_MINUTE); } public void setAmOrPm(int val) { mAmOrPm = (val % 2); invalidate(); } public int getAmOrPm() { return mAmOrPm; } public void swapAmPm() { mAmOrPm = (mAmOrPm == AM) ? PM : AM; invalidate(); } public void showHours(boolean animate) { if (mShowHours) return; mShowHours = true; if (animate) { startMinutesToHoursAnimation(); } initData(); updateLayoutData(); invalidate(); } public void showMinutes(boolean animate) { if (!mShowHours) return; mShowHours = false; if (animate) { startHoursToMinutesAnimation(); } initData(); updateLayoutData(); invalidate(); } private void initHoursAndMinutesText() { // Initialize the hours and minutes numbers. for (int i = 0; i < 12; i++) { mHours12Texts[i] = String.format("%d", HOURS_NUMBERS[i]); mOuterHours24Texts[i] = String.format("%02d", HOURS_NUMBERS_24[i]); mInnerHours24Texts[i] = String.format("%d", HOURS_NUMBERS[i]); mMinutesTexts[i] = String.format("%02d", MINUTES_NUMBERS[i]); } String[] amPmTexts = new DateFormatSymbols().getAmPmStrings(); mAmPmText[AM] = amPmTexts[0]; mAmPmText[PM] = amPmTexts[1]; } private void initData() { if (mIs24HourMode) { mOuterTextHours = mOuterHours24Texts; mInnerTextHours = mInnerHours24Texts; } else { mOuterTextHours = mHours12Texts; mInnerTextHours = null; } mOuterTextMinutes = mMinutesTexts; final Resources res = getResources(); if (mShowHours) { if (mIs24HourMode) { mCircleRadiusMultiplier[HOURS] = Float.parseFloat( res.getString(R.string.timepicker_circle_radius_multiplier_24HourMode)); mNumbersRadiusMultiplier[HOURS] = Float.parseFloat( res.getString(R.string.timepicker_numbers_radius_multiplier_outer)); mTextSizeMultiplier[HOURS] = Float.parseFloat( res.getString(R.string.timepicker_text_size_multiplier_outer)); mNumbersRadiusMultiplier[HOURS_INNER] = Float.parseFloat( res.getString(R.string.timepicker_numbers_radius_multiplier_inner)); mTextSizeMultiplier[HOURS_INNER] = Float.parseFloat( res.getString(R.string.timepicker_text_size_multiplier_inner)); } else { mCircleRadiusMultiplier[HOURS] = Float.parseFloat( res.getString(R.string.timepicker_circle_radius_multiplier)); mNumbersRadiusMultiplier[HOURS] = Float.parseFloat( res.getString(R.string.timepicker_numbers_radius_multiplier_normal)); mTextSizeMultiplier[HOURS] = Float.parseFloat( res.getString(R.string.timepicker_text_size_multiplier_normal)); } } else { mCircleRadiusMultiplier[MINUTES] = Float.parseFloat( res.getString(R.string.timepicker_circle_radius_multiplier)); mNumbersRadiusMultiplier[MINUTES] = Float.parseFloat( res.getString(R.string.timepicker_numbers_radius_multiplier_normal)); mTextSizeMultiplier[MINUTES] = Float.parseFloat( res.getString(R.string.timepicker_text_size_multiplier_normal)); } mAnimationRadiusMultiplier[HOURS] = 1; mAnimationRadiusMultiplier[HOURS_INNER] = 1; mAnimationRadiusMultiplier[MINUTES] = 1; mAmPmCircleRadiusMultiplier = Float.parseFloat( res.getString(R.string.timepicker_ampm_circle_radius_multiplier)); mPaint[HOURS].setAlpha(mShowHours ? ALPHA_OPAQUE : ALPHA_TRANSPARENT); mPaint[MINUTES].setAlpha(mShowHours ? ALPHA_TRANSPARENT : ALPHA_OPAQUE); mPaintSelector[HOURS][SELECTOR_CIRCLE].setAlpha( mShowHours ?ALPHA_SELECTOR : ALPHA_TRANSPARENT); mPaintSelector[HOURS][SELECTOR_DOT].setAlpha( mShowHours ? ALPHA_OPAQUE : ALPHA_TRANSPARENT); mPaintSelector[HOURS][SELECTOR_LINE].setAlpha( mShowHours ? ALPHA_SELECTOR : ALPHA_TRANSPARENT); mPaintSelector[MINUTES][SELECTOR_CIRCLE].setAlpha( mShowHours ? ALPHA_TRANSPARENT : ALPHA_SELECTOR); mPaintSelector[MINUTES][SELECTOR_DOT].setAlpha( mShowHours ? ALPHA_TRANSPARENT : ALPHA_OPAQUE); mPaintSelector[MINUTES][SELECTOR_LINE].setAlpha( mShowHours ? ALPHA_TRANSPARENT : ALPHA_SELECTOR); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { updateLayoutData(); } private void updateLayoutData() { mXCenter = getWidth() / 2; mYCenter = getHeight() / 2; final int min = Math.min(mXCenter, mYCenter); mCircleRadius[HOURS] = min * mCircleRadiusMultiplier[HOURS]; mCircleRadius[HOURS_INNER] = min * mCircleRadiusMultiplier[HOURS]; mCircleRadius[MINUTES] = min * mCircleRadiusMultiplier[MINUTES]; if (!mIs24HourMode) { // We'll need to draw the AM/PM circles, so the main circle will need to have // a slightly higher center. To keep the entire view centered vertically, we'll // have to push it up by half the radius of the AM/PM circles. int amPmCircleRadius = (int) (mCircleRadius[HOURS] * mAmPmCircleRadiusMultiplier); mYCenter -= amPmCircleRadius / 2; } mMinHypotenuseForInnerNumber = (int) (mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS_INNER]) - mSelectionRadius[HOURS]; mMaxHypotenuseForOuterNumber = (int) (mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS]) + mSelectionRadius[HOURS]; mHalfwayHypotenusePoint = (int) (mCircleRadius[HOURS] * ((mNumbersRadiusMultiplier[HOURS] + mNumbersRadiusMultiplier[HOURS_INNER]) / 2)); mTextSize[HOURS] = mCircleRadius[HOURS] * mTextSizeMultiplier[HOURS]; mTextSize[MINUTES] = mCircleRadius[MINUTES] * mTextSizeMultiplier[MINUTES]; if (mIs24HourMode) { mInnerTextSize = mCircleRadius[HOURS] * mTextSizeMultiplier[HOURS_INNER]; } calculateGridSizesHours(); calculateGridSizesMinutes(); mSelectionRadius[HOURS] = (int) (mCircleRadius[HOURS] * mSelectionRadiusMultiplier); mSelectionRadius[HOURS_INNER] = mSelectionRadius[HOURS]; mSelectionRadius[MINUTES] = (int) (mCircleRadius[MINUTES] * mSelectionRadiusMultiplier); mAmPmCircleRadius = (int) (mCircleRadius[HOURS] * mAmPmCircleRadiusMultiplier); mPaintAmPmText.setTextSize(mAmPmCircleRadius * 3 / 4); // Line up the vertical center of the AM/PM circles with the bottom of the main circle. mAmPmYCenter = mYCenter + mCircleRadius[HOURS]; // Line up the horizontal edges of the AM/PM circles with the horizontal edges // of the main circle mLeftIndicatorXCenter = mXCenter - mCircleRadius[HOURS] + mAmPmCircleRadius; mRightIndicatorXCenter = mXCenter + mCircleRadius[HOURS] - mAmPmCircleRadius; } @Override public void onDraw(Canvas canvas) { canvas.save(); calculateGridSizesHours(); calculateGridSizesMinutes(); drawCircleBackground(canvas); drawTextElements(canvas, mTextSize[HOURS], mTypeface, mOuterTextHours, mTextGridWidths[HOURS], mTextGridHeights[HOURS], mPaint[HOURS]); if (mIs24HourMode && mInnerTextHours != null) { drawTextElements(canvas, mInnerTextSize, mTypeface, mInnerTextHours, mInnerTextGridWidths, mInnerTextGridHeights, mPaint[HOURS]); } drawTextElements(canvas, mTextSize[MINUTES], mTypeface, mOuterTextMinutes, mTextGridWidths[MINUTES], mTextGridHeights[MINUTES], mPaint[MINUTES]); drawCenter(canvas); drawSelector(canvas); if (!mIs24HourMode) { drawAmPm(canvas); } if(!mInputEnabled) { // Draw outer view rectangle mRectF.set(0, 0, getWidth(), getHeight()); canvas.drawRect(mRectF, mPaintDisabled); } if (DEBUG) { drawDebug(canvas); } canvas.restore(); } private void drawCircleBackground(Canvas canvas) { canvas.drawCircle(mXCenter, mYCenter, mCircleRadius[HOURS], mPaintBackground); } private void drawCenter(Canvas canvas) { canvas.drawCircle(mXCenter, mYCenter, CENTER_RADIUS, mPaintCenter); } private void drawSelector(Canvas canvas) { drawSelector(canvas, mIsOnInnerCircle ? HOURS_INNER : HOURS); drawSelector(canvas, MINUTES); } private void drawAmPm(Canvas canvas) { final boolean isLayoutRtl = isLayoutRtl(); int amColor = mAmPmUnselectedColor; int amAlpha = ALPHA_OPAQUE; int pmColor = mAmPmUnselectedColor; int pmAlpha = ALPHA_OPAQUE; if (mAmOrPm == AM) { amColor = mAmPmSelectedColor; amAlpha = ALPHA_AMPM_SELECTED; } else if (mAmOrPm == PM) { pmColor = mAmPmSelectedColor; pmAlpha = ALPHA_AMPM_SELECTED; } if (mAmOrPmPressed == AM) { amColor = mAmPmSelectedColor; amAlpha = ALPHA_AMPM_PRESSED; } else if (mAmOrPmPressed == PM) { pmColor = mAmPmSelectedColor; pmAlpha = ALPHA_AMPM_PRESSED; } // Draw the two circles mPaintAmPmCircle[AM].setColor(amColor); mPaintAmPmCircle[AM].setAlpha(amAlpha); canvas.drawCircle(isLayoutRtl ? mRightIndicatorXCenter : mLeftIndicatorXCenter, mAmPmYCenter, mAmPmCircleRadius, mPaintAmPmCircle[AM]); mPaintAmPmCircle[PM].setColor(pmColor); mPaintAmPmCircle[PM].setAlpha(pmAlpha); canvas.drawCircle(isLayoutRtl ? mLeftIndicatorXCenter : mRightIndicatorXCenter, mAmPmYCenter, mAmPmCircleRadius, mPaintAmPmCircle[PM]); // Draw the AM/PM texts on top mPaintAmPmText.setColor(mAmPmTextColor); float textYCenter = mAmPmYCenter - (int) (mPaintAmPmText.descent() + mPaintAmPmText.ascent()) / 2; canvas.drawText(isLayoutRtl ? mAmPmText[PM] : mAmPmText[AM], mLeftIndicatorXCenter, textYCenter, mPaintAmPmText); canvas.drawText(isLayoutRtl ? mAmPmText[AM] : mAmPmText[PM], mRightIndicatorXCenter, textYCenter, mPaintAmPmText); } private void drawSelector(Canvas canvas, int index) { // Calculate the current radius at which to place the selection circle. mLineLength[index] = (int) (mCircleRadius[index] * mNumbersRadiusMultiplier[index] * mAnimationRadiusMultiplier[index]); double selectionRadians = Math.toRadians(mSelectionDegrees[index]); int pointX = mXCenter + (int) (mLineLength[index] * Math.sin(selectionRadians)); int pointY = mYCenter - (int) (mLineLength[index] * Math.cos(selectionRadians)); // Draw the selection circle canvas.drawCircle(pointX, pointY, mSelectionRadius[index], mPaintSelector[index % 2][SELECTOR_CIRCLE]); // Draw the dot if needed if (mSelectionDegrees[index] % 30 != 0) { // We're not on a direct tick canvas.drawCircle(pointX, pointY, (mSelectionRadius[index] * 2 / 7), mPaintSelector[index % 2][SELECTOR_DOT]); } else { // We're not drawing the dot, so shorten the line to only go as far as the edge of the // selection circle int lineLength = mLineLength[index] - mSelectionRadius[index]; pointX = mXCenter + (int) (lineLength * Math.sin(selectionRadians)); pointY = mYCenter - (int) (lineLength * Math.cos(selectionRadians)); } // Draw the line canvas.drawLine(mXCenter, mYCenter, pointX, pointY, mPaintSelector[index % 2][SELECTOR_LINE]); } private void drawDebug(Canvas canvas) { // Draw outer numbers circle final float outerRadius = mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS]; canvas.drawCircle(mXCenter, mYCenter, outerRadius, mPaintDebug); // Draw inner numbers circle final float innerRadius = mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS_INNER]; canvas.drawCircle(mXCenter, mYCenter, innerRadius, mPaintDebug); // Draw outer background circle canvas.drawCircle(mXCenter, mYCenter, mCircleRadius[HOURS], mPaintDebug); // Draw outer rectangle for circles float left = mXCenter - outerRadius; float top = mYCenter - outerRadius; float right = mXCenter + outerRadius; float bottom = mYCenter + outerRadius; mRectF = new RectF(left, top, right, bottom); canvas.drawRect(mRectF, mPaintDebug); // Draw outer rectangle for background left = mXCenter - mCircleRadius[HOURS]; top = mYCenter - mCircleRadius[HOURS]; right = mXCenter + mCircleRadius[HOURS]; bottom = mYCenter + mCircleRadius[HOURS]; mRectF.set(left, top, right, bottom); canvas.drawRect(mRectF, mPaintDebug); // Draw outer view rectangle mRectF.set(0, 0, getWidth(), getHeight()); canvas.drawRect(mRectF, mPaintDebug); // Draw selected time final String selected = String.format("%02d:%02d", getCurrentHour(), getCurrentMinute()); ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); TextView tv = new TextView(getContext()); tv.setLayoutParams(lp); tv.setText(selected); tv.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); Paint paint = tv.getPaint(); paint.setColor(DEBUG_TEXT_COLOR); final int width = tv.getMeasuredWidth(); float height = paint.descent() - paint.ascent(); float x = mXCenter - width / 2; float y = mYCenter + 1.5f * height; canvas.drawText(selected.toString(), x, y, paint); } private void calculateGridSizesHours() { // Calculate the text positions float numbersRadius = mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS] * mAnimationRadiusMultiplier[HOURS]; // Calculate the positions for the 12 numbers in the main circle. calculateGridSizes(mPaint[HOURS], numbersRadius, mXCenter, mYCenter, mTextSize[HOURS], mTextGridHeights[HOURS], mTextGridWidths[HOURS]); // If we have an inner circle, calculate those positions too. if (mIs24HourMode) { float innerNumbersRadius = mCircleRadius[HOURS_INNER] * mNumbersRadiusMultiplier[HOURS_INNER] * mAnimationRadiusMultiplier[HOURS_INNER]; calculateGridSizes(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter, mInnerTextSize, mInnerTextGridHeights, mInnerTextGridWidths); } } private void calculateGridSizesMinutes() { // Calculate the text positions float numbersRadius = mCircleRadius[MINUTES] * mNumbersRadiusMultiplier[MINUTES] * mAnimationRadiusMultiplier[MINUTES]; // Calculate the positions for the 12 numbers in the main circle. calculateGridSizes(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter, mTextSize[MINUTES], mTextGridHeights[MINUTES], mTextGridWidths[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 calculateGridSizes(Paint paint, float numbersRadius, float xCenter, float yCenter, float textSize, float[] textGridHeights, float[] textGridWidths) { /* * The numbers need to be drawn in a 7x7 grid, representing the points on the Unit Circle. */ final float offset1 = numbersRadius; // cos(30) = a / r => r * cos(30) final float offset2 = numbersRadius * COSINE_30_DEGREES; // sin(30) = o / r => r * sin(30) final float offset3 = numbersRadius * SINE_30_DEGREES; paint.setTextSize(textSize); // We'll need yTextBase to be slightly lower to account for the text's baseline. yCenter -= (paint.descent() + paint.ascent()) / 2; textGridHeights[0] = yCenter - offset1; textGridWidths[0] = xCenter - offset1; textGridHeights[1] = yCenter - offset2; textGridWidths[1] = xCenter - offset2; textGridHeights[2] = yCenter - offset3; textGridWidths[2] = xCenter - offset3; textGridHeights[3] = yCenter; textGridWidths[3] = xCenter; textGridHeights[4] = yCenter + offset3; textGridWidths[4] = xCenter + offset3; textGridHeights[5] = yCenter + offset2; textGridWidths[5] = xCenter + offset2; textGridHeights[6] = yCenter + offset1; textGridWidths[6] = xCenter + offset1; } /** * Draw the 12 text values at the positions specified by the textGrid parameters. */ private void drawTextElements(Canvas canvas, float textSize, Typeface typeface, String[] texts, float[] textGridWidths, float[] textGridHeights, Paint paint) { paint.setTextSize(textSize); paint.setTypeface(typeface); canvas.drawText(texts[0], textGridWidths[3], textGridHeights[0], paint); canvas.drawText(texts[1], textGridWidths[4], textGridHeights[1], paint); canvas.drawText(texts[2], textGridWidths[5], textGridHeights[2], paint); canvas.drawText(texts[3], textGridWidths[6], textGridHeights[3], paint); canvas.drawText(texts[4], textGridWidths[5], textGridHeights[4], paint); canvas.drawText(texts[5], textGridWidths[4], textGridHeights[5], paint); canvas.drawText(texts[6], textGridWidths[3], textGridHeights[6], paint); canvas.drawText(texts[7], textGridWidths[2], textGridHeights[5], paint); canvas.drawText(texts[8], textGridWidths[1], textGridHeights[4], paint); canvas.drawText(texts[9], textGridWidths[0], textGridHeights[3], paint); canvas.drawText(texts[10], textGridWidths[1], textGridHeights[2], paint); canvas.drawText(texts[11], textGridWidths[2], textGridHeights[1], paint); } // Used for animating the hours by changing their radius private void setAnimationRadiusMultiplierHours(float animationRadiusMultiplier) { mAnimationRadiusMultiplier[HOURS] = animationRadiusMultiplier; mAnimationRadiusMultiplier[HOURS_INNER] = animationRadiusMultiplier; } // Used for animating the minutes by changing their radius private void setAnimationRadiusMultiplierMinutes(float animationRadiusMultiplier) { mAnimationRadiusMultiplier[MINUTES] = animationRadiusMultiplier; } private static ObjectAnimator getRadiusDisappearAnimator(Object target, String radiusPropertyName, InvalidateUpdateListener updateListener, float midRadiusMultiplier, float endRadiusMultiplier) { Keyframe kf0, kf1, kf2; float midwayPoint = 0.2f; int duration = 500; kf0 = Keyframe.ofFloat(0f, 1); kf1 = Keyframe.ofFloat(midwayPoint, midRadiusMultiplier); kf2 = Keyframe.ofFloat(1f, endRadiusMultiplier); PropertyValuesHolder radiusDisappear = PropertyValuesHolder.ofKeyframe( radiusPropertyName, kf0, kf1, kf2); ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder( target, radiusDisappear).setDuration(duration); animator.addUpdateListener(updateListener); return animator; } private static ObjectAnimator getRadiusReappearAnimator(Object target, String radiusPropertyName, InvalidateUpdateListener updateListener, float midRadiusMultiplier, float endRadiusMultiplier) { Keyframe kf0, kf1, kf2, kf3; float midwayPoint = 0.2f; int duration = 500; // Set up animator for reappearing. float delayMultiplier = 0.25f; float transitionDurationMultiplier = 1f; float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier; int totalDuration = (int) (duration * totalDurationMultiplier); float delayPoint = (delayMultiplier * duration) / totalDuration; midwayPoint = 1 - (midwayPoint * (1 - delayPoint)); kf0 = Keyframe.ofFloat(0f, endRadiusMultiplier); kf1 = Keyframe.ofFloat(delayPoint, endRadiusMultiplier); kf2 = Keyframe.ofFloat(midwayPoint, midRadiusMultiplier); kf3 = Keyframe.ofFloat(1f, 1); PropertyValuesHolder radiusReappear = PropertyValuesHolder.ofKeyframe( radiusPropertyName, kf0, kf1, kf2, kf3); ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder( target, radiusReappear).setDuration(totalDuration); animator.addUpdateListener(updateListener); return animator; } private static ObjectAnimator getFadeOutAnimator(Object target, int startAlpha, int endAlpha, InvalidateUpdateListener updateListener) { int duration = 500; ObjectAnimator animator = ObjectAnimator.ofInt(target, "alpha", startAlpha, endAlpha); animator.setDuration(duration); animator.addUpdateListener(updateListener); return animator; } private static ObjectAnimator getFadeInAnimator(Object target, int startAlpha, int endAlpha, InvalidateUpdateListener updateListener) { Keyframe kf0, kf1, kf2; int duration = 500; // Set up animator for reappearing. float delayMultiplier = 0.25f; float transitionDurationMultiplier = 1f; float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier; int totalDuration = (int) (duration * totalDurationMultiplier); float delayPoint = (delayMultiplier * duration) / totalDuration; kf0 = Keyframe.ofInt(0f, startAlpha); kf1 = Keyframe.ofInt(delayPoint, startAlpha); kf2 = Keyframe.ofInt(1f, endAlpha); PropertyValuesHolder fadeIn = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1, kf2); ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder( target, fadeIn).setDuration(totalDuration); animator.addUpdateListener(updateListener); return animator; } private class InvalidateUpdateListener implements ValueAnimator.AnimatorUpdateListener { @Override public void onAnimationUpdate(ValueAnimator animation) { RadialTimePickerView.this.invalidate(); } } private void startHoursToMinutesAnimation() { if (mHoursToMinutesAnims.size() == 0) { mHoursToMinutesAnims.add(getRadiusDisappearAnimator(this, "animationRadiusMultiplierHours", mInvalidateUpdateListener, mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier)); mHoursToMinutesAnims.add(getFadeOutAnimator(mPaint[HOURS], ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mHoursToMinutesAnims.add(getFadeOutAnimator(mPaintSelector[HOURS][SELECTOR_CIRCLE], ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mHoursToMinutesAnims.add(getFadeOutAnimator(mPaintSelector[HOURS][SELECTOR_DOT], ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mHoursToMinutesAnims.add(getFadeOutAnimator(mPaintSelector[HOURS][SELECTOR_LINE], ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mHoursToMinutesAnims.add(getRadiusReappearAnimator(this, "animationRadiusMultiplierMinutes", mInvalidateUpdateListener, mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier)); mHoursToMinutesAnims.add(getFadeInAnimator(mPaint[MINUTES], ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener)); mHoursToMinutesAnims.add(getFadeInAnimator(mPaintSelector[MINUTES][SELECTOR_CIRCLE], ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener)); mHoursToMinutesAnims.add(getFadeInAnimator(mPaintSelector[MINUTES][SELECTOR_DOT], ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener)); mHoursToMinutesAnims.add(getFadeInAnimator(mPaintSelector[MINUTES][SELECTOR_LINE], ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener)); } if (mTransition != null && mTransition.isRunning()) { mTransition.end(); } mTransition = new AnimatorSet(); mTransition.playTogether(mHoursToMinutesAnims); mTransition.start(); } private void startMinutesToHoursAnimation() { if (mMinuteToHoursAnims.size() == 0) { mMinuteToHoursAnims.add(getRadiusDisappearAnimator(this, "animationRadiusMultiplierMinutes", mInvalidateUpdateListener, mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier)); mMinuteToHoursAnims.add(getFadeOutAnimator(mPaint[MINUTES], ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mMinuteToHoursAnims.add(getFadeOutAnimator(mPaintSelector[MINUTES][SELECTOR_CIRCLE], ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mMinuteToHoursAnims.add(getFadeOutAnimator(mPaintSelector[MINUTES][SELECTOR_DOT], ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mMinuteToHoursAnims.add(getFadeOutAnimator(mPaintSelector[MINUTES][SELECTOR_LINE], ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener)); mMinuteToHoursAnims.add(getRadiusReappearAnimator(this, "animationRadiusMultiplierHours", mInvalidateUpdateListener, mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier)); mMinuteToHoursAnims.add(getFadeInAnimator(mPaint[HOURS], ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener)); mMinuteToHoursAnims.add(getFadeInAnimator(mPaintSelector[HOURS][SELECTOR_CIRCLE], ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener)); mMinuteToHoursAnims.add(getFadeInAnimator(mPaintSelector[HOURS][SELECTOR_DOT], ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener)); mMinuteToHoursAnims.add(getFadeInAnimator(mPaintSelector[HOURS][SELECTOR_LINE], ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener)); } if (mTransition != null && mTransition.isRunning()) { mTransition.end(); } mTransition = new AnimatorSet(); mTransition.playTogether(mMinuteToHoursAnims); mTransition.start(); } private int getDegreesFromXY(float x, float y) { final double hypotenuse = Math.sqrt( (y - mYCenter) * (y - mYCenter) + (x - mXCenter) * (x - mXCenter)); // Basic check if we're outside the range of the disk if (hypotenuse > mCircleRadius[HOURS]) { return -1; } // Check if (mIs24HourMode && mShowHours) { if (hypotenuse >= mMinHypotenuseForInnerNumber && hypotenuse <= mHalfwayHypotenusePoint) { mIsOnInnerCircle = true; } else if (hypotenuse <= mMaxHypotenuseForOuterNumber && hypotenuse >= mHalfwayHypotenusePoint) { mIsOnInnerCircle = false; } else { return -1; } } else { final int index = (mShowHours) ? HOURS : MINUTES; final float length = (mCircleRadius[index] * mNumbersRadiusMultiplier[index]); final int distanceToNumber = (int) Math.abs(hypotenuse - length); final int maxAllowedDistance = (int) (mCircleRadius[index] * (1 - mNumbersRadiusMultiplier[index])); if (distanceToNumber > maxAllowedDistance) { return -1; } } final float opposite = Math.abs(y - mYCenter); double degrees = Math.toDegrees(Math.asin(opposite / hypotenuse)); // Now we have to translate to the correct quadrant. boolean rightSide = (x > mXCenter); boolean topSide = (y < mYCenter); if (rightSide && topSide) { degrees = 90 - degrees; } else if (rightSide && !topSide) { degrees = 90 + degrees; } else if (!rightSide && !topSide) { degrees = 270 - degrees; } else if (!rightSide && topSide) { degrees = 270 + degrees; } return (int) degrees; } private int getIsTouchingAmOrPm(float x, float y) { final boolean isLayoutRtl = isLayoutRtl(); int squaredYDistance = (int) ((y - mAmPmYCenter) * (y - mAmPmYCenter)); int distanceToAmCenter = (int) Math.sqrt( (x - mLeftIndicatorXCenter) * (x - mLeftIndicatorXCenter) + squaredYDistance); if (distanceToAmCenter <= mAmPmCircleRadius) { return (isLayoutRtl ? PM : AM); } int distanceToPmCenter = (int) Math.sqrt( (x - mRightIndicatorXCenter) * (x - mRightIndicatorXCenter) + squaredYDistance); if (distanceToPmCenter <= mAmPmCircleRadius) { return (isLayoutRtl ? AM : PM); } // Neither was close enough. return -1; } @Override public boolean onTouch(View v, MotionEvent event) { if(!mInputEnabled) { return true; } final float eventX = event.getX(); final float eventY = event.getY(); int degrees; int snapDegrees; boolean result = false; switch(event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: mAmOrPmPressed = getIsTouchingAmOrPm(eventX, eventY); if (mAmOrPmPressed != -1) { result = true; } else { degrees = getDegreesFromXY(eventX, eventY); if (degrees != -1) { snapDegrees = (mShowHours ? snapOnly30s(degrees, 0) : snapPrefer30s(degrees)) % 360; if (mShowHours) { mSelectionDegrees[HOURS] = snapDegrees; mSelectionDegrees[HOURS_INNER] = snapDegrees; } else { mSelectionDegrees[MINUTES] = snapDegrees; } performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); if (mListener != null) { if (mShowHours) { mListener.onValueSelected(HOURS, getCurrentHour(), false); } else { mListener.onValueSelected(MINUTES, getCurrentMinute(), false); } } result = true; } } invalidate(); return result; case MotionEvent.ACTION_UP: mAmOrPmPressed = getIsTouchingAmOrPm(eventX, eventY); if (mAmOrPmPressed != -1) { if (mAmOrPm != mAmOrPmPressed) { swapAmPm(); } mAmOrPmPressed = -1; if (mListener != null) { mListener.onValueSelected(AMPM, getCurrentHour(), true); } result = true; } else { degrees = getDegreesFromXY(eventX, eventY); if (degrees != -1) { snapDegrees = (mShowHours ? snapOnly30s(degrees, 0) : snapPrefer30s(degrees)) % 360; if (mShowHours) { mSelectionDegrees[HOURS] = snapDegrees; mSelectionDegrees[HOURS_INNER] = snapDegrees; } else { mSelectionDegrees[MINUTES] = snapDegrees; } if (mListener != null) { if (mShowHours) { mListener.onValueSelected(HOURS, getCurrentHour(), true); } else { mListener.onValueSelected(MINUTES, getCurrentMinute(), true); } } result = true; } } if (result) { invalidate(); } return result; default: break; } return false; } /** * Necessary for accessibility, to ensure we support "scrolling" forward and backward * in the circle. */ @Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(info); info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); info.addAction(AccessibilityNodeInfo.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(); Time time = new Time(); time.hour = getCurrentHour(); time.minute = getCurrentMinute(); long millis = time.normalize(true); 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. */ @SuppressLint("NewApi") @Override public boolean performAccessibilityAction(int action, Bundle arguments) { if (super.performAccessibilityAction(action, arguments)) { return true; } int changeMultiplier = 0; if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { changeMultiplier = 1; } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { changeMultiplier = -1; } if (changeMultiplier != 0) { int value = 0; int stepSize = 0; if (mShowHours) { stepSize = DEGREES_FOR_ONE_HOUR; value = getCurrentHour() % 12; } else { stepSize = DEGREES_FOR_ONE_MINUTE; value = getCurrentMinute(); } int degrees = value * stepSize; degrees = snapOnly30s(degrees, changeMultiplier); value = degrees / stepSize; int maxValue = 0; int minValue = 0; if (mShowHours) { 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; } if (mShowHours) { setCurrentHour(value); if (mListener != null) { mListener.onValueSelected(HOURS, value, false); } } else { setCurrentMinute(value); if (mListener != null) { mListener.onValueSelected(MINUTES, value, false); } } return true; } return false; } public void setInputEnabled(boolean inputEnabled) { mInputEnabled = inputEnabled; invalidate(); } }