From 26bac5854f9ab738562431443cab020e8140aad1 Mon Sep 17 00:00:00 2001 From: pubiqq Date: Thu, 14 Aug 2025 16:26:00 +0300 Subject: [PATCH] [TimePicker] Make the dial selector discrete --- .../material/timepicker/ClockFaceView.java | 14 +- .../material/timepicker/ClockHandView.java | 294 ++++++++++++------ .../timepicker/TimePickerClockPresenter.java | 82 +++-- .../material/timepicker/TimePickerView.java | 4 + 4 files changed, 250 insertions(+), 144 deletions(-) diff --git a/lib/java/com/google/android/material/timepicker/ClockFaceView.java b/lib/java/com/google/android/material/timepicker/ClockFaceView.java index 368e1404fd8..0f73af0d837 100644 --- a/lib/java/com/google/android/material/timepicker/ClockFaceView.java +++ b/lib/java/com/google/android/material/timepicker/ClockFaceView.java @@ -91,7 +91,8 @@ class ClockFaceView extends RadialViewGroup implements OnRotateListener { private String[] values; - private float currentHandRotation; + private float currentHandDegrees; + private float currentHandLevel; private final ColorStateList textColor; @@ -366,11 +367,14 @@ private RadialGradient getGradientForTextView(RectF selectorBox, TextView tv) { } @Override - public void onRotate(float rotation, boolean animating) { - if (abs(currentHandRotation - rotation) > EPSILON) { - currentHandRotation = rotation; - findIntersectingTextView(); + public void onRotate(float degrees, int level, boolean animating) { + if (currentHandDegrees == degrees && currentHandLevel == level) { + return; } + + currentHandDegrees = degrees; + currentHandLevel = level; + findIntersectingTextView(); } @Override diff --git a/lib/java/com/google/android/material/timepicker/ClockHandView.java b/lib/java/com/google/android/material/timepicker/ClockHandView.java index 57a313d6598..3a54469c0b5 100644 --- a/lib/java/com/google/android/material/timepicker/ClockHandView.java +++ b/lib/java/com/google/android/material/timepicker/ClockHandView.java @@ -16,6 +16,8 @@ package com.google.android.material.timepicker; +import static android.view.HapticFeedbackConstants.CLOCK_TICK; + import com.google.android.material.R; import static com.google.android.material.timepicker.RadialViewGroup.LEVEL_1; @@ -40,20 +42,34 @@ import android.view.ViewConfiguration; import androidx.annotation.Dimension; import androidx.annotation.FloatRange; +import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import com.google.android.material.animation.AnimationUtils; import com.google.android.material.internal.ViewUtils; -import com.google.android.material.math.MathUtils; import com.google.android.material.motion.MotionUtils; import com.google.android.material.timepicker.RadialViewGroup.Level; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; /** A Class to draw the hand on a Clock face. */ class ClockHandView extends View { + private static final int SNAP_MODE_CONTINUOUS = 0; + private static final int SNAP_MODE_BY_STOPS = 1; + private static final int SNAP_MODE_BY_NUMBER_STOPS = 2; + + @IntDef({ + SNAP_MODE_CONTINUOUS, + SNAP_MODE_BY_STOPS, + SNAP_MODE_BY_NUMBER_STOPS + }) + @Retention(RetentionPolicy.SOURCE) + private @interface SnapMode {} + private static final int DEFAULT_ANIMATION_DURATION = 200; private final int animationDuration; private final TimeInterpolator animationInterpolator; @@ -61,37 +77,48 @@ class ClockHandView extends View { private boolean animatingOnTouchUp; private float downX; private float downY; - private boolean isInTapRegion; private final int scaledTouchSlop; private boolean isMultiLevel; + private int stopCount; + private int numberStopCount; + private int stopOffset; + /** A listener whenever the hand is rotated. */ public interface OnRotateListener { - void onRotate(@FloatRange(from = 0f, to = 360f) float rotation, boolean animating); + void onRotate( + @FloatRange(from = 0f, to = 360f) float degrees, + @Level int level, + boolean animating); } /** A listener called whenever the hand is released, after a touch event stream. */ public interface OnActionUpListener { - void onActionUp(@FloatRange(from = 0f, to = 360f) float rotation, boolean moveInEventStream); + void onActionUp( + @FloatRange(from = 0f, to = 360f) float degrees, + @Level int level); } private final List listeners = new ArrayList<>(); private final int selectorRadius; - private final float centerDotRadius; + private final int centerDotRadius; private final Paint paint = new Paint(); - // Since the selector moves, overlapping views may need information about - // its current position - private final RectF selectorBox = new RectF(); + + private float centerX; + private float centerY; + private float selectorCenterX; + private float selectorCenterY; + private float selectorLineX; + private float selectorLineY; @Px private final int selectorStrokeWidth; private float originalDeg; - private boolean changedDuringTouch; + private boolean isBeingDragged; private OnActionUpListener onActionUpListener; - private double degRad; private int circleRadius; @Level private int currentLevel = LEVEL_1; @@ -153,6 +180,12 @@ public void onAnimationCancel(Animator animation) { }); } + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + updateClockHandXY(); + } + @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); @@ -167,10 +200,19 @@ public void setHandRotation(@FloatRange(from = 0f, to = 360f) float degrees) { } public void setHandRotation(@FloatRange(from = 0f, to = 360f) float degrees, boolean animate) { + setHandPosition(degrees, getCurrentLevel(), SNAP_MODE_CONTINUOUS, animate); + } + + private void setHandRotationInternal( + @FloatRange(from = 0f, to = 360f) float degrees, boolean animate) { + setHandPositionInternal(degrees, getCurrentLevel(), SNAP_MODE_CONTINUOUS, animate); + } + + private void setHandPosition(float degrees, int level, @SnapMode int mode, boolean animate) { rotationAnimator.cancel(); if (!animate) { - setHandRotationInternal(degrees, false); + setHandPositionInternal(degrees, level, mode, /* animate= */ false); return; } @@ -200,32 +242,57 @@ private Pair getValuesForAnimation(float degrees) { return new Pair<>(currentDegrees, degrees); } - private void setHandRotationInternal( - @FloatRange(from = 0f, to = 360f) float degrees, boolean animate) { - degrees = degrees % 360; - originalDeg = degrees; - // Subtract 90f so that 0 degrees is at number 12. - float angDeg = originalDeg - 90f; + private void setHandPositionInternal(float degrees, int level, @SnapMode int mode, boolean animate) { + degrees = calculateActualAngle(degrees, mode); - degRad = Math.toRadians(angDeg); - int yCenter = getHeight() / 2; - int xCenter = getWidth() / 2; - int leveledCircleRadius = getLeveledCircleRadius(currentLevel); - float selCenterX = xCenter + leveledCircleRadius * (float) Math.cos(degRad); - float selCenterY = yCenter + leveledCircleRadius * (float) Math.sin(degRad); - selectorBox.set( - selCenterX - selectorRadius, - selCenterY - selectorRadius, - selCenterX + selectorRadius, - selCenterY + selectorRadius); + if (degrees == this.originalDeg && level == this.currentLevel) { + return; + } - for (OnRotateListener listener : listeners) { - listener.onRotate(degrees, animate); + this.originalDeg = degrees; + this.currentLevel = level; + updateClockHandXY(); + + if (mode == SNAP_MODE_BY_STOPS || mode == SNAP_MODE_BY_NUMBER_STOPS) { + performHapticFeedback(CLOCK_TICK); } + dispatchOnRotate(degrees, level, animate); invalidate(); } + private float calculateActualAngle(float degrees, @SnapMode int mode) { + switch (mode) { + case SNAP_MODE_BY_STOPS: + return calculateActualAngle(degrees, stopCount, stopOffset); + case SNAP_MODE_BY_NUMBER_STOPS: + return calculateActualAngle(degrees, numberStopCount, stopOffset); + case SNAP_MODE_CONTINUOUS: + return degrees % 360f; + default: + throw new RuntimeException("Unhandled mode: " + mode); + } + } + + private static float calculateActualAngle(float degrees, int stopCount, int stopOffset) { + if (stopCount == 0) { + return degrees % 360f; + } + + float step = 360f / stopCount; + float closestDegrees = (float) (Math.floor((degrees + step / 2f) / step)) * step; + return (closestDegrees + stopOffset) % 360; + } + + private void dispatchOnRotate( + @FloatRange(from = 0f, to = 360f) float degrees, + @Level int level, + boolean animating) { + for (OnRotateListener listener : listeners) { + listener.onRotate(degrees, level, animating); + } + } + public void setAnimateOnTouchUp(boolean animating) { animatingOnTouchUp = animating; } @@ -238,6 +305,19 @@ public void setOnActionUpListener(OnActionUpListener listener) { this.onActionUpListener = listener; } + void setStopParams(int stopCount, int numberStopCount, int stopOffset) { + if (this.stopCount == stopCount + && this.numberStopCount == numberStopCount + && this.stopOffset == stopOffset) { + return; + } + + this.stopCount = stopCount; + this.numberStopCount = numberStopCount; + this.stopOffset = stopOffset; + invalidate(); + } + @FloatRange(from = 0f, to = 360f) public float getHandRotation() { return originalDeg; @@ -247,39 +327,44 @@ public float getHandRotation() { protected void onDraw(Canvas canvas) { super.onDraw(canvas); - drawSelector(canvas); + // Draw the line. + paint.setStrokeWidth(selectorStrokeWidth); + canvas.drawLine(centerX, centerY, selectorLineX, selectorLineY, paint); + canvas.drawCircle(centerX, centerY, centerDotRadius, paint); + + // Draw the selection circle. + paint.setStrokeWidth(0); + canvas.drawCircle(selectorCenterX, selectorCenterY, selectorRadius, paint); } - private void drawSelector(Canvas canvas) { - int yCenter = getHeight() / 2; - int xCenter = getWidth() / 2; + private void updateClockHandXY() { + centerX = getWidth() / 2f; + centerY = getHeight() / 2f; - // Calculate the current radius at which to place the selection circle. - int leveledCircleRadius = getLeveledCircleRadius(currentLevel); - float selCenterX = xCenter + leveledCircleRadius * (float) Math.cos(degRad); - float selCenterY = yCenter + leveledCircleRadius * (float) Math.sin(degRad); + // Subtract 90f so that 0 degrees is at number 12. + double degRad = Math.toRadians(originalDeg - 90f); - // Draw the selection circle. - paint.setStrokeWidth(0); - canvas.drawCircle(selCenterX, selCenterY, selectorRadius, paint); + double sin = Math.sin(degRad); + double cos = Math.cos(degRad); + + float leveledCircleRadius = getLeveledCircleRadius(currentLevel); + selectorCenterX = (float) (centerX + leveledCircleRadius * cos); + selectorCenterY = (float) (centerY + leveledCircleRadius * sin); // Shorten the line to only go from the edge of the center dot to the // edge of the selection circle. - double sin = Math.sin(degRad); - double cos = Math.cos(degRad); float lineLength = leveledCircleRadius - selectorRadius; - float linePointX = xCenter + (int) (lineLength * cos); - float linePointY = yCenter + (int) (lineLength * sin); - - // Draw the line. - paint.setStrokeWidth(selectorStrokeWidth); - canvas.drawLine(xCenter, yCenter, linePointX, linePointY, paint); - canvas.drawCircle(xCenter, yCenter, centerDotRadius, paint); + selectorLineX = (float) (centerX + lineLength * cos); + selectorLineY = (float) (centerY + lineLength * sin); } /** Returns the current bounds of the selector, relative to the this view. */ public RectF getCurrentSelectorBox() { - return selectorBox; + return new RectF( + selectorCenterX - selectorRadius, + selectorCenterY - selectorRadius, + selectorCenterX + selectorRadius, + selectorCenterY + selectorRadius); } /** Returns the current radius of the selector */ @@ -292,7 +377,12 @@ public int getSelectorRadius() { * edge of the selector. */ public void setCircleRadius(@Dimension int circleRadius) { + if (this.circleRadius == circleRadius) { + return; + } + this.circleRadius = circleRadius; + updateClockHandXY(); invalidate(); } @@ -300,92 +390,101 @@ public void setCircleRadius(@Dimension int circleRadius) { @SuppressLint("ClickableViewAccessibility") public boolean onTouchEvent(MotionEvent event) { int action = event.getActionMasked(); - boolean forceSelection = false; - boolean actionDown = false; - boolean actionUp = false; float x = event.getX(); float y = event.getY(); + switch (action) { case MotionEvent.ACTION_DOWN: downX = x; downY = y; - isInTapRegion = true; - // This is a new event stream. - changedDuringTouch = false; - actionDown = true; + isBeingDragged = isOnSelector(x, y); + + @SnapMode int mode = isBeingDragged ? SNAP_MODE_BY_STOPS : SNAP_MODE_BY_NUMBER_STOPS; + setHandPositionFromXY(x, y, mode, /* animate= */ false); break; case MotionEvent.ACTION_MOVE: - case MotionEvent.ACTION_UP: - final int deltaX = (int) (x - downX); - final int deltaY = (int) (y - downY); - int distance = (deltaX * deltaX) + (deltaY * deltaY); - isInTapRegion = distance > scaledTouchSlop; - // If we saw a down/up pair without the value changing, assume - // this is a single-tap selection and force a change. - if (changedDuringTouch) { - forceSelection = true; + isBeingDragged |= isOutsideTapRegion(x, y, downX, downY); + if (isBeingDragged) { + setHandPositionFromXY(x, y, SNAP_MODE_BY_STOPS, /* animate= */ false); } - actionUp = action == MotionEvent.ACTION_UP; - if (isMultiLevel) { - adjustLevel(x, y); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + isBeingDragged |= isOutsideTapRegion(x, y, downX, downY); + if (isBeingDragged) { + setHandPositionFromXY(x, y, SNAP_MODE_BY_STOPS, animatingOnTouchUp); } + + dispatchOnActionUp(originalDeg, currentLevel); break; default: break; } - changedDuringTouch |= handleTouchInput(x, y, forceSelection, actionDown, actionUp); - if (changedDuringTouch && actionUp && onActionUpListener != null) { - onActionUpListener.onActionUp(getDegreesFromXY(x, y), /* moveInEventStream= */ isInTapRegion); - } - return true; } - private void adjustLevel(float x, float y) { - int xCenter = getWidth() / 2; - int yCenter = getHeight() / 2; - float selectionRadius = MathUtils.dist(xCenter, yCenter, x, y); - int level2CircleRadius = getLeveledCircleRadius(LEVEL_2); - float buffer = ViewUtils.dpToPx(getContext(), 12); - currentLevel = selectionRadius <= level2CircleRadius + buffer ? LEVEL_2 : LEVEL_1; + private boolean isOnSelector(float x, float y) { + return Math.hypot(x - selectorCenterX, y - selectorCenterY) <= selectorRadius; } - private boolean handleTouchInput( - float x, float y, boolean forceSelection, boolean touchDown, boolean actionUp) { - int degrees = getDegreesFromXY(x, y); - boolean valueChanged = getHandRotation() != degrees; - if (touchDown && valueChanged) { - return true; - } + private boolean isOutsideTapRegion(float upX, float upY, float downX, float downY) { + return Math.hypot(upX - downX, upY - downY) > scaledTouchSlop; + } - if (valueChanged || forceSelection) { - setHandRotation(degrees, actionUp && animatingOnTouchUp); - return true; - } + private void setHandPositionFromXY(float x, float y, @SnapMode int mode, boolean animate) { + float degrees = getDegreesFromXY(x, y); + int level = getLevelFromXY(x, y); - return false; + setHandPosition(degrees, level, mode, animate); } - private int getDegreesFromXY(float x, float y) { + private float getDegreesFromXY(float x, float y) { int xCenter = getWidth() / 2; int yCenter = getHeight() / 2; double dX = x - xCenter; double dY = y - yCenter; - int degrees = (int) Math.toDegrees(Math.atan2(dY, dX)) + 90; + float degrees = (float) (Math.toDegrees(Math.atan2(dY, dX)) + 90); if (degrees < 0) { degrees += 360; } return degrees; } + @Level + private int getLevelFromXY(float x, float y) { + if (!isMultiLevel) { + return LEVEL_1; + } + + int xCenter = getWidth() / 2; + int yCenter = getHeight() / 2; + float selectionRadius = (float) Math.hypot(x - xCenter, y - yCenter); + int level2CircleRadius = getLeveledCircleRadius(LEVEL_2); + float buffer = ViewUtils.dpToPx(getContext(), 12); + return selectionRadius <= level2CircleRadius + buffer ? LEVEL_2 : LEVEL_1; + } + + private void dispatchOnActionUp( + @FloatRange(from = 0f, to = 360f) float degrees, + @Level int level) { + if (onActionUpListener != null) { + onActionUpListener.onActionUp(degrees, level); + } + } + @Level int getCurrentLevel() { return currentLevel; } void setCurrentLevel(@Level int level) { - currentLevel = level; + if (this.currentLevel == level) { + return; + } + + this.currentLevel = level; + updateClockHandXY(); invalidate(); } @@ -394,6 +493,7 @@ void setMultiLevel(boolean isMultiLevel) { currentLevel = LEVEL_1; // reset } this.isMultiLevel = isMultiLevel; + updateClockHandXY(); invalidate(); } diff --git a/lib/java/com/google/android/material/timepicker/TimePickerClockPresenter.java b/lib/java/com/google/android/material/timepicker/TimePickerClockPresenter.java index bee592eb4f5..0c477e919b7 100644 --- a/lib/java/com/google/android/material/timepicker/TimePickerClockPresenter.java +++ b/lib/java/com/google/android/material/timepicker/TimePickerClockPresenter.java @@ -18,7 +18,6 @@ import com.google.android.material.R; -import static android.view.HapticFeedbackConstants.CLOCK_TICK; import static android.view.View.GONE; import static androidx.core.content.ContextCompat.getSystemService; import static com.google.android.material.timepicker.RadialViewGroup.LEVEL_1; @@ -110,42 +109,33 @@ private String[] getHourClockValues() { } @Override - public void onRotate(float rotation, boolean animating) { + public void onRotate(float degrees, int level, boolean animating) { // Do not update the displayed and actual time during an animation if (broadcasting || animating) { return; } - int prevHour = time.hour; - int prevMinute = time.minute; - int rotationInt = Math.round(rotation); - if (time.selection == MINUTE) { - int minuteOffset = DEGREES_PER_MINUTE / 2; - time.setMinute((rotationInt + minuteOffset) / DEGREES_PER_MINUTE); - minuteRotation = (float) Math.floor(time.minute * DEGREES_PER_MINUTE); - } else { - int hourOffset = DEGREES_PER_HOUR / 2; - - int hour = (rotationInt + hourOffset) / DEGREES_PER_HOUR; - if (time.format == CLOCK_24H) { - hour %= 12; // To correct hour (12 -> 0) in the 345-360 rotation section. - if (timePickerView.getCurrentLevel() == LEVEL_2) { - hour += 12; + switch (time.selection) { + case HOUR: + int hour = Math.round(degrees / DEGREES_PER_HOUR); + if (time.format == CLOCK_24H) { + hour %= 12; // To correct hour (12 -> 0) in the 345-360 rotation section. + if (level == LEVEL_2) { + hour += 12; + } } - } - time.setHour(hour); - hourRotation = getHourRotation(); + time.setHour(hour); + hourRotation = getHourRotation(); + break; + case MINUTE: + int minute = Math.round(degrees / DEGREES_PER_MINUTE); + time.setMinute(minute); + minuteRotation = getMinuteRotation(); + break; } updateTime(); - performHapticFeedback(prevHour, prevMinute); - } - - private void performHapticFeedback(int prevHour, int prevMinute) { - if (time.minute != prevMinute || time.hour != prevHour) { - timePickerView.performHapticFeedback(CLOCK_TICK); - } } @Override @@ -167,6 +157,7 @@ void setSelection(@ActiveSelection int selection, boolean animate) { isMinute ? MINUTE_CLOCK_VALUES : getHourClockValues(), isMinute ? R.string.material_minute_suffix : time.getHourContentDescriptionResId()); updateCurrentLevel(); + updateStopParams(); timePickerView.setHandRotation(isMinute ? minuteRotation : hourRotation, animate); timePickerView.setActiveSelection(selection); timePickerView.setMinuteHourDelegate( @@ -203,15 +194,28 @@ private void updateCurrentLevel() { timePickerView.setCurrentLevel(currentLevel); } + private void updateStopParams() { + switch (time.selection) { + case HOUR: + timePickerView.setStopParams(/* stopCount= */ 12, /* numberStopCount= */ 12, /* stopOffset= */ 0); + break; + case MINUTE: + timePickerView.setStopParams(/* stopCount= */ 60, /* numberStopCount= */ 12, /* stopOffset= */ 0); + break; + default: + throw new IllegalStateException("Unhandled selection: " + time.selection); + } + } + @Override - public void onActionUp(float rotation, boolean moveInEventStream) { + public void onActionUp(float degrees, int level) { broadcasting = true; - int prevMinute = time.minute; - int prevHour = time.hour; + if (time.selection == HOUR) { // Current rotation might be half way to an exact hour position. // Snap to the closest hour before animating to the position the minute selection is on. - timePickerView.setHandRotation(hourRotation, /* animate= */ false); + timePickerView.setHandRotation(degrees, /* animate= */ false); + timePickerView.setCurrentLevel(level); // Automatically move to minutes once the user finishes choosing the hour. AccessibilityManager am = @@ -220,19 +224,9 @@ public void onActionUp(float rotation, boolean moveInEventStream) { if (!isExploreByTouchEnabled) { setSelection(MINUTE, /* animate= */ true); } - } else { - int rotationInt = Math.round(rotation); - if (!moveInEventStream) { - // snap minute to 5 minute increment if there was only a touch down/up. - int newRotation = (rotationInt + 15) / 30; - time.setMinute(newRotation * 5); - minuteRotation = time.minute * DEGREES_PER_MINUTE; - } - timePickerView.setHandRotation(minuteRotation, /* animate= */ moveInEventStream); } + broadcasting = false; - updateTime(); - performHapticFeedback(prevHour, prevMinute); } private void updateTime() { @@ -255,4 +249,8 @@ private void updateValues(String[] values, String format) { private int getHourRotation() { return (time.getHourForDisplay() * DEGREES_PER_HOUR) % 360; } + + private int getMinuteRotation() { + return (time.minute * DEGREES_PER_MINUTE) % 360; + } } diff --git a/lib/java/com/google/android/material/timepicker/TimePickerView.java b/lib/java/com/google/android/material/timepicker/TimePickerView.java index fd10d77cfa1..45bef9a015b 100644 --- a/lib/java/com/google/android/material/timepicker/TimePickerView.java +++ b/lib/java/com/google/android/material/timepicker/TimePickerView.java @@ -192,6 +192,10 @@ public void setValues(String[] values, @StringRes int contentDescription) { clockFace.setValues(values, contentDescription); } + void setStopParams(int stopCount, int numberStopCount, int stopOffset) { + clockHandView.setStopParams(stopCount, numberStopCount, stopOffset); + } + @Override public void setHandRotation(float rotation) { clockHandView.setHandRotation(rotation);