diff options
| author | Fabrice Di Meglio <fdimeglio@google.com> | 2013-08-05 12:07:24 -0700 |
|---|---|---|
| committer | Fabrice Di Meglio <fdimeglio@google.com> | 2013-09-27 12:09:41 -0700 |
| commit | eeff63a5c347f282b5c8c3ae6a0924bf03fbce8f (patch) | |
| tree | 088f7e62f386aff705be5047dde133013a9753fc /core/java/android/widget/RadialTimePickerView.java | |
| parent | 9c437bcb57a7cf5eb3bd1a85a4f2322fd34f50a3 (diff) | |
Update TimePicker widget and its related dialog
- the old TimePicker widget is still there for obvious layout compatibility reasons
- add a new delegate implementation for having a new UI based on a radial picker
- use the new delegate only for the TimePickerDialog (which does not need to be
the same)
- added support for Theming and light/dark Themes
- added support for I18N (hour formatting and time separator and also position of
AM/PM indicator coming from Unicode CLDR)
- added support for RTL
- verified support for Keyboard
- verified that CTS tests for TimePicker are passing (for both the legacy and the
new widgets)
Also added a new HapticFeedbackConstants.CLOCK_TICK and its related code for
enabling ticks vibration.
Change-Id: Ib9b53a152bd9e97383dc391ef8c26da91217298f
Diffstat (limited to 'core/java/android/widget/RadialTimePickerView.java')
| -rw-r--r-- | core/java/android/widget/RadialTimePickerView.java | 1396 |
1 files changed, 1396 insertions, 0 deletions
diff --git a/core/java/android/widget/RadialTimePickerView.java b/core/java/android/widget/RadialTimePickerView.java new file mode 100644 index 000000000000..1c9ab6142a5b --- /dev/null +++ b/core/java/android/widget/RadialTimePickerView.java @@ -0,0 +1,1396 @@ +/* + * 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<Animator> mHoursToMinutesAnims = new ArrayList<Animator>(); + private final ArrayList<Animator> mMinuteToHoursAnims = new ArrayList<Animator>(); + + 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(); + } +} |
