diff options
author | Sam Blitzstein <sblitz@google.com> | 2013-02-15 16:46:06 -0800 |
---|---|---|
committer | Sam Blitzstein <sblitz@google.com> | 2013-03-20 10:21:01 -0700 |
commit | 6e896f805cac499b777c98755149f07ccd7ba5c3 (patch) | |
tree | 1a5467332ae3039da901983fdf46725211417229 /src/com | |
parent | ebd2a4069dda00781262a1cbfd4a9d22fce15ed7 (diff) | |
download | datetimepicker-6e896f805cac499b777c98755149f07ccd7ba5c3.tar.gz |
Adding new timepicker library.
Timepicker is a radial, animated selector.
Change-Id: Ib6a6deebf7673dcb14561261314a0e082d4a3ffc
Diffstat (limited to 'src/com')
-rw-r--r-- | src/com/android/datetimepicker/AmPmCirclesView.java | 178 | ||||
-rw-r--r-- | src/com/android/datetimepicker/CircleView.java | 111 | ||||
-rw-r--r-- | src/com/android/datetimepicker/RadialSelectorView.java | 345 | ||||
-rw-r--r-- | src/com/android/datetimepicker/RadialTextsView.java | 315 | ||||
-rw-r--r-- | src/com/android/datetimepicker/TimePicker.java | 527 | ||||
-rw-r--r-- | src/com/android/datetimepicker/TimePickerDialog.java | 301 |
6 files changed, 1777 insertions, 0 deletions
diff --git a/src/com/android/datetimepicker/AmPmCirclesView.java b/src/com/android/datetimepicker/AmPmCirclesView.java new file mode 100644 index 0000000..cb04cad --- /dev/null +++ b/src/com/android/datetimepicker/AmPmCirclesView.java @@ -0,0 +1,178 @@ +/* + * 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 com.android.datetimepicker; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.Paint.Align; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; + +import com.android.datetimepicker.R; + +public class AmPmCirclesView extends View { + private static final String TAG = "AmPmCirclesView"; + + private final Paint mPaint = new Paint(); + private int mWhite; + private int mDarkGray; + private int mBlue; + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private String mAmText; + private String mPmText; + private boolean mIsInitialized; + + private static final int AM = TimePickerDialog.AM; + private static final int PM = TimePickerDialog.PM; + + private boolean mDrawValuesReady; + private int mAmPmCircleRadius; + private int mAmXCenter; + private int mPmXCenter; + private int mAmPmYCenter; + private int mAmOrPm; + private int mAmOrPmPressed; + + public AmPmCirclesView(Context context) { + super(context); + mIsInitialized = false; + } + + public void initialize(Context context, int amOrPm) { + if (mIsInitialized) { + Log.e(TAG, "AmPmCirclesView may only be initialized once."); + return; + } + + Resources res = context.getResources(); + mWhite = res.getColor(R.color.white); + mDarkGray = res.getColor(R.color.dark_gray); + mBlue = res.getColor(R.color.blue); + Typeface tf = Typeface.create("sans-serif-thin", Typeface.NORMAL); + mPaint.setTypeface(tf); + mPaint.setAntiAlias(true); + mPaint.setTextAlign(Align.CENTER); + + mCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier)); + mAmText = res.getString(R.string.am_label); + mPmText = res.getString(R.string.pm_label); + + setAmOrPm(amOrPm); + mAmOrPmPressed = -1; + + mIsInitialized = true; + } + + public void setAmOrPm(int amOrPm) { + mAmOrPm = amOrPm; + } + + public void setAmOrPmPressed(int amOrPmPressed) { + mAmOrPmPressed = amOrPmPressed; + } + + public int getIsTouchingAmOrPm(float xCoord, float yCoord) { + if (!mDrawValuesReady) { + return -1; + } + + int squaredYDistance = (int) ((yCoord - mAmPmYCenter)*(yCoord - mAmPmYCenter)); + + int distanceToAmCenter = + (int) Math.sqrt((xCoord - mAmXCenter)*(xCoord - mAmXCenter) + squaredYDistance); + if (distanceToAmCenter <= mAmPmCircleRadius) { + return AM; + } + + int distanceToPmCenter = + (int) Math.sqrt((xCoord - mPmXCenter)*(xCoord - mPmXCenter) + squaredYDistance); + if (distanceToPmCenter <= mAmPmCircleRadius) { + return PM; + } + + // Neither was close enough. + return -1; + } + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + int layoutXCenter = getWidth() / 2; + int layoutYCenter = getHeight() / 2; + int circleRadius = + (int) (Math.min(layoutXCenter, layoutYCenter) * mCircleRadiusMultiplier); + mAmPmCircleRadius = (int) (circleRadius * mAmPmCircleRadiusMultiplier); + int textSize = mAmPmCircleRadius * 2 / 3; + mPaint.setTextSize(textSize); + + // Line up the vertical center of the AM/PM circles with the bottom of the main circle. + mAmPmYCenter = layoutYCenter - mAmPmCircleRadius / 2 + circleRadius; + // Line up the horizontal edges of the AM/PM circles with the horizontal edges + // of the main circle. + mAmXCenter = layoutXCenter - circleRadius + mAmPmCircleRadius; + mPmXCenter = layoutXCenter + circleRadius - mAmPmCircleRadius; + + mDrawValuesReady = true; + } + + int amColor = mWhite; + int amAlpha = 255; + int pmColor = mWhite; + int pmAlpha = 255; + if (mAmOrPm == AM) { + amColor = mBlue; + amAlpha = 45; + } else if (mAmOrPm == PM) { + pmColor = mBlue; + pmAlpha = 45; + } + if (mAmOrPmPressed == AM) { + amColor = mBlue; + amAlpha = 175; + } else if (mAmOrPmPressed == PM) { + pmColor = mBlue; + pmAlpha = 175; + } + + mPaint.setColor(amColor); + mPaint.setAlpha(amAlpha); + canvas.drawCircle(mAmXCenter, mAmPmYCenter, mAmPmCircleRadius, mPaint); + mPaint.setColor(pmColor); + mPaint.setAlpha(pmAlpha); + canvas.drawCircle(mPmXCenter, mAmPmYCenter, mAmPmCircleRadius, mPaint); + + mPaint.setColor(mDarkGray); + int textYCenter = mAmPmYCenter - (int) (mPaint.descent() + mPaint.ascent()) / 2; + canvas.drawText(mAmText, mAmXCenter, textYCenter, mPaint); + canvas.drawText(mPmText, mPmXCenter, textYCenter, mPaint); + } +} diff --git a/src/com/android/datetimepicker/CircleView.java b/src/com/android/datetimepicker/CircleView.java new file mode 100644 index 0000000..cd89410 --- /dev/null +++ b/src/com/android/datetimepicker/CircleView.java @@ -0,0 +1,111 @@ +/* + * 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 com.android.datetimepicker; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; + +import com.android.datetimepicker.R; + +public class CircleView extends View { + private static final String TAG = "CircleView"; + + private final Paint mPaint = new Paint(); + private boolean mIs24HourMode; + private int mWhite; + private int mBlack; + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private boolean mIsInitialized; + + private boolean mDrawValuesReady; + private int mXCenter; + private int mYCenter; + private int mCircleRadius; + + public CircleView(Context context) { + super(context); + + Resources res = context.getResources(); + mWhite = res.getColor(R.color.white); + mBlack = res.getColor(R.color.black); + mPaint.setAntiAlias(true); + + mIsInitialized = false; + } + + public void initialize(Context context, boolean is24HourMode) { + if (mIsInitialized) { + Log.e(TAG, "CircleView may only be initialized once."); + return; + } + + Resources res = context.getResources(); + mIs24HourMode = is24HourMode; + if (is24HourMode) { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.circle_radius_multiplier_24HourMode)); + } else { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier)); + } + + mIsInitialized = true; + } + + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + mXCenter = getWidth() / 2; + mYCenter = getHeight() / 2; + mCircleRadius = (int) (Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier); + + 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 * mAmPmCircleRadiusMultiplier); + mYCenter -= amPmCircleRadius / 2; + } + + mDrawValuesReady = true; + } + + mPaint.setColor(mWhite); + canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaint); + + mPaint.setColor(mBlack); + canvas.drawCircle(mXCenter, mYCenter, 2, mPaint); + } +} diff --git a/src/com/android/datetimepicker/RadialSelectorView.java b/src/com/android/datetimepicker/RadialSelectorView.java new file mode 100644 index 0000000..5f56b56 --- /dev/null +++ b/src/com/android/datetimepicker/RadialSelectorView.java @@ -0,0 +1,345 @@ +/* + * 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 com.android.datetimepicker; + +import android.animation.Keyframe; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.app.Service; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.os.SystemClock; +import android.os.Vibrator; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; + +import com.android.datetimepicker.R; + +public class RadialSelectorView extends View { + private static final String TAG = "RadialSelectorView"; + + private final Paint mPaint = new Paint(); + + private boolean mIsInitialized; + private boolean mDrawValuesReady; + + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private float mInnerNumbersRadiusMultiplier; + private float mOuterNumbersRadiusMultiplier; + private float mNumbersRadiusMultiplier; + private float mSelectionRadiusMultiplier; + private float mAnimationRadiusMultiplier; + private boolean mIs24HourMode; + private boolean mHasInnerCircle; + + private int mXCenter; + private int mYCenter; + private int mCircleRadius; + private float mTransitionMidRadiusMultiplier; + private float mTransitionEndRadiusMultiplier; + private int mLineLength; + private int mSelectionRadius; + private InvalidateUpdateListener mInvalidateUpdateListener; + + private int mSelectionDegrees; + private double mSelectionRadians; + private boolean mDrawLine; + private boolean mForceDrawDot; + + public RadialSelectorView(Context context) { + super(context); + mIsInitialized = false; + } + + public void initialize(Context context, int selectionDegrees, boolean is24HourMode, + boolean hasInnerCircle, boolean isInnerCircle, boolean disappearsOut) { + if (mIsInitialized) { + Log.e(TAG, "This RadialSelectorView may only be initialized once."); + return; + } + + Resources res = context.getResources(); + + int blue = res.getColor(R.color.blue); + mPaint.setColor(blue); + mPaint.setAntiAlias(true); + + mIs24HourMode = is24HourMode; + if (is24HourMode) { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.circle_radius_multiplier_24HourMode)); + } else { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier)); + } + + mHasInnerCircle = hasInnerCircle; + if (hasInnerCircle) { + mInnerNumbersRadiusMultiplier = + Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_inner)); + mOuterNumbersRadiusMultiplier = + Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_outer)); + } else { + mNumbersRadiusMultiplier = + Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_normal)); + } + mSelectionRadiusMultiplier = + Float.parseFloat(res.getString(R.string.selection_radius_multiplier)); + + setSelection(selectionDegrees, isInnerCircle, false, false); + + mAnimationRadiusMultiplier = 1; + mTransitionMidRadiusMultiplier = 1f + (0.05f * (disappearsOut? -1 : 1)); + mTransitionEndRadiusMultiplier = 1f + (0.3f * (disappearsOut? 1 : -1)); + mInvalidateUpdateListener = new InvalidateUpdateListener(); + + mIsInitialized = true; + } + + public void setSelection(int selectionDegrees, boolean isInnerCircle, + boolean drawLine, boolean forceDrawDot) { + mSelectionDegrees = selectionDegrees; + mSelectionRadians = selectionDegrees * Math.PI / 180; + mDrawLine = drawLine; + mForceDrawDot = forceDrawDot; + + if (mHasInnerCircle) { + if (isInnerCircle) { + mNumbersRadiusMultiplier = mInnerNumbersRadiusMultiplier; + } else { + mNumbersRadiusMultiplier = mOuterNumbersRadiusMultiplier; + } + } + } + + public void setDrawLine(boolean drawLine) { + mDrawLine = drawLine; + } + + public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) { + mAnimationRadiusMultiplier = animationRadiusMultiplier; + } + + public int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal, + final Boolean[] isInnerCircle) { + if (!mDrawValuesReady) { + return -1; + } + + double hypotenuse = Math.sqrt( + (pointY - mYCenter)*(pointY - mYCenter) + + (pointX - mXCenter)*(pointX - mXCenter)); + // Check if we're outside the range + if (mHasInnerCircle) { + if (forceLegal) { + // If we're told to force the coordinates to be legal, we'll set the isInnerCircle + // boolean based based off whichever number the coordinates are closer to. + int innerNumberRadius = (int) (mCircleRadius * mInnerNumbersRadiusMultiplier); + int distanceToInnerNumber = (int) Math.abs(hypotenuse - innerNumberRadius); + int outerNumberRadius = (int) (mCircleRadius * mOuterNumbersRadiusMultiplier); + int distanceToOuterNumber = (int) Math.abs(hypotenuse - outerNumberRadius); + + isInnerCircle[0] = (distanceToInnerNumber <= distanceToOuterNumber); + } else { + // Otherwise, if we're close enough to either number (with the space between the + // two allotted equally), set the isInnerCircle boolean as the closer one. + // appropriately, but otherwise return -1. + int minAllowedHypotenuseForInnerNumber = + (int) (mCircleRadius * mInnerNumbersRadiusMultiplier) - mSelectionRadius; + int maxAllowedHypotenuseForOuterNumber = + (int) (mCircleRadius * mOuterNumbersRadiusMultiplier) + mSelectionRadius; + int halfwayHypotenusePoint = (int) (mCircleRadius * + ((mOuterNumbersRadiusMultiplier + mInnerNumbersRadiusMultiplier) / 2)); + + if (hypotenuse >= minAllowedHypotenuseForInnerNumber && + hypotenuse <= halfwayHypotenusePoint) { + isInnerCircle[0] = true; + } else if (hypotenuse <= maxAllowedHypotenuseForOuterNumber && + hypotenuse >= halfwayHypotenusePoint) { + isInnerCircle[0] = false; + } else { + return -1; + } + } + } else { + // If there's just one circle, we'll need to return -1 if: + // we're not told to force the coordinates to be legal, and + // the coordinates' distance to the number is within the allowed distance. + if (!forceLegal) { + int distanceToNumber = (int) Math.abs(hypotenuse - mLineLength); + // The max allowed distance will be defined as the distance from the center of the + // number to the edge of the circle. + int maxAllowedDistance = (int) (mCircleRadius * (1 - mNumbersRadiusMultiplier)); + if (distanceToNumber > maxAllowedDistance) { + return -1; + } + } + } + + + float opposite = Math.abs(pointY - mYCenter); + double radians = Math.asin(opposite / hypotenuse); + int degrees = (int) (radians * 180 / Math.PI); + + // Now we have to translate to the correct quadrant. + boolean rightSide = (pointX > mXCenter); + boolean topSide = (pointY < 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 degrees; + } + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + mXCenter = getWidth() / 2; + mYCenter = getHeight() / 2; + mCircleRadius = (int) (Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier); + + 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 * mAmPmCircleRadiusMultiplier); + mYCenter -= amPmCircleRadius / 2; + } + + mSelectionRadius = (int) (mCircleRadius * mSelectionRadiusMultiplier); + + mDrawValuesReady = true; + } + + mLineLength = (int) (mCircleRadius * mNumbersRadiusMultiplier * mAnimationRadiusMultiplier); + int pointX = mXCenter + (int) (mLineLength * Math.sin(mSelectionRadians)); + int pointY = mYCenter - (int) (mLineLength * Math.cos(mSelectionRadians)); + + mPaint.setAlpha(75); + canvas.drawCircle(pointX, pointY, mSelectionRadius, mPaint); + + if (mForceDrawDot | mSelectionDegrees % 30 != 0) { + // We're not on a direct tick. + mPaint.setAlpha(255); + canvas.drawCircle(pointX, pointY, mSelectionRadius / 4, mPaint); + } else { + int lineLength = mLineLength; + lineLength -= mSelectionRadius; + pointX = mXCenter + (int) (lineLength * Math.sin(mSelectionRadians)); + pointY = mYCenter - (int) (lineLength * Math.cos(mSelectionRadians)); + } + + if (mDrawLine || true) { + mPaint.setAlpha(255); + mPaint.setStrokeWidth(1); + canvas.drawLine(mXCenter, mYCenter, pointX, pointY, mPaint); + } + } + + public ObjectAnimator getDisappearAnimator() { + if (!mIsInitialized || !mDrawValuesReady) { + Log.e(TAG, "RadialSelectorView was not ready for animation."); + return null; + } + + Keyframe kf0, kf1, kf2; + float midwayPoint = 0.2f; + int duration = 500; + + kf0 = Keyframe.ofFloat(0f, 1); + kf1 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier); + kf2 = Keyframe.ofFloat(1f, mTransitionEndRadiusMultiplier); + PropertyValuesHolder radiusDisappear = PropertyValuesHolder.ofKeyframe( + "animationRadiusMultiplier", kf0, kf1, kf2); + + kf0 = Keyframe.ofFloat(0f, 1f); + kf1 = Keyframe.ofFloat(1f, 0f); + PropertyValuesHolder fadeOut = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1); + + ObjectAnimator disappearAnimator = ObjectAnimator.ofPropertyValuesHolder( + this, radiusDisappear, fadeOut).setDuration(duration); + disappearAnimator.addUpdateListener(mInvalidateUpdateListener); + + return disappearAnimator; + } + + public ObjectAnimator getReappearAnimator() { + if (!mIsInitialized || !mDrawValuesReady) { + Log.e(TAG, "RadialSelectorView was not ready for animation."); + return null; + } + + Keyframe kf0, kf1, kf2, kf3; + float midwayPoint = 0.2f; + int duration = 500; + + // The time points are half of what they would normally be, because this animation is + // staggered against the disappear so they happen seamlessly. The reappear starts + // halfway into the disappear. + float delayMultiplier = 0.5f; + float transitionDurationMultiplier = 0.75f; + float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier; + int totalDuration = (int) (duration * totalDurationMultiplier); + float delayPoint = (delayMultiplier * duration) / totalDuration; + midwayPoint = 1 - (midwayPoint * (1 - delayPoint)); + + kf0 = Keyframe.ofFloat(0f, mTransitionEndRadiusMultiplier); + kf1 = Keyframe.ofFloat(delayPoint, mTransitionEndRadiusMultiplier); + kf2 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier); + kf3 = Keyframe.ofFloat(1f, 1); + PropertyValuesHolder radiusReappear = PropertyValuesHolder.ofKeyframe( + "animationRadiusMultiplier", kf0, kf1, kf2, kf3); + + kf0 = Keyframe.ofFloat(0f, 0f); + kf1 = Keyframe.ofFloat(delayPoint, 0f); + kf2 = Keyframe.ofFloat(1f, 1f); + PropertyValuesHolder fadeIn = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1, kf2); + + ObjectAnimator reappearAnimator = ObjectAnimator.ofPropertyValuesHolder( + this, radiusReappear, fadeIn).setDuration(totalDuration); + reappearAnimator.addUpdateListener(mInvalidateUpdateListener); + return reappearAnimator; + } + + private class InvalidateUpdateListener implements AnimatorUpdateListener { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + RadialSelectorView.this.invalidate(); + } + } +} diff --git a/src/com/android/datetimepicker/RadialTextsView.java b/src/com/android/datetimepicker/RadialTextsView.java new file mode 100644 index 0000000..6909d5c --- /dev/null +++ b/src/com/android/datetimepicker/RadialTextsView.java @@ -0,0 +1,315 @@ +/* + * 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 com.android.datetimepicker; + +import android.animation.Keyframe; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.Paint.Align; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; + +import com.android.datetimepicker.R; + +public class RadialTextsView extends View { + private final static String TAG = "RadialTextsView"; + + private final Paint mPaint = new Paint(); + + private boolean mDrawValuesReady; + private boolean mIsInitialized; + + private String[] mTexts; + private String[] mInnerTexts; + private boolean mIs24HourMode; + private boolean mHasInnerCircle; + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private float mNumbersRadiusMultiplier; + private float mInnerNumbersRadiusMultiplier; + private float mTextSizeMultiplier; + private float mInnerTextSizeMultiplier; + + private int mXCenter; + private int mYCenter; + private float mCircleRadius; + private boolean mTextGridValuesDirty; + private float mTextSize; + private float mInnerTextSize; + private float[] mTextGridHeights; + private float[] mTextGridWidths; + private float[] mInnerTextGridHeights; + private float[] mInnerTextGridWidths; + + private float mAnimationRadiusMultiplier; + private float mTransitionMidRadiusMultiplier; + private float mTransitionEndRadiusMultiplier; + ObjectAnimator mDisappearAnimator; + ObjectAnimator mReappearAnimator; + private InvalidateUpdateListener mInvalidateUpdateListener; + + public RadialTextsView(Context context) { + super(context); + mIsInitialized = false; + } + + public void initialize(Resources res, String[] texts, String[] innerTexts, + boolean is24HourMode, boolean disappearsOut) { + if (mIsInitialized) { + Log.e(TAG, "This RadialTextsView may only be initialized once."); + return; + } + + int black = res.getColor(R.color.black); + mPaint.setColor(black); + Typeface tf = Typeface.create("sans-serif-thin", Typeface.NORMAL); + mPaint.setTypeface(tf); + mPaint.setAntiAlias(true); + mPaint.setTextAlign(Align.CENTER); + + mTexts = texts; + mInnerTexts = innerTexts; + mIs24HourMode = is24HourMode; + mHasInnerCircle = (innerTexts != null); + + if (is24HourMode) { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.circle_radius_multiplier_24HourMode)); + } else { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier)); + } + + mTextGridHeights = new float[7]; + mTextGridWidths = new float[7]; + if (mHasInnerCircle) { + mNumbersRadiusMultiplier = Float.parseFloat( + res.getString(R.string.numbers_radius_multiplier_outer)); + mTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.text_size_multiplier_outer)); + mInnerNumbersRadiusMultiplier = Float.parseFloat( + res.getString(R.string.numbers_radius_multiplier_inner)); + mInnerTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.text_size_multiplier_inner)); + + mInnerTextGridHeights = new float[7]; + mInnerTextGridWidths = new float[7]; + } else { + mNumbersRadiusMultiplier = Float.parseFloat( + res.getString(R.string.numbers_radius_multiplier_normal)); + mTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.text_size_multiplier_normal)); + } + + mAnimationRadiusMultiplier = 1; + mTransitionMidRadiusMultiplier = 1f + (0.05f * (disappearsOut? -1 : 1)); + mTransitionEndRadiusMultiplier = 1f + (0.3f * (disappearsOut? 1 : -1)); + mInvalidateUpdateListener = new InvalidateUpdateListener(); + + mTextGridValuesDirty = true; + mIsInitialized = true; + } + + public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) { + mAnimationRadiusMultiplier = animationRadiusMultiplier; + mTextGridValuesDirty = true; + } + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + mXCenter = getWidth() / 2; + mYCenter = getHeight() / 2; + mCircleRadius = Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier; + 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. + float amPmCircleRadius = mCircleRadius * mAmPmCircleRadiusMultiplier; + mYCenter -= amPmCircleRadius / 2; + } + + mTextSize = mCircleRadius * mTextSizeMultiplier; + if (mHasInnerCircle) { + mInnerTextSize = mCircleRadius * mInnerTextSizeMultiplier; + } + + // Set up the spots for the animation. + renderAnimations(); + + mTextGridValuesDirty = true; + mDrawValuesReady = true; + } + + if (mTextGridValuesDirty) { + float numbersRadius = + mCircleRadius * mNumbersRadiusMultiplier * mAnimationRadiusMultiplier; + + calculateGridSizes(numbersRadius, mXCenter, mYCenter, + mTextSize, mTextGridHeights, mTextGridWidths); + if (mHasInnerCircle) { + float innerNumbersRadius = + mCircleRadius * mInnerNumbersRadiusMultiplier * mAnimationRadiusMultiplier; + calculateGridSizes(innerNumbersRadius, mXCenter, mYCenter, + mInnerTextSize, mInnerTextGridHeights, mInnerTextGridWidths); + } + mTextGridValuesDirty = false; + } + + drawTexts(canvas, mTextSize, mTexts, mTextGridWidths, mTextGridHeights); + if (mHasInnerCircle) { + drawTexts(canvas, mInnerTextSize, mInnerTexts, + mInnerTextGridWidths, mInnerTextGridHeights); + } + } + + private void calculateGridSizes(float numbersRadius, float xCenter, float yCenter, + float textSize, float[] textGridHeights, float[] textGridWidths) { + /* + * In the interest of efficient drawing, the following formulas have been simplified + * as much as possible. + * The numbers need to be drawn in a 7x7 grid representing the points on the Unit Circle. + */ + float offset1 = numbersRadius; + // cos(30) = a / r => r * cos(30) = a => r * √3/2 = a + float offset2 = numbersRadius * ((float) Math.sqrt(3)) / 2f; + // sin(30) = o / r => r * sin(30) = o => r / 2 = a + float offset3 = numbersRadius / 2f; + // We'll need yTextBase to be slightly lower to account for the text's baseline. + mPaint.setTextSize(textSize); + yCenter -= (mPaint.descent() + mPaint.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; + } + + private void drawTexts(Canvas canvas, float textSize, String[] texts, + float[] textGridWidths, float[] textGridHeights) { + mPaint.setTextSize(textSize); + canvas.drawText(texts[0], textGridWidths[3], textGridHeights[0], mPaint); + canvas.drawText(texts[1], textGridWidths[4], textGridHeights[1], mPaint); + canvas.drawText(texts[2], textGridWidths[5], textGridHeights[2], mPaint); + canvas.drawText(texts[3], textGridWidths[6], textGridHeights[3], mPaint); + canvas.drawText(texts[4], textGridWidths[5], textGridHeights[4], mPaint); + canvas.drawText(texts[5], textGridWidths[4], textGridHeights[5], mPaint); + canvas.drawText(texts[6], textGridWidths[3], textGridHeights[6], mPaint); + canvas.drawText(texts[7], textGridWidths[2], textGridHeights[5], mPaint); + canvas.drawText(texts[8], textGridWidths[1], textGridHeights[4], mPaint); + canvas.drawText(texts[9], textGridWidths[0], textGridHeights[3], mPaint); + canvas.drawText(texts[10], textGridWidths[1], textGridHeights[2], mPaint); + canvas.drawText(texts[11], textGridWidths[2], textGridHeights[1], mPaint); + } + + private void renderAnimations() { + Keyframe kf0, kf1, kf2, kf3; + float midwayPoint = 0.2f; + int duration = 500; + + // Set up animator for disappearing. + kf0 = Keyframe.ofFloat(0f, 1); + kf1 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier); + kf2 = Keyframe.ofFloat(1f, mTransitionEndRadiusMultiplier); + PropertyValuesHolder radiusDisappear = PropertyValuesHolder.ofKeyframe( + "animationRadiusMultiplier", kf0, kf1, kf2); + + kf0 = Keyframe.ofFloat(0f, 1f); + kf1 = Keyframe.ofFloat(1f, 0f); + PropertyValuesHolder fadeOut = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1); + + mDisappearAnimator = ObjectAnimator.ofPropertyValuesHolder( + this, radiusDisappear, fadeOut).setDuration(duration); + mDisappearAnimator.addUpdateListener(mInvalidateUpdateListener); + + + // Set up animator for reappearing. + float delayMultiplier = 0.5f; + float transitionDurationMultiplier = 0.75f; + float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier; + int totalDuration = (int) (duration * totalDurationMultiplier); + float delayPoint = (delayMultiplier * duration) / totalDuration; + midwayPoint = 1 - (midwayPoint * (1 - delayPoint)); + + kf0 = Keyframe.ofFloat(0f, mTransitionEndRadiusMultiplier); + kf1 = Keyframe.ofFloat(delayPoint, mTransitionEndRadiusMultiplier); + kf2 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier); + kf3 = Keyframe.ofFloat(1f, 1); + PropertyValuesHolder radiusReappear = PropertyValuesHolder.ofKeyframe( + "animationRadiusMultiplier", kf0, kf1, kf2, kf3); + + kf0 = Keyframe.ofFloat(0f, 0f); + kf1 = Keyframe.ofFloat(delayPoint, 0f); + kf2 = Keyframe.ofFloat(1f, 1f); + PropertyValuesHolder fadeIn = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1, kf2); + + mReappearAnimator = ObjectAnimator.ofPropertyValuesHolder( + this, radiusReappear, fadeIn).setDuration(totalDuration); + mReappearAnimator.addUpdateListener(mInvalidateUpdateListener); + } + + public ObjectAnimator getDisappearAnimator() { + if (!mIsInitialized || !mDrawValuesReady) { + Log.e(TAG, "RadialTextView was not ready for animation."); + return null; + } + + return mDisappearAnimator; + } + + public ObjectAnimator getReappearAnimator() { + if (!mIsInitialized || !mDrawValuesReady) { + Log.e(TAG, "RadialTextView was not ready for animation."); + return null; + } + + return mReappearAnimator; + } + + private class InvalidateUpdateListener implements AnimatorUpdateListener { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + RadialTextsView.this.invalidate(); + } + } +} diff --git a/src/com/android/datetimepicker/TimePicker.java b/src/com/android/datetimepicker/TimePicker.java new file mode 100644 index 0000000..201ba75 --- /dev/null +++ b/src/com/android/datetimepicker/TimePicker.java @@ -0,0 +1,527 @@ +/* + * 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 com.android.datetimepicker; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Service; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.os.Handler; +import android.os.SystemClock; +import android.os.Vibrator; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnClickListener; +import android.view.View.OnTouchListener; +import android.view.ViewConfiguration; +import android.view.animation.AnimationSet; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; + +import com.android.datetimepicker.R; + +public class TimePicker extends FrameLayout implements OnTouchListener { + private static final String TAG = "TimePicker"; + + private final int TOUCH_SLOP; + private final int TAP_TIMEOUT; + private final int PRESSED_STATE_DURATION; + private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = 30; + private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6; + private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX; + private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX; + private static final int AMPM_INDEX = TimePickerDialog.AMPM_INDEX; + private static final int AM = TimePickerDialog.AM; + private static final int PM = TimePickerDialog.PM; + + private Vibrator mVibrator; + private long mLastVibrate; + private int mLastValueSelected; + + private OnValueSelectedListener mListener; + private boolean mTimeInitialized; + private int mCurrentHoursOfDay; + private int mCurrentMinutes; + private boolean mIs24HourMode; + private int mCurrentItemShowing; + + private CircleView mCircleView; + private AmPmCirclesView mAmPmCirclesView; + private RadialTextsView mHourRadialTextsView; + private RadialTextsView mMinuteRadialTextsView; + private RadialSelectorView mHourRadialSelectorView; + private RadialSelectorView mMinuteRadialSelectorView; + + private int mIsTouchingAmOrPm = -1; + private boolean mDoingMove; + private int mDownDegrees; + private float mDownX; + private float mDownY; + + private ReselectSelectorRunnable mReselectSelectorRunnable; + + private Handler mHandler = new Handler(); + + public interface OnValueSelectedListener { + void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance); + } + + public TimePicker(Context context, AttributeSet attrs) { + super(context, attrs); + + setOnTouchListener(this); + ViewConfiguration vc = ViewConfiguration.get(context); + TOUCH_SLOP = vc.getScaledTouchSlop(); + TAP_TIMEOUT = ViewConfiguration.getTapTimeout(); + PRESSED_STATE_DURATION = ViewConfiguration.getPressedStateDuration(); + mDoingMove = false; + + mCircleView = new CircleView(context); + addView(mCircleView); + + mAmPmCirclesView = new AmPmCirclesView(context); + addView(mAmPmCirclesView); + + mHourRadialTextsView = new RadialTextsView(context); + addView(mHourRadialTextsView); + mMinuteRadialTextsView = new RadialTextsView(context); + addView(mMinuteRadialTextsView); + + mHourRadialSelectorView = new RadialSelectorView(context); + addView(mHourRadialSelectorView); + mMinuteRadialSelectorView = new RadialSelectorView(context); + addView(mMinuteRadialSelectorView); + + setCurrentItemShowing(HOUR_INDEX, false); + + mReselectSelectorRunnable = new ReselectSelectorRunnable(this); + + mVibrator = (Vibrator) context.getSystemService(Service.VIBRATOR_SERVICE); + mLastVibrate = 0; + mLastValueSelected = -1; + + mTimeInitialized = false; + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int measuredWidth = MeasureSpec.getSize(widthMeasureSpec); + int measuredHeight = MeasureSpec.getSize(heightMeasureSpec); + super.onMeasure(widthMeasureSpec, + measuredWidth < measuredHeight? widthMeasureSpec : heightMeasureSpec); + } + + public void setOnValueSelectedListener(OnValueSelectedListener listener) { + mListener = listener; + } + + public void initialize(Context context, int initialHoursOfDay, int initialMinutes, + boolean is24HourMode) { + if (mTimeInitialized) { + Log.e(TAG, "Time has already been initialized."); + return; + } + + setValueForItem(HOUR_INDEX, initialHoursOfDay); + setValueForItem(MINUTE_INDEX, initialMinutes); + mIs24HourMode = is24HourMode; + + mCircleView.initialize(context, is24HourMode); + mCircleView.invalidate(); + if (!is24HourMode) { + mAmPmCirclesView.initialize(context, initialHoursOfDay < 12? AM : PM); + mAmPmCirclesView.invalidate(); + } + + Resources res = context.getResources(); + String[] hoursTexts = res.getStringArray(is24HourMode? R.array.hours_24 : R.array.hours); + String[] innerHoursTexts = res.getStringArray(R.array.hours); + String[] minutesTexts = res.getStringArray(R.array.minutes); + mHourRadialTextsView.initialize(res, + hoursTexts, (is24HourMode? innerHoursTexts : null), is24HourMode, true); + mHourRadialTextsView.invalidate(); + mMinuteRadialTextsView.initialize(res, minutesTexts, null, is24HourMode, false); + mMinuteRadialTextsView.invalidate(); + + int initialHourDegrees = (initialHoursOfDay % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE; + int initialMinuteDegrees = initialMinutes * MINUTE_VALUE_TO_DEGREES_STEP_SIZE; + mHourRadialSelectorView.initialize(context, initialHourDegrees, + is24HourMode, is24HourMode, isHourInnerCircle(initialHoursOfDay), true); + mHourRadialSelectorView.invalidate(); + mMinuteRadialSelectorView.initialize(context, initialMinuteDegrees, + is24HourMode, false, false, false); + mHourRadialSelectorView.invalidate(); + + + mTimeInitialized = true; + } + + private boolean isHourInnerCircle(int hourOfDay) { + // We'll have the 00 hours on the outside circle. + return mIs24HourMode && (hourOfDay <= 12 && hourOfDay != 0); + } + + public int getHours() { + return mCurrentHoursOfDay; + } + + public int getMinutes() { + return mCurrentMinutes; + } + + private int getCurrentlyShowingValue() { + int currentIndex = getCurrentItemShowing(); + if (currentIndex == HOUR_INDEX) { + return mCurrentHoursOfDay; + } else if (currentIndex == MINUTE_INDEX) { + return mCurrentMinutes; + } else { + return -1; + } + } + + public int getIsCurrentlyAmOrPm() { + if (mCurrentHoursOfDay < 12) { + return AM; + } else if (mCurrentHoursOfDay < 24) { + return PM; + } + return -1; + } + + private void setValueForItem(int index, int value) { + if (index == HOUR_INDEX) { + mCurrentHoursOfDay = value; + } else if (index == MINUTE_INDEX){ + mCurrentMinutes = value; + } else if (index == AMPM_INDEX) { + if (value == AM) { + mCurrentHoursOfDay = mCurrentHoursOfDay % 12; + } else if (value == PM) { + mCurrentHoursOfDay = (mCurrentHoursOfDay % 12) + 12; + } + } + } + + public void setAmOrPm(int amOrPm) { + mAmPmCirclesView.setAmOrPm(amOrPm); + mAmPmCirclesView.invalidate(); + setValueForItem(AMPM_INDEX, amOrPm); + } + + private int reselectSelector(int index, int degrees, boolean isInnerCircle, + boolean forceNotFineGrained, boolean forceDrawLine, boolean forceDrawDot) { + if (degrees == -1 || (index != 0 && index != 1)) { + return -1; + } + + int stepSize; + int currentShowing = getCurrentItemShowing(); + if (!forceNotFineGrained && (currentShowing == 1)) { + stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE; + } else { + stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; + } + int floor = (degrees / stepSize) * stepSize; + int ceiling = floor + stepSize; + if ((degrees - floor) < (ceiling - degrees)) { + degrees = floor; + } else { + degrees = ceiling; + } + + RadialSelectorView radialSelectorView; + if (index == 0) { + // Index == 0, hours. + radialSelectorView = mHourRadialSelectorView; + stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; + } else { + // Index == 1, minutes. + radialSelectorView = mMinuteRadialSelectorView; + stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE; + } + radialSelectorView.setSelection(degrees, isInnerCircle, forceDrawLine, forceDrawDot); + radialSelectorView.invalidate(); + + + if (currentShowing == HOUR_INDEX) { + if (mIs24HourMode) { + if (degrees == 0 && isInnerCircle) { + degrees = 360; + } else if (degrees == 360 && !isInnerCircle) { + degrees = 0; + } + } else if (degrees == 0) { + degrees = 360; + } + } else if (degrees == 360 && currentShowing == MINUTE_INDEX) { + degrees = 0; + } + + int value = degrees / stepSize; + if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) { + value += 12; + } + return value; + } + + private int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal, + final Boolean[] isInnerCircle) { + int currentItem = getCurrentItemShowing(); + if (currentItem == 0) { + return mHourRadialSelectorView.getDegreesFromCoords( + pointX, pointY, forceLegal, isInnerCircle); + } else if (currentItem == 1) { + return mMinuteRadialSelectorView.getDegreesFromCoords( + pointX, pointY, forceLegal, isInnerCircle); + } else { + return -1; + } + } + + public int getCurrentItemShowing() { + if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX) { + Log.e(TAG, "Current item showing was unfortunately set to "+mCurrentItemShowing); + return -1; + } + return mCurrentItemShowing; + } + + public void setCurrentItemShowing(int index, boolean animate) { + if (index != HOUR_INDEX && index != MINUTE_INDEX) { + Log.e(TAG, "TimePicker does not support view at index "+index); + return; + } + + if (animate && (index != getCurrentItemShowing())) { + ObjectAnimator[] anims = new ObjectAnimator[4]; + if (index == MINUTE_INDEX) { + anims[0] = mHourRadialTextsView.getDisappearAnimator(); + anims[1] = mHourRadialSelectorView.getDisappearAnimator(); + anims[2] = mMinuteRadialTextsView.getReappearAnimator(); + anims[3] = mMinuteRadialSelectorView.getReappearAnimator(); + } else if (index == HOUR_INDEX){ + anims[0] = mHourRadialTextsView.getReappearAnimator(); + anims[1] = mHourRadialSelectorView.getReappearAnimator(); + anims[2] = mMinuteRadialTextsView.getDisappearAnimator(); + anims[3] = mMinuteRadialSelectorView.getDisappearAnimator(); + } + + AnimatorSet transition = new AnimatorSet(); + transition.playTogether(anims); + transition.start(); + } else { + int hourAlpha = (index == 0) ? 255 : 0; + int minuteAlpha = (index == 1) ? 255 : 0; + mHourRadialTextsView.setAlpha(hourAlpha); + mHourRadialSelectorView.setAlpha(hourAlpha); + mMinuteRadialTextsView.setAlpha(minuteAlpha); + mMinuteRadialSelectorView.setAlpha(minuteAlpha); + } + + mCurrentItemShowing = index; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + final float eventX = event.getX(); + final float eventY = event.getY(); + int degrees; + int value; + final int currentShowing = getCurrentItemShowing(); + final Boolean[] isInnerCircle = new Boolean[1]; + isInnerCircle[0] = false; + + long millis = SystemClock.uptimeMillis(); + + switch(event.getAction()) { + case MotionEvent.ACTION_DOWN: + mDownX = eventX; + mDownY = eventY; + + mLastValueSelected = -1; + mDoingMove = false; + if (!mIs24HourMode) { + mIsTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); + } else { + mIsTouchingAmOrPm = -1; + } + if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { + tryVibrate(); + mDownDegrees = -1; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + mAmPmCirclesView.setAmOrPmPressed(mIsTouchingAmOrPm); + mAmPmCirclesView.invalidate(); + } + }, TAP_TIMEOUT); + } else { + mDownDegrees = getDegreesFromCoords(eventX, eventY, false, isInnerCircle); + if (mDownDegrees != -1) { + tryTick(); + mLastValueSelected = getCurrentlyShowingValue(); + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + mDoingMove = true; + int value = reselectSelector(currentShowing, mDownDegrees, + isInnerCircle[0], false, true, true); + mListener.onValueSelected(getCurrentItemShowing(), value, false); + } + }, TAP_TIMEOUT); + } + } + return true; + case MotionEvent.ACTION_MOVE: + float dY = Math.abs(eventY - mDownY); + float dX = Math.abs(eventX - mDownX); + + if (!mDoingMove && dX <= TOUCH_SLOP && dY <= TOUCH_SLOP) { + // Hasn't registered down yet, just slight, accidental movement of finger. + break; + } + + // If we're in the middle of touching down on AM or PM, check if we still are. + // If so, no-op. If not, remove its pressed state. Either way, no need to check + // for touches on the other circle. + if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { + mHandler.removeCallbacksAndMessages(null); + int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); + if (isTouchingAmOrPm != mIsTouchingAmOrPm) { + mAmPmCirclesView.setAmOrPmPressed(-1); + mAmPmCirclesView.invalidate(); + mIsTouchingAmOrPm = -1; + } + break; + } + + if (mDownDegrees == -1) { + // Original down was illegal, so no movement will register. + break; + } + + mDoingMove = true; + mHandler.removeCallbacksAndMessages(null); + degrees = getDegreesFromCoords(eventX, eventY, true, isInnerCircle); + if (degrees != -1) { + value = reselectSelector(currentShowing, degrees, + isInnerCircle[0], false, true, true); + if (value != mLastValueSelected) { + tryTick(); + mLastValueSelected = value; + } + mListener.onValueSelected(getCurrentItemShowing(), value, false); + } + return true; + case MotionEvent.ACTION_UP: + mHandler.removeCallbacksAndMessages(null); + + if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { + int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); + mAmPmCirclesView.setAmOrPmPressed(-1); + mAmPmCirclesView.invalidate(); + + if (isTouchingAmOrPm == mIsTouchingAmOrPm) { + mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm); + if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) { + mListener.onValueSelected(AMPM_INDEX, mIsTouchingAmOrPm, false); + setValueForItem(AMPM_INDEX, isTouchingAmOrPm); + } + } + mIsTouchingAmOrPm = -1; + break; + } + + if (mDownDegrees != -1) { + degrees = getDegreesFromCoords(eventX, eventY, mDoingMove, isInnerCircle); + if (degrees != -1) { + value = reselectSelector(currentShowing, degrees, isInnerCircle[0], + !mDoingMove, true, false); + mListener.onValueSelected(getCurrentItemShowing(), value, true); + + if (currentShowing == HOUR_INDEX && !mIs24HourMode) { + int amOrPm = getIsCurrentlyAmOrPm(); + if (amOrPm == AM && value == 12) { + value = 0; + } else if (amOrPm == PM && value != 12) { + value += 12; + } + } + setValueForItem(getCurrentItemShowing(), value); + } + } + mDoingMove = false; + return true; + default: + break; + } + return false; + } + + private class ReselectSelectorRunnable implements Runnable { + TimePicker mTimePicker; + private int mIndex; + private int mDegrees; + private boolean mIsInnerCircle; + private boolean mForceNotFineGrained; + private boolean mForceDrawLine; + private boolean mForceDrawDot; + + public ReselectSelectorRunnable(TimePicker timePicker) { + mTimePicker = timePicker; + } + + public void initializeValues(int index, int degrees, boolean isInnerCircle, + boolean forceNotFineGrained, boolean forceDrawLine, boolean forceDrawDot) { + mIndex = index; + mDegrees = degrees; + mIsInnerCircle = isInnerCircle; + mForceNotFineGrained = forceNotFineGrained; + mForceDrawDot = forceDrawDot; + } + + @Override + public void run() { + mTimePicker.reselectSelector(mIndex, mDegrees, mIsInnerCircle, mForceNotFineGrained, + mForceDrawLine, mForceDrawDot); + } + } + + public void tryVibrate() { + if (mVibrator != null) { + long now = SystemClock.uptimeMillis(); + // We want to try to vibrate each individual tick discretely. + if (now - mLastVibrate >= 100) { + mVibrator.vibrate(5); + mLastVibrate = now; + } + } + } + + public void tryTick() { + tryVibrate(); + } +} diff --git a/src/com/android/datetimepicker/TimePickerDialog.java b/src/com/android/datetimepicker/TimePickerDialog.java new file mode 100644 index 0000000..d76ebd4 --- /dev/null +++ b/src/com/android/datetimepicker/TimePickerDialog.java @@ -0,0 +1,301 @@ +/* + * 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 com.android.datetimepicker; + +import android.app.ActionBar.LayoutParams; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Typeface; +import android.os.Bundle; +import android.os.Handler; +import android.os.SystemClock; +import android.text.style.AlignmentSpan; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import com.android.datetimepicker.R; + +import com.android.datetimepicker.TimePicker.OnValueSelectedListener; + +/** + * Dialog to set a time. + */ +public class TimePickerDialog extends DialogFragment implements OnValueSelectedListener{ + private static final String TAG = "TimePickerDialog"; + + private static final String KEY_HOUR_OF_DAY = "hour_of_day"; + private static final String KEY_MINUTE = "minute"; + private static final String KEY_IS_24_HOUR_VIEW = "is_24_hour_view"; + public static final int HOUR_INDEX = 0; + public static final int MINUTE_INDEX = 1; + public static final int AMPM_INDEX = 2; // NOT a real index for the purpose of what's showing. + public static final int AM = 0; + public static final int PM = 1; + + private Handler mHandler = new Handler(); + + private OnTimeSetListener mCallback; + + private Button mDoneButton; + private TextView mHourView; + private TextView mMinuteView; + private TextView mAmPmTextView; + private TimePicker mTimePicker; + + private int mBlue; + private int mBlack; + private String mAmText; + private String mPmText; + + private boolean mAllowAutoAdvance; + private int mInitialHourOfDay; + private int mInitialMinute; + private boolean mIs24HourMode; + private int mWidthPixels; + + /** + * The callback interface used to indicate the user is done filling in + * the time (they clicked on the 'Set' button). + */ + public interface OnTimeSetListener { + + /** + * @param view The view associated with this listener. + * @param hourOfDay The hour that was set. + * @param minute The minute that was set. + */ + void onTimeSet(TimePicker view, int hourOfDay, int minute); + } + + public TimePickerDialog() { + // Empty constructor required for dialog fragment. + } + + public TimePickerDialog(Context context, int theme, OnTimeSetListener callback, + int hourOfDay, int minute, boolean is24HourMode) { + // Empty constructor required for dialog fragment. + } + + public static TimePickerDialog newInstance(OnTimeSetListener callback, + int hourOfDay, int minute, boolean is24HourMode) { + TimePickerDialog ret = new TimePickerDialog(); + ret.initialize(callback, hourOfDay, minute, is24HourMode); + return ret; + } + + public void initialize(OnTimeSetListener callback, + int hourOfDay, int minute, boolean is24HourMode) { + mCallback = callback; + + mInitialHourOfDay = hourOfDay; + mInitialMinute = minute; + mIs24HourMode = is24HourMode; + } + + public void setOnTimeSetListener(OnTimeSetListener callback) { + mCallback = callback; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null && savedInstanceState.containsKey(KEY_HOUR_OF_DAY) + && savedInstanceState.containsKey(KEY_MINUTE) + && savedInstanceState.containsKey(KEY_IS_24_HOUR_VIEW)) { + mInitialHourOfDay = savedInstanceState.getInt(KEY_HOUR_OF_DAY); + mInitialMinute = savedInstanceState.getInt(KEY_MINUTE); + mIs24HourMode = savedInstanceState.getBoolean(KEY_IS_24_HOUR_VIEW); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE); + + View view = inflater.inflate(R.layout.time_picker_dialog, null); + Resources res = getResources(); + + mBlue = res.getColor(R.color.blue); + mBlack = res.getColor(R.color.black); + + mHourView = (TextView) view.findViewById(R.id.hours); + mMinuteView = (TextView) view.findViewById(R.id.minutes); + mAmPmTextView = (TextView) view.findViewById(R.id.ampm_label); + mAmText = res.getString(R.string.am_label); + mPmText = res.getString(R.string.pm_label); + + mTimePicker = (TimePicker) view.findViewById(R.id.time_picker); + mTimePicker.setOnValueSelectedListener(this); + mTimePicker.initialize(getActivity(), mInitialHourOfDay, mInitialMinute, mIs24HourMode); + mTimePicker.invalidate(); + + mHourView.setTextColor(mBlue); + mHourView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + setCurrentItemShowing(HOUR_INDEX, true); + mTimePicker.tryVibrate(); + } + }); + mMinuteView.setTextColor(mBlack); + mMinuteView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + setCurrentItemShowing(MINUTE_INDEX, true); + mTimePicker.tryVibrate(); + } + }); + + mDoneButton = (Button) view.findViewById(R.id.done_button); + mDoneButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mTimePicker.tryVibrate(); + if (mCallback != null) { + mCallback.onTimeSet(mTimePicker, + mTimePicker.getHours(), mTimePicker.getMinutes()); + } + dismiss(); + } + }); + + DisplayMetrics metrics = new DisplayMetrics(); + getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); + mWidthPixels = metrics.widthPixels; + + if (mIs24HourMode) { + mAmPmTextView.setVisibility(View.GONE); + + RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + paramsSeparator.addRule(RelativeLayout.CENTER_IN_PARENT); + TextView separatorView = (TextView) view.findViewById(R.id.separator); + separatorView.setLayoutParams(paramsSeparator); + } else { + mAmPmTextView.setVisibility(View.VISIBLE); + updateAmPmDisplay(mInitialHourOfDay < 12? AM : PM); + View amPmHitspace = view.findViewById(R.id.ampm_hitspace); + amPmHitspace.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mTimePicker.tryVibrate(); + int amOrPm = mTimePicker.getIsCurrentlyAmOrPm(); + if (amOrPm == AM) { + amOrPm = PM; + } else if (amOrPm == PM){ + amOrPm = AM; + } + updateAmPmDisplay(amOrPm); + mTimePicker.setAmOrPm(amOrPm); + } + }); + } + + mAllowAutoAdvance = true; + setHour(mInitialHourOfDay); + setMinute(mInitialMinute); + + return view; + } + + private void updateAmPmDisplay(int amOrPm) { + if (amOrPm == AM) { + mAmPmTextView.setText(mAmText); + } else if (amOrPm == PM){ + mAmPmTextView.setText(mPmText); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + if (mTimePicker != null) { + outState.putInt(KEY_HOUR_OF_DAY, mTimePicker.getHours()); + outState.putInt(KEY_MINUTE, mTimePicker.getMinutes()); + outState.putBoolean(KEY_IS_24_HOUR_VIEW, mIs24HourMode); + } + } + + @Override + public void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance) { + if (pickerIndex == HOUR_INDEX) { + setHour(newValue); + if (mAllowAutoAdvance && autoAdvance) { + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + setCurrentItemShowing(MINUTE_INDEX, true); + } + }, 150); + } + } else if (pickerIndex == MINUTE_INDEX){ + setMinute(newValue); + } else if (pickerIndex == AMPM_INDEX) { + updateAmPmDisplay(newValue); + } + } + + private void setHour(int value) { + String format; + if (mIs24HourMode) { + format = "%02d"; + } else { + format = "%d"; + value = value % 12; + if (value == 0) { + value = 12; + } + } + + mHourView.setText(String.format(format, value)); + } + + private void setMinute(int value) { + if (value == 60) { + value = 0; + } + mMinuteView.setText(String.format("%02d", value)); + } + + private void setCurrentItemShowing(int index, boolean animate) { +/* + if (mAllowAutoAdvance && index == 1) { + // Once we've seen the minutes, no need to auto-advance. + mAllowAutoAdvance = false; + } +*/ + mTimePicker.setCurrentItemShowing(index, animate); + int hourColor = (index == HOUR_INDEX)? mBlue : mBlack; + int minuteColor = (index == MINUTE_INDEX)? mBlue : mBlack; + mHourView.setTextColor(hourColor); + mMinuteView.setTextColor(minuteColor); + } +} |