Alan Viverette | 3da604b | 2020-06-10 18:34:39 +0000 | [diff] [blame] | 1 | /* |
| 2 | * Copyright (C) 2013 The Android Open Source Project |
| 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. |
| 6 | * You may obtain a copy of the License at |
| 7 | * |
| 8 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | * |
| 10 | * Unless required by applicable law or agreed to in writing, software |
| 11 | * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | * See the License for the specific language governing permissions and |
| 14 | * limitations under the License. |
| 15 | */ |
| 16 | |
| 17 | package android.widget; |
| 18 | |
| 19 | import android.animation.ObjectAnimator; |
| 20 | import android.annotation.IntDef; |
| 21 | import android.content.Context; |
| 22 | import android.content.res.ColorStateList; |
| 23 | import android.content.res.Resources; |
| 24 | import android.content.res.TypedArray; |
| 25 | import android.graphics.Canvas; |
| 26 | import android.graphics.Color; |
| 27 | import android.graphics.Paint; |
| 28 | import android.graphics.Path; |
| 29 | import android.graphics.Rect; |
| 30 | import android.graphics.Region; |
| 31 | import android.graphics.Typeface; |
| 32 | import android.os.Bundle; |
| 33 | import android.util.AttributeSet; |
| 34 | import android.util.FloatProperty; |
| 35 | import android.util.IntArray; |
| 36 | import android.util.Log; |
| 37 | import android.util.MathUtils; |
| 38 | import android.util.StateSet; |
| 39 | import android.util.TypedValue; |
| 40 | import android.view.HapticFeedbackConstants; |
| 41 | import android.view.MotionEvent; |
| 42 | import android.view.PointerIcon; |
| 43 | import android.view.View; |
| 44 | import android.view.accessibility.AccessibilityEvent; |
| 45 | import android.view.accessibility.AccessibilityNodeInfo; |
| 46 | import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; |
| 47 | |
| 48 | import com.android.internal.R; |
| 49 | import com.android.internal.widget.ExploreByTouchHelper; |
| 50 | |
| 51 | import java.lang.annotation.Retention; |
| 52 | import java.lang.annotation.RetentionPolicy; |
| 53 | import java.util.Calendar; |
| 54 | import java.util.Locale; |
| 55 | |
| 56 | /** |
| 57 | * View to show a clock circle picker (with one or two picking circles) |
| 58 | * |
| 59 | * @hide |
| 60 | */ |
| 61 | public class RadialTimePickerView extends View { |
| 62 | private static final String TAG = "RadialTimePickerView"; |
| 63 | |
| 64 | public static final int HOURS = 0; |
| 65 | public static final int MINUTES = 1; |
| 66 | |
| 67 | /** @hide */ |
| 68 | @IntDef({HOURS, MINUTES}) |
| 69 | @Retention(RetentionPolicy.SOURCE) |
| 70 | @interface PickerType {} |
| 71 | |
| 72 | private static final int HOURS_INNER = 2; |
| 73 | |
| 74 | private static final int SELECTOR_CIRCLE = 0; |
| 75 | private static final int SELECTOR_DOT = 1; |
| 76 | private static final int SELECTOR_LINE = 2; |
| 77 | |
| 78 | private static final int AM = 0; |
| 79 | private static final int PM = 1; |
| 80 | |
| 81 | private static final int HOURS_IN_CIRCLE = 12; |
| 82 | private static final int MINUTES_IN_CIRCLE = 60; |
| 83 | private static final int DEGREES_FOR_ONE_HOUR = 360 / HOURS_IN_CIRCLE; |
| 84 | private static final int DEGREES_FOR_ONE_MINUTE = 360 / MINUTES_IN_CIRCLE; |
| 85 | |
| 86 | private static final int[] HOURS_NUMBERS = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; |
| 87 | private static final int[] HOURS_NUMBERS_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23}; |
| 88 | private static final int[] MINUTES_NUMBERS = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}; |
| 89 | |
| 90 | private static final int ANIM_DURATION_NORMAL = 500; |
| 91 | private static final int ANIM_DURATION_TOUCH = 60; |
| 92 | |
| 93 | private static final int[] SNAP_PREFER_30S_MAP = new int[361]; |
| 94 | |
| 95 | private static final int NUM_POSITIONS = 12; |
| 96 | private static final float[] COS_30 = new float[NUM_POSITIONS]; |
| 97 | private static final float[] SIN_30 = new float[NUM_POSITIONS]; |
| 98 | |
| 99 | /** "Something is wrong" color used when a color attribute is missing. */ |
| 100 | private static final int MISSING_COLOR = Color.MAGENTA; |
| 101 | |
| 102 | static { |
| 103 | // Prepare mapping to snap touchable degrees to selectable degrees. |
| 104 | preparePrefer30sMap(); |
| 105 | |
| 106 | final double increment = 2.0 * Math.PI / NUM_POSITIONS; |
| 107 | double angle = Math.PI / 2.0; |
| 108 | for (int i = 0; i < NUM_POSITIONS; i++) { |
| 109 | COS_30[i] = (float) Math.cos(angle); |
| 110 | SIN_30[i] = (float) Math.sin(angle); |
| 111 | angle += increment; |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | private final FloatProperty<RadialTimePickerView> HOURS_TO_MINUTES = |
| 116 | new FloatProperty<RadialTimePickerView>("hoursToMinutes") { |
| 117 | @Override |
| 118 | public Float get(RadialTimePickerView radialTimePickerView) { |
| 119 | return radialTimePickerView.mHoursToMinutes; |
| 120 | } |
| 121 | |
| 122 | @Override |
| 123 | public void setValue(RadialTimePickerView object, float value) { |
| 124 | object.mHoursToMinutes = value; |
| 125 | object.invalidate(); |
| 126 | } |
| 127 | }; |
| 128 | |
| 129 | private final String[] mHours12Texts = new String[12]; |
| 130 | private final String[] mOuterHours24Texts = new String[12]; |
| 131 | private final String[] mInnerHours24Texts = new String[12]; |
| 132 | private final String[] mMinutesTexts = new String[12]; |
| 133 | |
| 134 | private final Paint[] mPaint = new Paint[2]; |
| 135 | private final Paint mPaintCenter = new Paint(); |
| 136 | private final Paint[] mPaintSelector = new Paint[3]; |
| 137 | private final Paint mPaintBackground = new Paint(); |
| 138 | |
| 139 | private final Typeface mTypeface; |
| 140 | |
| 141 | private final ColorStateList[] mTextColor = new ColorStateList[3]; |
| 142 | private final int[] mTextSize = new int[3]; |
| 143 | private final int[] mTextInset = new int[3]; |
| 144 | |
| 145 | private final float[][] mOuterTextX = new float[2][12]; |
| 146 | private final float[][] mOuterTextY = new float[2][12]; |
| 147 | |
| 148 | private final float[] mInnerTextX = new float[12]; |
| 149 | private final float[] mInnerTextY = new float[12]; |
| 150 | |
| 151 | private final int[] mSelectionDegrees = new int[2]; |
| 152 | |
| 153 | private final RadialPickerTouchHelper mTouchHelper; |
| 154 | |
| 155 | private final Path mSelectorPath = new Path(); |
| 156 | |
| 157 | private boolean mIs24HourMode; |
| 158 | private boolean mShowHours; |
| 159 | |
| 160 | private ObjectAnimator mHoursToMinutesAnimator; |
| 161 | private float mHoursToMinutes; |
| 162 | |
| 163 | /** |
| 164 | * When in 24-hour mode, indicates that the current hour is between |
| 165 | * 1 and 12 (inclusive). |
| 166 | */ |
| 167 | private boolean mIsOnInnerCircle; |
| 168 | |
| 169 | private int mSelectorRadius; |
| 170 | private int mSelectorStroke; |
| 171 | private int mSelectorDotRadius; |
| 172 | private int mCenterDotRadius; |
| 173 | |
| 174 | private int mSelectorColor; |
| 175 | private int mSelectorDotColor; |
| 176 | |
| 177 | private int mXCenter; |
| 178 | private int mYCenter; |
| 179 | private int mCircleRadius; |
| 180 | |
| 181 | private int mMinDistForInnerNumber; |
| 182 | private int mMaxDistForOuterNumber; |
| 183 | private int mHalfwayDist; |
| 184 | |
| 185 | private String[] mOuterTextHours; |
| 186 | private String[] mInnerTextHours; |
| 187 | private String[] mMinutesText; |
| 188 | |
| 189 | private int mAmOrPm; |
| 190 | |
| 191 | private float mDisabledAlpha; |
| 192 | |
| 193 | private OnValueSelectedListener mListener; |
| 194 | |
| 195 | private boolean mInputEnabled = true; |
| 196 | |
| 197 | interface OnValueSelectedListener { |
| 198 | /** |
| 199 | * Called when the selected value at a given picker index has changed. |
| 200 | * |
| 201 | * @param pickerType the type of value that has changed, one of: |
| 202 | * <ul> |
| 203 | * <li>{@link #MINUTES} |
| 204 | * <li>{@link #HOURS} |
| 205 | * </ul> |
| 206 | * @param newValue the new value as minute in hour (0-59) or hour in |
| 207 | * day (0-23) |
| 208 | * @param autoAdvance when the picker type is {@link #HOURS}, |
| 209 | * {@code true} to switch to the {@link #MINUTES} |
| 210 | * picker or {@code false} to stay on the current |
| 211 | * picker. No effect when picker type is |
| 212 | * {@link #MINUTES}. |
| 213 | */ |
| 214 | void onValueSelected(@PickerType int pickerType, int newValue, boolean autoAdvance); |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger |
| 219 | * selectable area to each of the 12 visible values, such that the ratio of space apportioned |
| 220 | * to a visible value : space apportioned to a non-visible value will be 14 : 4. |
| 221 | * E.g. the output of 30 degrees should have a higher range of input associated with it than |
| 222 | * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock |
| 223 | * circle (5 on the minutes, 1 or 13 on the hours). |
| 224 | */ |
| 225 | private static void preparePrefer30sMap() { |
| 226 | // We'll split up the visible output and the non-visible output such that each visible |
| 227 | // output will correspond to a range of 14 associated input degrees, and each non-visible |
| 228 | // output will correspond to a range of 4 associate input degrees, so visible numbers |
| 229 | // are more than 3 times easier to get than non-visible numbers: |
| 230 | // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc. |
| 231 | // |
| 232 | // If an output of 30 degrees should correspond to a range of 14 associated degrees, then |
| 233 | // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should |
| 234 | // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you |
| 235 | // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this |
| 236 | // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the |
| 237 | // ability to aggressively prefer the visible values by a factor of more than 3:1, which |
| 238 | // greatly contributes to the selectability of these values. |
| 239 | |
| 240 | // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}. |
| 241 | int snappedOutputDegrees = 0; |
| 242 | // Count of how many inputs we've designated to the specified output. |
| 243 | int count = 1; |
| 244 | // How many input we expect for a specified output. This will be 14 for output divisible |
| 245 | // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so |
| 246 | // the caller can decide which they need. |
| 247 | int expectedCount = 8; |
| 248 | // Iterate through the input. |
| 249 | for (int degrees = 0; degrees < 361; degrees++) { |
| 250 | // Save the input-output mapping. |
| 251 | SNAP_PREFER_30S_MAP[degrees] = snappedOutputDegrees; |
| 252 | // If this is the last input for the specified output, calculate the next output and |
| 253 | // the next expected count. |
| 254 | if (count == expectedCount) { |
| 255 | snappedOutputDegrees += 6; |
| 256 | if (snappedOutputDegrees == 360) { |
| 257 | expectedCount = 7; |
| 258 | } else if (snappedOutputDegrees % 30 == 0) { |
| 259 | expectedCount = 14; |
| 260 | } else { |
| 261 | expectedCount = 4; |
| 262 | } |
| 263 | count = 1; |
| 264 | } else { |
| 265 | count++; |
| 266 | } |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees, |
| 272 | * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be |
| 273 | * weighted heavier than the degrees corresponding to non-visible numbers. |
| 274 | * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the |
| 275 | * mapping. |
| 276 | */ |
| 277 | private static int snapPrefer30s(int degrees) { |
| 278 | if (SNAP_PREFER_30S_MAP == null) { |
| 279 | return -1; |
| 280 | } |
| 281 | return SNAP_PREFER_30S_MAP[degrees]; |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all |
| 286 | * multiples of 30), where the input will be "snapped" to the closest visible degrees. |
| 287 | * @param degrees The input degrees |
| 288 | * @param forceHigherOrLower The output may be forced to either the higher or lower step, or may |
| 289 | * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force |
| 290 | * strictly lower, and 0 to snap to the closer one. |
| 291 | * @return output degrees, will be a multiple of 30 |
| 292 | */ |
| 293 | private static int snapOnly30s(int degrees, int forceHigherOrLower) { |
| 294 | final int stepSize = DEGREES_FOR_ONE_HOUR; |
| 295 | int floor = (degrees / stepSize) * stepSize; |
| 296 | final int ceiling = floor + stepSize; |
| 297 | if (forceHigherOrLower == 1) { |
| 298 | degrees = ceiling; |
| 299 | } else if (forceHigherOrLower == -1) { |
| 300 | if (degrees == floor) { |
| 301 | floor -= stepSize; |
| 302 | } |
| 303 | degrees = floor; |
| 304 | } else { |
| 305 | if ((degrees - floor) < (ceiling - degrees)) { |
| 306 | degrees = floor; |
| 307 | } else { |
| 308 | degrees = ceiling; |
| 309 | } |
| 310 | } |
| 311 | return degrees; |
| 312 | } |
| 313 | |
| 314 | @SuppressWarnings("unused") |
| 315 | public RadialTimePickerView(Context context) { |
| 316 | this(context, null); |
| 317 | } |
| 318 | |
| 319 | public RadialTimePickerView(Context context, AttributeSet attrs) { |
| 320 | this(context, attrs, R.attr.timePickerStyle); |
| 321 | } |
| 322 | |
| 323 | public RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr) { |
| 324 | this(context, attrs, defStyleAttr, 0); |
| 325 | } |
| 326 | |
| 327 | public RadialTimePickerView( |
| 328 | Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { |
| 329 | super(context, attrs); |
| 330 | |
| 331 | applyAttributes(attrs, defStyleAttr, defStyleRes); |
| 332 | |
| 333 | // Pull disabled alpha from theme. |
| 334 | final TypedValue outValue = new TypedValue(); |
| 335 | context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true); |
| 336 | mDisabledAlpha = outValue.getFloat(); |
| 337 | |
| 338 | mTypeface = Typeface.create("sans-serif", Typeface.NORMAL); |
| 339 | |
| 340 | mPaint[HOURS] = new Paint(); |
| 341 | mPaint[HOURS].setAntiAlias(true); |
| 342 | mPaint[HOURS].setTextAlign(Paint.Align.CENTER); |
| 343 | |
| 344 | mPaint[MINUTES] = new Paint(); |
| 345 | mPaint[MINUTES].setAntiAlias(true); |
| 346 | mPaint[MINUTES].setTextAlign(Paint.Align.CENTER); |
| 347 | |
| 348 | mPaintCenter.setAntiAlias(true); |
| 349 | |
| 350 | mPaintSelector[SELECTOR_CIRCLE] = new Paint(); |
| 351 | mPaintSelector[SELECTOR_CIRCLE].setAntiAlias(true); |
| 352 | |
| 353 | mPaintSelector[SELECTOR_DOT] = new Paint(); |
| 354 | mPaintSelector[SELECTOR_DOT].setAntiAlias(true); |
| 355 | |
| 356 | mPaintSelector[SELECTOR_LINE] = new Paint(); |
| 357 | mPaintSelector[SELECTOR_LINE].setAntiAlias(true); |
| 358 | mPaintSelector[SELECTOR_LINE].setStrokeWidth(2); |
| 359 | |
| 360 | mPaintBackground.setAntiAlias(true); |
| 361 | |
| 362 | final Resources res = getResources(); |
| 363 | mSelectorRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_radius); |
| 364 | mSelectorStroke = res.getDimensionPixelSize(R.dimen.timepicker_selector_stroke); |
| 365 | mSelectorDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_dot_radius); |
| 366 | mCenterDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_center_dot_radius); |
| 367 | |
| 368 | mTextSize[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal); |
| 369 | mTextSize[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal); |
| 370 | mTextSize[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_inner); |
| 371 | |
| 372 | mTextInset[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal); |
| 373 | mTextInset[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal); |
| 374 | mTextInset[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_inner); |
| 375 | |
| 376 | mShowHours = true; |
| 377 | mHoursToMinutes = HOURS; |
| 378 | mIs24HourMode = false; |
| 379 | mAmOrPm = AM; |
| 380 | |
| 381 | // Set up accessibility components. |
| 382 | mTouchHelper = new RadialPickerTouchHelper(); |
| 383 | setAccessibilityDelegate(mTouchHelper); |
| 384 | |
| 385 | if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) { |
| 386 | setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); |
| 387 | } |
| 388 | |
| 389 | initHoursAndMinutesText(); |
| 390 | initData(); |
| 391 | |
| 392 | // Initial values |
| 393 | final Calendar calendar = Calendar.getInstance(Locale.getDefault()); |
| 394 | final int currentHour = calendar.get(Calendar.HOUR_OF_DAY); |
| 395 | final int currentMinute = calendar.get(Calendar.MINUTE); |
| 396 | |
| 397 | setCurrentHourInternal(currentHour, false, false); |
| 398 | setCurrentMinuteInternal(currentMinute, false); |
| 399 | |
| 400 | setHapticFeedbackEnabled(true); |
| 401 | } |
| 402 | |
| 403 | void applyAttributes(AttributeSet attrs, int defStyleAttr, int defStyleRes) { |
| 404 | final Context context = getContext(); |
| 405 | final TypedArray a = getContext().obtainStyledAttributes(attrs, |
| 406 | R.styleable.TimePicker, defStyleAttr, defStyleRes); |
| 407 | saveAttributeDataForStyleable(context, R.styleable.TimePicker, |
| 408 | attrs, a, defStyleAttr, defStyleRes); |
| 409 | |
| 410 | final ColorStateList numbersTextColor = a.getColorStateList( |
| 411 | R.styleable.TimePicker_numbersTextColor); |
| 412 | final ColorStateList numbersInnerTextColor = a.getColorStateList( |
| 413 | R.styleable.TimePicker_numbersInnerTextColor); |
| 414 | mTextColor[HOURS] = numbersTextColor == null ? |
| 415 | ColorStateList.valueOf(MISSING_COLOR) : numbersTextColor; |
| 416 | mTextColor[HOURS_INNER] = numbersInnerTextColor == null ? |
| 417 | ColorStateList.valueOf(MISSING_COLOR) : numbersInnerTextColor; |
| 418 | mTextColor[MINUTES] = mTextColor[HOURS]; |
| 419 | |
| 420 | // Set up various colors derived from the selector "activated" state. |
| 421 | final ColorStateList selectorColors = a.getColorStateList( |
| 422 | R.styleable.TimePicker_numbersSelectorColor); |
| 423 | final int selectorActivatedColor; |
| 424 | if (selectorColors != null) { |
| 425 | final int[] stateSetEnabledActivated = StateSet.get( |
| 426 | StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED); |
| 427 | selectorActivatedColor = selectorColors.getColorForState( |
| 428 | stateSetEnabledActivated, 0); |
| 429 | } else { |
| 430 | selectorActivatedColor = MISSING_COLOR; |
| 431 | } |
| 432 | |
| 433 | mPaintCenter.setColor(selectorActivatedColor); |
| 434 | |
| 435 | final int[] stateSetActivated = StateSet.get( |
| 436 | StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED); |
| 437 | |
| 438 | mSelectorColor = selectorActivatedColor; |
| 439 | mSelectorDotColor = mTextColor[HOURS].getColorForState(stateSetActivated, 0); |
| 440 | |
| 441 | mPaintBackground.setColor(a.getColor(R.styleable.TimePicker_numbersBackgroundColor, |
| 442 | context.getColor(R.color.timepicker_default_numbers_background_color_material))); |
| 443 | |
| 444 | a.recycle(); |
| 445 | } |
| 446 | |
| 447 | public void initialize(int hour, int minute, boolean is24HourMode) { |
| 448 | if (mIs24HourMode != is24HourMode) { |
| 449 | mIs24HourMode = is24HourMode; |
| 450 | initData(); |
| 451 | } |
| 452 | |
| 453 | setCurrentHourInternal(hour, false, false); |
| 454 | setCurrentMinuteInternal(minute, false); |
| 455 | } |
| 456 | |
| 457 | public void setCurrentItemShowing(int item, boolean animate) { |
| 458 | switch (item){ |
| 459 | case HOURS: |
| 460 | showHours(animate); |
| 461 | break; |
| 462 | case MINUTES: |
| 463 | showMinutes(animate); |
| 464 | break; |
| 465 | default: |
| 466 | Log.e(TAG, "ClockView does not support showing item " + item); |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | public int getCurrentItemShowing() { |
| 471 | return mShowHours ? HOURS : MINUTES; |
| 472 | } |
| 473 | |
| 474 | public void setOnValueSelectedListener(OnValueSelectedListener listener) { |
| 475 | mListener = listener; |
| 476 | } |
| 477 | |
| 478 | /** |
| 479 | * Sets the current hour in 24-hour time. |
| 480 | * |
| 481 | * @param hour the current hour between 0 and 23 (inclusive) |
| 482 | */ |
| 483 | public void setCurrentHour(int hour) { |
| 484 | setCurrentHourInternal(hour, true, false); |
| 485 | } |
| 486 | |
| 487 | /** |
| 488 | * Sets the current hour. |
| 489 | * |
| 490 | * @param hour The current hour |
| 491 | * @param callback Whether the value listener should be invoked |
| 492 | * @param autoAdvance Whether the listener should auto-advance to the next |
| 493 | * selection mode, e.g. hour to minutes |
| 494 | */ |
| 495 | private void setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance) { |
| 496 | final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR; |
| 497 | mSelectionDegrees[HOURS] = degrees; |
| 498 | |
| 499 | // 0 is 12 AM (midnight) and 12 is 12 PM (noon). |
| 500 | final int amOrPm = (hour == 0 || (hour % 24) < 12) ? AM : PM; |
| 501 | final boolean isOnInnerCircle = getInnerCircleForHour(hour); |
| 502 | if (mAmOrPm != amOrPm || mIsOnInnerCircle != isOnInnerCircle) { |
| 503 | mAmOrPm = amOrPm; |
| 504 | mIsOnInnerCircle = isOnInnerCircle; |
| 505 | |
| 506 | initData(); |
| 507 | mTouchHelper.invalidateRoot(); |
| 508 | } |
| 509 | |
| 510 | invalidate(); |
| 511 | |
| 512 | if (callback && mListener != null) { |
| 513 | mListener.onValueSelected(HOURS, hour, autoAdvance); |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | /** |
| 518 | * Returns the current hour in 24-hour time. |
| 519 | * |
| 520 | * @return the current hour between 0 and 23 (inclusive) |
| 521 | */ |
| 522 | public int getCurrentHour() { |
| 523 | return getHourForDegrees(mSelectionDegrees[HOURS], mIsOnInnerCircle); |
| 524 | } |
| 525 | |
| 526 | private int getHourForDegrees(int degrees, boolean innerCircle) { |
| 527 | int hour = (degrees / DEGREES_FOR_ONE_HOUR) % 12; |
| 528 | if (mIs24HourMode) { |
| 529 | // Convert the 12-hour value into 24-hour time based on where the |
| 530 | // selector is positioned. |
| 531 | if (!innerCircle && hour == 0) { |
| 532 | // Outer circle is 1 through 12. |
| 533 | hour = 12; |
| 534 | } else if (innerCircle && hour != 0) { |
| 535 | // Inner circle is 13 through 23 and 0. |
| 536 | hour += 12; |
| 537 | } |
| 538 | } else if (mAmOrPm == PM) { |
| 539 | hour += 12; |
| 540 | } |
| 541 | return hour; |
| 542 | } |
| 543 | |
| 544 | /** |
| 545 | * @param hour the hour in 24-hour time or 12-hour time |
| 546 | */ |
| 547 | private int getDegreesForHour(int hour) { |
| 548 | // Convert to be 0-11. |
| 549 | if (mIs24HourMode) { |
| 550 | if (hour >= 12) { |
| 551 | hour -= 12; |
| 552 | } |
| 553 | } else if (hour == 12) { |
| 554 | hour = 0; |
| 555 | } |
| 556 | return hour * DEGREES_FOR_ONE_HOUR; |
| 557 | } |
| 558 | |
| 559 | /** |
| 560 | * @param hour the hour in 24-hour time or 12-hour time |
| 561 | */ |
| 562 | private boolean getInnerCircleForHour(int hour) { |
| 563 | return mIs24HourMode && (hour == 0 || hour > 12); |
| 564 | } |
| 565 | |
| 566 | public void setCurrentMinute(int minute) { |
| 567 | setCurrentMinuteInternal(minute, true); |
| 568 | } |
| 569 | |
| 570 | private void setCurrentMinuteInternal(int minute, boolean callback) { |
| 571 | mSelectionDegrees[MINUTES] = (minute % MINUTES_IN_CIRCLE) * DEGREES_FOR_ONE_MINUTE; |
| 572 | |
| 573 | invalidate(); |
| 574 | |
| 575 | if (callback && mListener != null) { |
| 576 | mListener.onValueSelected(MINUTES, minute, false); |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | // Returns minutes in 0-59 range |
| 581 | public int getCurrentMinute() { |
| 582 | return getMinuteForDegrees(mSelectionDegrees[MINUTES]); |
| 583 | } |
| 584 | |
| 585 | private int getMinuteForDegrees(int degrees) { |
| 586 | return degrees / DEGREES_FOR_ONE_MINUTE; |
| 587 | } |
| 588 | |
| 589 | private int getDegreesForMinute(int minute) { |
| 590 | return minute * DEGREES_FOR_ONE_MINUTE; |
| 591 | } |
| 592 | |
| 593 | /** |
| 594 | * Sets whether the picker is showing AM or PM hours. Has no effect when |
| 595 | * in 24-hour mode. |
| 596 | * |
| 597 | * @param amOrPm {@link #AM} or {@link #PM} |
| 598 | * @return {@code true} if the value changed from what was previously set, |
| 599 | * or {@code false} otherwise |
| 600 | */ |
| 601 | public boolean setAmOrPm(int amOrPm) { |
| 602 | if (mAmOrPm == amOrPm || mIs24HourMode) { |
| 603 | return false; |
| 604 | } |
| 605 | |
| 606 | mAmOrPm = amOrPm; |
| 607 | invalidate(); |
| 608 | mTouchHelper.invalidateRoot(); |
| 609 | return true; |
| 610 | } |
| 611 | |
| 612 | public int getAmOrPm() { |
| 613 | return mAmOrPm; |
| 614 | } |
| 615 | |
| 616 | public void showHours(boolean animate) { |
| 617 | showPicker(true, animate); |
| 618 | } |
| 619 | |
| 620 | public void showMinutes(boolean animate) { |
| 621 | showPicker(false, animate); |
| 622 | } |
| 623 | |
| 624 | private void initHoursAndMinutesText() { |
| 625 | // Initialize the hours and minutes numbers. |
| 626 | for (int i = 0; i < 12; i++) { |
| 627 | mHours12Texts[i] = String.format("%d", HOURS_NUMBERS[i]); |
| 628 | mInnerHours24Texts[i] = String.format("%02d", HOURS_NUMBERS_24[i]); |
| 629 | mOuterHours24Texts[i] = String.format("%d", HOURS_NUMBERS[i]); |
| 630 | mMinutesTexts[i] = String.format("%02d", MINUTES_NUMBERS[i]); |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | private void initData() { |
| 635 | if (mIs24HourMode) { |
| 636 | mOuterTextHours = mOuterHours24Texts; |
| 637 | mInnerTextHours = mInnerHours24Texts; |
| 638 | } else { |
| 639 | mOuterTextHours = mHours12Texts; |
| 640 | mInnerTextHours = mHours12Texts; |
| 641 | } |
| 642 | |
| 643 | mMinutesText = mMinutesTexts; |
| 644 | } |
| 645 | |
| 646 | @Override |
| 647 | protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| 648 | if (!changed) { |
| 649 | return; |
| 650 | } |
| 651 | |
| 652 | mXCenter = getWidth() / 2; |
| 653 | mYCenter = getHeight() / 2; |
| 654 | mCircleRadius = Math.min(mXCenter, mYCenter); |
| 655 | |
| 656 | mMinDistForInnerNumber = mCircleRadius - mTextInset[HOURS_INNER] - mSelectorRadius; |
| 657 | mMaxDistForOuterNumber = mCircleRadius - mTextInset[HOURS] + mSelectorRadius; |
| 658 | mHalfwayDist = mCircleRadius - (mTextInset[HOURS] + mTextInset[HOURS_INNER]) / 2; |
| 659 | |
| 660 | calculatePositionsHours(); |
| 661 | calculatePositionsMinutes(); |
| 662 | |
| 663 | mTouchHelper.invalidateRoot(); |
| 664 | } |
| 665 | |
| 666 | @Override |
| 667 | public void onDraw(Canvas canvas) { |
| 668 | final float alphaMod = mInputEnabled ? 1 : mDisabledAlpha; |
| 669 | |
| 670 | drawCircleBackground(canvas); |
| 671 | |
| 672 | final Path selectorPath = mSelectorPath; |
| 673 | drawSelector(canvas, selectorPath); |
| 674 | drawHours(canvas, selectorPath, alphaMod); |
| 675 | drawMinutes(canvas, selectorPath, alphaMod); |
| 676 | drawCenter(canvas, alphaMod); |
| 677 | } |
| 678 | |
| 679 | private void showPicker(boolean hours, boolean animate) { |
| 680 | if (mShowHours == hours) { |
| 681 | return; |
| 682 | } |
| 683 | |
| 684 | mShowHours = hours; |
| 685 | |
| 686 | if (animate) { |
| 687 | animatePicker(hours, ANIM_DURATION_NORMAL); |
| 688 | } else { |
| 689 | // If we have a pending or running animator, cancel it. |
| 690 | if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) { |
| 691 | mHoursToMinutesAnimator.cancel(); |
| 692 | mHoursToMinutesAnimator = null; |
| 693 | } |
| 694 | mHoursToMinutes = hours ? 0.0f : 1.0f; |
| 695 | } |
| 696 | |
| 697 | initData(); |
| 698 | invalidate(); |
| 699 | mTouchHelper.invalidateRoot(); |
| 700 | } |
| 701 | |
| 702 | private void animatePicker(boolean hoursToMinutes, long duration) { |
| 703 | final float target = hoursToMinutes ? HOURS : MINUTES; |
| 704 | if (mHoursToMinutes == target) { |
| 705 | // If we have a pending or running animator, cancel it. |
| 706 | if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) { |
| 707 | mHoursToMinutesAnimator.cancel(); |
| 708 | mHoursToMinutesAnimator = null; |
| 709 | } |
| 710 | |
| 711 | // We're already showing the correct picker. |
| 712 | return; |
| 713 | } |
| 714 | |
| 715 | mHoursToMinutesAnimator = ObjectAnimator.ofFloat(this, HOURS_TO_MINUTES, target); |
| 716 | mHoursToMinutesAnimator.setAutoCancel(true); |
| 717 | mHoursToMinutesAnimator.setDuration(duration); |
| 718 | mHoursToMinutesAnimator.start(); |
| 719 | } |
| 720 | |
| 721 | private void drawCircleBackground(Canvas canvas) { |
| 722 | canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaintBackground); |
| 723 | } |
| 724 | |
| 725 | private void drawHours(Canvas canvas, Path selectorPath, float alphaMod) { |
| 726 | final int hoursAlpha = (int) (255f * (1f - mHoursToMinutes) * alphaMod + 0.5f); |
| 727 | if (hoursAlpha > 0) { |
| 728 | // Exclude the selector region, then draw inner/outer hours with no |
| 729 | // activated states. |
| 730 | canvas.save(Canvas.CLIP_SAVE_FLAG); |
| 731 | canvas.clipPath(selectorPath, Region.Op.DIFFERENCE); |
| 732 | drawHoursClipped(canvas, hoursAlpha, false); |
| 733 | canvas.restore(); |
| 734 | |
| 735 | // Intersect the selector region, then draw minutes with only |
| 736 | // activated states. |
| 737 | canvas.save(Canvas.CLIP_SAVE_FLAG); |
| 738 | canvas.clipPath(selectorPath, Region.Op.INTERSECT); |
| 739 | drawHoursClipped(canvas, hoursAlpha, true); |
| 740 | canvas.restore(); |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | private void drawHoursClipped(Canvas canvas, int hoursAlpha, boolean showActivated) { |
| 745 | // Draw outer hours. |
| 746 | drawTextElements(canvas, mTextSize[HOURS], mTypeface, mTextColor[HOURS], mOuterTextHours, |
| 747 | mOuterTextX[HOURS], mOuterTextY[HOURS], mPaint[HOURS], hoursAlpha, |
| 748 | showActivated && !mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated); |
| 749 | |
| 750 | // Draw inner hours (13-00) for 24-hour time. |
| 751 | if (mIs24HourMode && mInnerTextHours != null) { |
| 752 | drawTextElements(canvas, mTextSize[HOURS_INNER], mTypeface, mTextColor[HOURS_INNER], |
| 753 | mInnerTextHours, mInnerTextX, mInnerTextY, mPaint[HOURS], hoursAlpha, |
| 754 | showActivated && mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated); |
| 755 | } |
| 756 | } |
| 757 | |
| 758 | private void drawMinutes(Canvas canvas, Path selectorPath, float alphaMod) { |
| 759 | final int minutesAlpha = (int) (255f * mHoursToMinutes * alphaMod + 0.5f); |
| 760 | if (minutesAlpha > 0) { |
| 761 | // Exclude the selector region, then draw minutes with no |
| 762 | // activated states. |
| 763 | canvas.save(Canvas.CLIP_SAVE_FLAG); |
| 764 | canvas.clipPath(selectorPath, Region.Op.DIFFERENCE); |
| 765 | drawMinutesClipped(canvas, minutesAlpha, false); |
| 766 | canvas.restore(); |
| 767 | |
| 768 | // Intersect the selector region, then draw minutes with only |
| 769 | // activated states. |
| 770 | canvas.save(Canvas.CLIP_SAVE_FLAG); |
| 771 | canvas.clipPath(selectorPath, Region.Op.INTERSECT); |
| 772 | drawMinutesClipped(canvas, minutesAlpha, true); |
| 773 | canvas.restore(); |
| 774 | } |
| 775 | } |
| 776 | |
| 777 | private void drawMinutesClipped(Canvas canvas, int minutesAlpha, boolean showActivated) { |
| 778 | drawTextElements(canvas, mTextSize[MINUTES], mTypeface, mTextColor[MINUTES], mMinutesText, |
| 779 | mOuterTextX[MINUTES], mOuterTextY[MINUTES], mPaint[MINUTES], minutesAlpha, |
| 780 | showActivated, mSelectionDegrees[MINUTES], showActivated); |
| 781 | } |
| 782 | |
| 783 | private void drawCenter(Canvas canvas, float alphaMod) { |
| 784 | mPaintCenter.setAlpha((int) (255 * alphaMod + 0.5f)); |
| 785 | canvas.drawCircle(mXCenter, mYCenter, mCenterDotRadius, mPaintCenter); |
| 786 | } |
| 787 | |
| 788 | private int getMultipliedAlpha(int argb, int alpha) { |
| 789 | return (int) (Color.alpha(argb) * (alpha / 255.0) + 0.5); |
| 790 | } |
| 791 | |
| 792 | private void drawSelector(Canvas canvas, Path selectorPath) { |
| 793 | // Determine the current length, angle, and dot scaling factor. |
| 794 | final int hoursIndex = mIsOnInnerCircle ? HOURS_INNER : HOURS; |
| 795 | final int hoursInset = mTextInset[hoursIndex]; |
| 796 | final int hoursAngleDeg = mSelectionDegrees[hoursIndex % 2]; |
| 797 | final float hoursDotScale = mSelectionDegrees[hoursIndex % 2] % 30 != 0 ? 1 : 0; |
| 798 | |
| 799 | final int minutesIndex = MINUTES; |
| 800 | final int minutesInset = mTextInset[minutesIndex]; |
| 801 | final int minutesAngleDeg = mSelectionDegrees[minutesIndex]; |
| 802 | final float minutesDotScale = mSelectionDegrees[minutesIndex] % 30 != 0 ? 1 : 0; |
| 803 | |
| 804 | // Calculate the current radius at which to place the selection circle. |
| 805 | final int selRadius = mSelectorRadius; |
| 806 | final float selLength = |
| 807 | mCircleRadius - MathUtils.lerp(hoursInset, minutesInset, mHoursToMinutes); |
| 808 | final double selAngleRad = |
| 809 | Math.toRadians(MathUtils.lerpDeg(hoursAngleDeg, minutesAngleDeg, mHoursToMinutes)); |
| 810 | final float selCenterX = mXCenter + selLength * (float) Math.sin(selAngleRad); |
| 811 | final float selCenterY = mYCenter - selLength * (float) Math.cos(selAngleRad); |
| 812 | |
| 813 | // Draw the selection circle. |
| 814 | final Paint paint = mPaintSelector[SELECTOR_CIRCLE]; |
| 815 | paint.setColor(mSelectorColor); |
| 816 | canvas.drawCircle(selCenterX, selCenterY, selRadius, paint); |
| 817 | |
| 818 | // If needed, set up the clip path for later. |
| 819 | if (selectorPath != null) { |
| 820 | selectorPath.reset(); |
| 821 | selectorPath.addCircle(selCenterX, selCenterY, selRadius, Path.Direction.CCW); |
| 822 | } |
| 823 | |
| 824 | // Draw the dot if we're between two items. |
| 825 | final float dotScale = MathUtils.lerp(hoursDotScale, minutesDotScale, mHoursToMinutes); |
| 826 | if (dotScale > 0) { |
| 827 | final Paint dotPaint = mPaintSelector[SELECTOR_DOT]; |
| 828 | dotPaint.setColor(mSelectorDotColor); |
| 829 | canvas.drawCircle(selCenterX, selCenterY, mSelectorDotRadius * dotScale, dotPaint); |
| 830 | } |
| 831 | |
| 832 | // Shorten the line to only go from the edge of the center dot to the |
| 833 | // edge of the selection circle. |
| 834 | final double sin = Math.sin(selAngleRad); |
| 835 | final double cos = Math.cos(selAngleRad); |
| 836 | final float lineLength = selLength - selRadius; |
| 837 | final int centerX = mXCenter + (int) (mCenterDotRadius * sin); |
| 838 | final int centerY = mYCenter - (int) (mCenterDotRadius * cos); |
| 839 | final float linePointX = centerX + (int) (lineLength * sin); |
| 840 | final float linePointY = centerY - (int) (lineLength * cos); |
| 841 | |
| 842 | // Draw the line. |
| 843 | final Paint linePaint = mPaintSelector[SELECTOR_LINE]; |
| 844 | linePaint.setColor(mSelectorColor); |
| 845 | linePaint.setStrokeWidth(mSelectorStroke); |
| 846 | canvas.drawLine(mXCenter, mYCenter, linePointX, linePointY, linePaint); |
| 847 | } |
| 848 | |
| 849 | private void calculatePositionsHours() { |
| 850 | // Calculate the text positions |
| 851 | final float numbersRadius = mCircleRadius - mTextInset[HOURS]; |
| 852 | |
| 853 | // Calculate the positions for the 12 numbers in the main circle. |
| 854 | calculatePositions(mPaint[HOURS], numbersRadius, mXCenter, mYCenter, |
| 855 | mTextSize[HOURS], mOuterTextX[HOURS], mOuterTextY[HOURS]); |
| 856 | |
| 857 | // If we have an inner circle, calculate those positions too. |
| 858 | if (mIs24HourMode) { |
| 859 | final int innerNumbersRadius = mCircleRadius - mTextInset[HOURS_INNER]; |
| 860 | calculatePositions(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter, |
| 861 | mTextSize[HOURS_INNER], mInnerTextX, mInnerTextY); |
| 862 | } |
| 863 | } |
| 864 | |
| 865 | private void calculatePositionsMinutes() { |
| 866 | // Calculate the text positions |
| 867 | final float numbersRadius = mCircleRadius - mTextInset[MINUTES]; |
| 868 | |
| 869 | // Calculate the positions for the 12 numbers in the main circle. |
| 870 | calculatePositions(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter, |
| 871 | mTextSize[MINUTES], mOuterTextX[MINUTES], mOuterTextY[MINUTES]); |
| 872 | } |
| 873 | |
| 874 | /** |
| 875 | * Using the trigonometric Unit Circle, calculate the positions that the text will need to be |
| 876 | * drawn at based on the specified circle radius. Place the values in the textGridHeights and |
| 877 | * textGridWidths parameters. |
| 878 | */ |
| 879 | private static void calculatePositions(Paint paint, float radius, float xCenter, float yCenter, |
| 880 | float textSize, float[] x, float[] y) { |
| 881 | // Adjust yCenter to account for the text's baseline. |
| 882 | paint.setTextSize(textSize); |
| 883 | yCenter -= (paint.descent() + paint.ascent()) / 2; |
| 884 | |
| 885 | for (int i = 0; i < NUM_POSITIONS; i++) { |
| 886 | x[i] = xCenter - radius * COS_30[i]; |
| 887 | y[i] = yCenter - radius * SIN_30[i]; |
| 888 | } |
| 889 | } |
| 890 | |
| 891 | /** |
| 892 | * Draw the 12 text values at the positions specified by the textGrid parameters. |
| 893 | */ |
| 894 | private void drawTextElements(Canvas canvas, float textSize, Typeface typeface, |
| 895 | ColorStateList textColor, String[] texts, float[] textX, float[] textY, Paint paint, |
| 896 | int alpha, boolean showActivated, int activatedDegrees, boolean activatedOnly) { |
| 897 | paint.setTextSize(textSize); |
| 898 | paint.setTypeface(typeface); |
| 899 | |
| 900 | // The activated index can touch a range of elements. |
| 901 | final float activatedIndex = activatedDegrees / (360.0f / NUM_POSITIONS); |
| 902 | final int activatedFloor = (int) activatedIndex; |
| 903 | final int activatedCeil = ((int) Math.ceil(activatedIndex)) % NUM_POSITIONS; |
| 904 | |
| 905 | for (int i = 0; i < 12; i++) { |
| 906 | final boolean activated = (activatedFloor == i || activatedCeil == i); |
| 907 | if (activatedOnly && !activated) { |
| 908 | continue; |
| 909 | } |
| 910 | |
| 911 | final int stateMask = StateSet.VIEW_STATE_ENABLED |
| 912 | | (showActivated && activated ? StateSet.VIEW_STATE_ACTIVATED : 0); |
| 913 | final int color = textColor.getColorForState(StateSet.get(stateMask), 0); |
| 914 | paint.setColor(color); |
| 915 | paint.setAlpha(getMultipliedAlpha(color, alpha)); |
| 916 | |
| 917 | canvas.drawText(texts[i], textX[i], textY[i], paint); |
| 918 | } |
| 919 | } |
| 920 | |
| 921 | private int getDegreesFromXY(float x, float y, boolean constrainOutside) { |
| 922 | // Ensure the point is inside the touchable area. |
| 923 | final int innerBound; |
| 924 | final int outerBound; |
| 925 | if (mIs24HourMode && mShowHours) { |
| 926 | innerBound = mMinDistForInnerNumber; |
| 927 | outerBound = mMaxDistForOuterNumber; |
| 928 | } else { |
| 929 | final int index = mShowHours ? HOURS : MINUTES; |
| 930 | final int center = mCircleRadius - mTextInset[index]; |
| 931 | innerBound = center - mSelectorRadius; |
| 932 | outerBound = center + mSelectorRadius; |
| 933 | } |
| 934 | |
| 935 | final double dX = x - mXCenter; |
| 936 | final double dY = y - mYCenter; |
| 937 | final double distFromCenter = Math.sqrt(dX * dX + dY * dY); |
| 938 | if (distFromCenter < innerBound || constrainOutside && distFromCenter > outerBound) { |
| 939 | return -1; |
| 940 | } |
| 941 | |
| 942 | // Convert to degrees. |
| 943 | final int degrees = (int) (Math.toDegrees(Math.atan2(dY, dX) + Math.PI / 2) + 0.5); |
| 944 | if (degrees < 0) { |
| 945 | return degrees + 360; |
| 946 | } else { |
| 947 | return degrees; |
| 948 | } |
| 949 | } |
| 950 | |
| 951 | private boolean getInnerCircleFromXY(float x, float y) { |
| 952 | if (mIs24HourMode && mShowHours) { |
| 953 | final double dX = x - mXCenter; |
| 954 | final double dY = y - mYCenter; |
| 955 | final double distFromCenter = Math.sqrt(dX * dX + dY * dY); |
| 956 | return distFromCenter <= mHalfwayDist; |
| 957 | } |
| 958 | return false; |
| 959 | } |
| 960 | |
| 961 | boolean mChangedDuringTouch = false; |
| 962 | |
| 963 | @Override |
| 964 | public boolean onTouchEvent(MotionEvent event) { |
| 965 | if (!mInputEnabled) { |
| 966 | return true; |
| 967 | } |
| 968 | |
| 969 | final int action = event.getActionMasked(); |
| 970 | if (action == MotionEvent.ACTION_MOVE |
| 971 | || action == MotionEvent.ACTION_UP |
| 972 | || action == MotionEvent.ACTION_DOWN) { |
| 973 | boolean forceSelection = false; |
| 974 | boolean autoAdvance = false; |
| 975 | |
| 976 | if (action == MotionEvent.ACTION_DOWN) { |
| 977 | // This is a new event stream, reset whether the value changed. |
| 978 | mChangedDuringTouch = false; |
| 979 | } else if (action == MotionEvent.ACTION_UP) { |
| 980 | autoAdvance = true; |
| 981 | |
| 982 | // If we saw a down/up pair without the value changing, assume |
| 983 | // this is a single-tap selection and force a change. |
| 984 | if (!mChangedDuringTouch) { |
| 985 | forceSelection = true; |
| 986 | } |
| 987 | } |
| 988 | |
| 989 | mChangedDuringTouch |= handleTouchInput( |
| 990 | event.getX(), event.getY(), forceSelection, autoAdvance); |
| 991 | } |
| 992 | |
| 993 | return true; |
| 994 | } |
| 995 | |
| 996 | private boolean handleTouchInput( |
| 997 | float x, float y, boolean forceSelection, boolean autoAdvance) { |
| 998 | final boolean isOnInnerCircle = getInnerCircleFromXY(x, y); |
| 999 | final int degrees = getDegreesFromXY(x, y, false); |
| 1000 | if (degrees == -1) { |
| 1001 | return false; |
| 1002 | } |
| 1003 | |
| 1004 | // Ensure we're showing the correct picker. |
| 1005 | animatePicker(mShowHours, ANIM_DURATION_TOUCH); |
| 1006 | |
| 1007 | final @PickerType int type; |
| 1008 | final int newValue; |
| 1009 | final boolean valueChanged; |
| 1010 | |
| 1011 | if (mShowHours) { |
| 1012 | final int snapDegrees = snapOnly30s(degrees, 0) % 360; |
| 1013 | valueChanged = mIsOnInnerCircle != isOnInnerCircle |
| 1014 | || mSelectionDegrees[HOURS] != snapDegrees; |
| 1015 | mIsOnInnerCircle = isOnInnerCircle; |
| 1016 | mSelectionDegrees[HOURS] = snapDegrees; |
| 1017 | type = HOURS; |
| 1018 | newValue = getCurrentHour(); |
| 1019 | } else { |
| 1020 | final int snapDegrees = snapPrefer30s(degrees) % 360; |
| 1021 | valueChanged = mSelectionDegrees[MINUTES] != snapDegrees; |
| 1022 | mSelectionDegrees[MINUTES] = snapDegrees; |
| 1023 | type = MINUTES; |
| 1024 | newValue = getCurrentMinute(); |
| 1025 | } |
| 1026 | |
| 1027 | if (valueChanged || forceSelection || autoAdvance) { |
| 1028 | // Fire the listener even if we just need to auto-advance. |
| 1029 | if (mListener != null) { |
| 1030 | mListener.onValueSelected(type, newValue, autoAdvance); |
| 1031 | } |
| 1032 | |
| 1033 | // Only provide feedback if the value actually changed. |
| 1034 | if (valueChanged || forceSelection) { |
| 1035 | performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); |
| 1036 | invalidate(); |
| 1037 | } |
| 1038 | return true; |
| 1039 | } |
| 1040 | |
| 1041 | return false; |
| 1042 | } |
| 1043 | |
| 1044 | @Override |
| 1045 | public boolean dispatchHoverEvent(MotionEvent event) { |
| 1046 | // First right-of-refusal goes the touch exploration helper. |
| 1047 | if (mTouchHelper.dispatchHoverEvent(event)) { |
| 1048 | return true; |
| 1049 | } |
| 1050 | return super.dispatchHoverEvent(event); |
| 1051 | } |
| 1052 | |
| 1053 | public void setInputEnabled(boolean inputEnabled) { |
| 1054 | mInputEnabled = inputEnabled; |
| 1055 | invalidate(); |
| 1056 | } |
| 1057 | |
| 1058 | @Override |
| 1059 | public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) { |
| 1060 | if (!isEnabled()) { |
| 1061 | return null; |
| 1062 | } |
| 1063 | final int degrees = getDegreesFromXY(event.getX(), event.getY(), false); |
| 1064 | if (degrees != -1) { |
| 1065 | return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND); |
| 1066 | } |
| 1067 | return super.onResolvePointerIcon(event, pointerIndex); |
| 1068 | } |
| 1069 | |
| 1070 | private class RadialPickerTouchHelper extends ExploreByTouchHelper { |
| 1071 | private final Rect mTempRect = new Rect(); |
| 1072 | |
| 1073 | private final int TYPE_HOUR = 1; |
| 1074 | private final int TYPE_MINUTE = 2; |
| 1075 | |
| 1076 | private final int SHIFT_TYPE = 0; |
| 1077 | private final int MASK_TYPE = 0xF; |
| 1078 | |
| 1079 | private final int SHIFT_VALUE = 8; |
| 1080 | private final int MASK_VALUE = 0xFF; |
| 1081 | |
| 1082 | /** Increment in which virtual views are exposed for minutes. */ |
| 1083 | private final int MINUTE_INCREMENT = 5; |
| 1084 | |
| 1085 | public RadialPickerTouchHelper() { |
| 1086 | super(RadialTimePickerView.this); |
| 1087 | } |
| 1088 | |
| 1089 | @Override |
| 1090 | public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { |
| 1091 | super.onInitializeAccessibilityNodeInfo(host, info); |
| 1092 | |
| 1093 | info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); |
| 1094 | info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); |
| 1095 | } |
| 1096 | |
| 1097 | @Override |
| 1098 | public boolean performAccessibilityAction(View host, int action, Bundle arguments) { |
| 1099 | if (super.performAccessibilityAction(host, action, arguments)) { |
| 1100 | return true; |
| 1101 | } |
| 1102 | |
| 1103 | switch (action) { |
| 1104 | case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: |
| 1105 | adjustPicker(1); |
| 1106 | return true; |
| 1107 | case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: |
| 1108 | adjustPicker(-1); |
| 1109 | return true; |
| 1110 | } |
| 1111 | |
| 1112 | return false; |
| 1113 | } |
| 1114 | |
| 1115 | private void adjustPicker(int step) { |
| 1116 | final int stepSize; |
| 1117 | final int initialStep; |
| 1118 | final int maxValue; |
| 1119 | final int minValue; |
| 1120 | if (mShowHours) { |
| 1121 | stepSize = 1; |
| 1122 | |
| 1123 | final int currentHour24 = getCurrentHour(); |
| 1124 | if (mIs24HourMode) { |
| 1125 | initialStep = currentHour24; |
| 1126 | minValue = 0; |
| 1127 | maxValue = 23; |
| 1128 | } else { |
| 1129 | initialStep = hour24To12(currentHour24); |
| 1130 | minValue = 1; |
| 1131 | maxValue = 12; |
| 1132 | } |
| 1133 | } else { |
| 1134 | stepSize = 5; |
| 1135 | initialStep = getCurrentMinute() / stepSize; |
| 1136 | minValue = 0; |
| 1137 | maxValue = 55; |
| 1138 | } |
| 1139 | |
| 1140 | final int nextValue = (initialStep + step) * stepSize; |
| 1141 | final int clampedValue = MathUtils.constrain(nextValue, minValue, maxValue); |
| 1142 | if (mShowHours) { |
| 1143 | setCurrentHour(clampedValue); |
| 1144 | } else { |
| 1145 | setCurrentMinute(clampedValue); |
| 1146 | } |
| 1147 | } |
| 1148 | |
| 1149 | @Override |
| 1150 | protected int getVirtualViewAt(float x, float y) { |
| 1151 | final int id; |
| 1152 | final int degrees = getDegreesFromXY(x, y, true); |
| 1153 | if (degrees != -1) { |
| 1154 | final int snapDegrees = snapOnly30s(degrees, 0) % 360; |
| 1155 | if (mShowHours) { |
| 1156 | final boolean isOnInnerCircle = getInnerCircleFromXY(x, y); |
| 1157 | final int hour24 = getHourForDegrees(snapDegrees, isOnInnerCircle); |
| 1158 | final int hour = mIs24HourMode ? hour24 : hour24To12(hour24); |
| 1159 | id = makeId(TYPE_HOUR, hour); |
| 1160 | } else { |
| 1161 | final int current = getCurrentMinute(); |
| 1162 | final int touched = getMinuteForDegrees(degrees); |
| 1163 | final int snapped = getMinuteForDegrees(snapDegrees); |
| 1164 | |
| 1165 | // If the touched minute is closer to the current minute |
| 1166 | // than it is to the snapped minute, return current. |
| 1167 | final int currentOffset = getCircularDiff(current, touched, MINUTES_IN_CIRCLE); |
| 1168 | final int snappedOffset = getCircularDiff(snapped, touched, MINUTES_IN_CIRCLE); |
| 1169 | final int minute; |
| 1170 | if (currentOffset < snappedOffset) { |
| 1171 | minute = current; |
| 1172 | } else { |
| 1173 | minute = snapped; |
| 1174 | } |
| 1175 | id = makeId(TYPE_MINUTE, minute); |
| 1176 | } |
| 1177 | } else { |
| 1178 | id = INVALID_ID; |
| 1179 | } |
| 1180 | |
| 1181 | return id; |
| 1182 | } |
| 1183 | |
| 1184 | /** |
| 1185 | * Returns the difference in degrees between two values along a circle. |
| 1186 | * |
| 1187 | * @param first value in the range [0,max] |
| 1188 | * @param second value in the range [0,max] |
| 1189 | * @param max the maximum value along the circle |
| 1190 | * @return the difference in between the two values |
| 1191 | */ |
| 1192 | private int getCircularDiff(int first, int second, int max) { |
| 1193 | final int diff = Math.abs(first - second); |
| 1194 | final int midpoint = max / 2; |
| 1195 | return (diff > midpoint) ? (max - diff) : diff; |
| 1196 | } |
| 1197 | |
| 1198 | @Override |
| 1199 | protected void getVisibleVirtualViews(IntArray virtualViewIds) { |
| 1200 | if (mShowHours) { |
| 1201 | final int min = mIs24HourMode ? 0 : 1; |
| 1202 | final int max = mIs24HourMode ? 23 : 12; |
| 1203 | for (int i = min; i <= max ; i++) { |
| 1204 | virtualViewIds.add(makeId(TYPE_HOUR, i)); |
| 1205 | } |
| 1206 | } else { |
| 1207 | final int current = getCurrentMinute(); |
| 1208 | for (int i = 0; i < MINUTES_IN_CIRCLE; i += MINUTE_INCREMENT) { |
| 1209 | virtualViewIds.add(makeId(TYPE_MINUTE, i)); |
| 1210 | |
| 1211 | // If the current minute falls between two increments, |
| 1212 | // insert an extra node for it. |
| 1213 | if (current > i && current < i + MINUTE_INCREMENT) { |
| 1214 | virtualViewIds.add(makeId(TYPE_MINUTE, current)); |
| 1215 | } |
| 1216 | } |
| 1217 | } |
| 1218 | } |
| 1219 | |
| 1220 | @Override |
| 1221 | protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) { |
| 1222 | event.setClassName(getClass().getName()); |
| 1223 | |
| 1224 | final int type = getTypeFromId(virtualViewId); |
| 1225 | final int value = getValueFromId(virtualViewId); |
| 1226 | final CharSequence description = getVirtualViewDescription(type, value); |
| 1227 | event.setContentDescription(description); |
| 1228 | } |
| 1229 | |
| 1230 | @Override |
| 1231 | protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) { |
| 1232 | node.setClassName(getClass().getName()); |
| 1233 | node.addAction(AccessibilityAction.ACTION_CLICK); |
| 1234 | |
| 1235 | final int type = getTypeFromId(virtualViewId); |
| 1236 | final int value = getValueFromId(virtualViewId); |
| 1237 | final CharSequence description = getVirtualViewDescription(type, value); |
| 1238 | node.setContentDescription(description); |
| 1239 | |
| 1240 | getBoundsForVirtualView(virtualViewId, mTempRect); |
| 1241 | node.setBoundsInParent(mTempRect); |
| 1242 | |
| 1243 | final boolean selected = isVirtualViewSelected(type, value); |
| 1244 | node.setSelected(selected); |
| 1245 | |
| 1246 | final int nextId = getVirtualViewIdAfter(type, value); |
| 1247 | if (nextId != INVALID_ID) { |
| 1248 | node.setTraversalBefore(RadialTimePickerView.this, nextId); |
| 1249 | } |
| 1250 | } |
| 1251 | |
| 1252 | private int getVirtualViewIdAfter(int type, int value) { |
| 1253 | if (type == TYPE_HOUR) { |
| 1254 | final int nextValue = value + 1; |
| 1255 | final int max = mIs24HourMode ? 23 : 12; |
| 1256 | if (nextValue <= max) { |
| 1257 | return makeId(type, nextValue); |
| 1258 | } |
| 1259 | } else if (type == TYPE_MINUTE) { |
| 1260 | final int current = getCurrentMinute(); |
| 1261 | final int snapValue = value - (value % MINUTE_INCREMENT); |
| 1262 | final int nextValue = snapValue + MINUTE_INCREMENT; |
| 1263 | if (value < current && nextValue > current) { |
| 1264 | // The current value is between two snap values. |
| 1265 | return makeId(type, current); |
| 1266 | } else if (nextValue < MINUTES_IN_CIRCLE) { |
| 1267 | return makeId(type, nextValue); |
| 1268 | } |
| 1269 | } |
| 1270 | return INVALID_ID; |
| 1271 | } |
| 1272 | |
| 1273 | @Override |
| 1274 | protected boolean onPerformActionForVirtualView(int virtualViewId, int action, |
| 1275 | Bundle arguments) { |
| 1276 | if (action == AccessibilityNodeInfo.ACTION_CLICK) { |
| 1277 | final int type = getTypeFromId(virtualViewId); |
| 1278 | final int value = getValueFromId(virtualViewId); |
| 1279 | if (type == TYPE_HOUR) { |
| 1280 | final int hour = mIs24HourMode ? value : hour12To24(value, mAmOrPm); |
| 1281 | setCurrentHour(hour); |
| 1282 | return true; |
| 1283 | } else if (type == TYPE_MINUTE) { |
| 1284 | setCurrentMinute(value); |
| 1285 | return true; |
| 1286 | } |
| 1287 | } |
| 1288 | return false; |
| 1289 | } |
| 1290 | |
| 1291 | private int hour12To24(int hour12, int amOrPm) { |
| 1292 | int hour24 = hour12; |
| 1293 | if (hour12 == 12) { |
| 1294 | if (amOrPm == AM) { |
| 1295 | hour24 = 0; |
| 1296 | } |
| 1297 | } else if (amOrPm == PM) { |
| 1298 | hour24 += 12; |
| 1299 | } |
| 1300 | return hour24; |
| 1301 | } |
| 1302 | |
| 1303 | private int hour24To12(int hour24) { |
| 1304 | if (hour24 == 0) { |
| 1305 | return 12; |
| 1306 | } else if (hour24 > 12) { |
| 1307 | return hour24 - 12; |
| 1308 | } else { |
| 1309 | return hour24; |
| 1310 | } |
| 1311 | } |
| 1312 | |
| 1313 | private void getBoundsForVirtualView(int virtualViewId, Rect bounds) { |
| 1314 | final float radius; |
| 1315 | final int type = getTypeFromId(virtualViewId); |
| 1316 | final int value = getValueFromId(virtualViewId); |
| 1317 | final float centerRadius; |
| 1318 | final float degrees; |
| 1319 | if (type == TYPE_HOUR) { |
| 1320 | final boolean innerCircle = getInnerCircleForHour(value); |
| 1321 | if (innerCircle) { |
| 1322 | centerRadius = mCircleRadius - mTextInset[HOURS_INNER]; |
| 1323 | radius = mSelectorRadius; |
| 1324 | } else { |
| 1325 | centerRadius = mCircleRadius - mTextInset[HOURS]; |
| 1326 | radius = mSelectorRadius; |
| 1327 | } |
| 1328 | |
| 1329 | degrees = getDegreesForHour(value); |
| 1330 | } else if (type == TYPE_MINUTE) { |
| 1331 | centerRadius = mCircleRadius - mTextInset[MINUTES]; |
| 1332 | degrees = getDegreesForMinute(value); |
| 1333 | radius = mSelectorRadius; |
| 1334 | } else { |
| 1335 | // This should never happen. |
| 1336 | centerRadius = 0; |
| 1337 | degrees = 0; |
| 1338 | radius = 0; |
| 1339 | } |
| 1340 | |
| 1341 | final double radians = Math.toRadians(degrees); |
| 1342 | final float xCenter = mXCenter + centerRadius * (float) Math.sin(radians); |
| 1343 | final float yCenter = mYCenter - centerRadius * (float) Math.cos(radians); |
| 1344 | |
| 1345 | bounds.set((int) (xCenter - radius), (int) (yCenter - radius), |
| 1346 | (int) (xCenter + radius), (int) (yCenter + radius)); |
| 1347 | } |
| 1348 | |
| 1349 | private CharSequence getVirtualViewDescription(int type, int value) { |
| 1350 | final CharSequence description; |
| 1351 | if (type == TYPE_HOUR || type == TYPE_MINUTE) { |
| 1352 | description = Integer.toString(value); |
| 1353 | } else { |
| 1354 | description = null; |
| 1355 | } |
| 1356 | return description; |
| 1357 | } |
| 1358 | |
| 1359 | private boolean isVirtualViewSelected(int type, int value) { |
| 1360 | final boolean selected; |
| 1361 | if (type == TYPE_HOUR) { |
| 1362 | selected = getCurrentHour() == value; |
| 1363 | } else if (type == TYPE_MINUTE) { |
| 1364 | selected = getCurrentMinute() == value; |
| 1365 | } else { |
| 1366 | selected = false; |
| 1367 | } |
| 1368 | return selected; |
| 1369 | } |
| 1370 | |
| 1371 | private int makeId(int type, int value) { |
| 1372 | return type << SHIFT_TYPE | value << SHIFT_VALUE; |
| 1373 | } |
| 1374 | |
| 1375 | private int getTypeFromId(int id) { |
| 1376 | return id >>> SHIFT_TYPE & MASK_TYPE; |
| 1377 | } |
| 1378 | |
| 1379 | private int getValueFromId(int id) { |
| 1380 | return id >>> SHIFT_VALUE & MASK_VALUE; |
| 1381 | } |
| 1382 | } |
| 1383 | } |