Skip to content

Commit

Permalink
[MaterialTimePicker][24H Redesign] 2-ring support for 24H clock
Browse files Browse the repository at this point in the history
Resolves #1450

This replaces the current implementation of the 24H clock mode from 1 ring to 2 rings.
The 24H picker now also defaults to text input mode.

PiperOrigin-RevId: 463652374
  • Loading branch information
paulfthomas authored and drchen committed Jul 28, 2022
1 parent 9ca8a80 commit cbc0711
Show file tree
Hide file tree
Showing 12 changed files with 249 additions and 78 deletions.
19 changes: 14 additions & 5 deletions docs/components/TimePicker.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,30 @@ val picker =
.setTimeFormat(TimeFormat.CLOCK_12H)
.setHour(12)
.setMinute(10)
.setTitle("Select Appointment time")
.setTitleText("Select Appointment time")
.build()
```

`minute` is a *[0, 60)* value and hour is a *[0, 23]* value regardless of which
time format you choose.

You can use either `TimeFormat.CLOCK_12H` or `TimeFormat.CLOCK_24H`, depending
on the location of the device:
You can use either `TimeFormat.CLOCK_12H` (1 ring) or `TimeFormat.CLOCK_24H` (2 rings),
depending on the location of the device:

```
val isSystem24Hour = is24HourFormat(this)
val clockFormat = if (isSystem24Hour) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
```

The time picker's input mode defaults to clock mode (`INPUT_MODE_CLOCK`) with
`TimeFormat.CLOCK_12H` and text input mode (`INPUT_MODE_KEYBOARD`) with `TimeFormat.CLOCK_24H`.

The time picker can be started in clock mode with:

```kt
MaterialTimePicker.Builder().setInputMode(INPUT_MODE_CLOCK)
```

The time picker can be started in text input mode with:

```kt
Expand Down Expand Up @@ -99,7 +108,7 @@ Use a descriptive title that for the task:
```kt
val picker =
MaterialTimePicker.Builder()
.setTitle("Select Appointment time")
.setTitleText("Select Appointment time")
...
```

Expand All @@ -122,7 +131,7 @@ Element | Attribute | Related metho
------------------------------- | ------------------------------ | ----------------------------------------------------- | -------------
**Hour** | `N/A` | `Builder.setHour`<br>`MaterialTimePicker.getHour` | `0`
**Minute** | `N/A` | `Builder.setMinute`<br>`MaterialTimePicker.getMinute` | `0`
**Title** | `N/A` | `Builder.setTitle` | `Select Time`
**Title** | `N/A` | `Builder.setTitleText` | `Select Time`
**Keyboard Icon** | `app:keyboardIcon` | `N/A` | `@drawable/ic_keyboard_black_24dp`
**Clock Icon** | `app:clockIcon` | `N/A` | `@drawable/ic_clock_black_24dp`
**Clock face Background Color** | `app:clockFaceBackgroundColor` | `N/A` | `?attr/colorSurfaceVariant`
Expand Down
Binary file modified docs/components/assets/timepicker/timepicker_formats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 68 additions & 15 deletions lib/java/com/google/android/material/timepicker/ClockFaceView.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import com.google.android.material.R;

import static android.view.MotionEvent.ACTION_DOWN;
import static android.view.MotionEvent.ACTION_UP;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat.SELECTION_MODE_SINGLE;
import static java.lang.Math.abs;
import static java.lang.Math.max;
Expand Down Expand Up @@ -72,6 +74,7 @@ class ClockFaceView extends RadialViewGroup implements OnRotateListener {
private final ClockHandView clockHandView;
private final Rect textViewRect = new Rect();
private final RectF scratch = new RectF();
private final Rect scratchLineBounds = new Rect();

private final SparseArray<TextView> textViewPool = new SparseArray<>();
private final AccessibilityDelegateCompat valueAccessibilityDelegate;
Expand Down Expand Up @@ -176,13 +179,12 @@ public void onInitializeAccessibilityNodeInfo(
@Override
public boolean performAccessibilityAction(View host, int action, Bundle args) {
if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
long eventTime = SystemClock.uptimeMillis();
float x = host.getX() + host.getWidth() / 2f;
float y = host.getY() + host.getHeight() / 2f;
clockHandView.onTouchEvent(
MotionEvent.obtain(eventTime, eventTime, MotionEvent.ACTION_DOWN, x, y, 0));
clockHandView.onTouchEvent(
MotionEvent.obtain(eventTime, eventTime, MotionEvent.ACTION_UP, x, y, 0));
long time = SystemClock.uptimeMillis();
host.getHitRect(textViewRect);
float x = textViewRect.centerX();
float y = textViewRect.centerY();
clockHandView.onTouchEvent(MotionEvent.obtain(time, time, ACTION_DOWN, x, y, 0));
clockHandView.onTouchEvent(MotionEvent.obtain(time, time, ACTION_UP, x, y, 0));
return true;
}
return super.performAccessibilityAction(host, action, args);
Expand All @@ -209,6 +211,8 @@ public void setValues(String[] values, @StringRes int contentDescription) {
}

private void updateTextViews(@StringRes int contentDescription) {
boolean isMultiLevel = false;

LayoutInflater inflater = LayoutInflater.from(getContext());
int size = textViewPool.size();
for (int i = 0; i < max(values.length, size); ++i) {
Expand All @@ -225,9 +229,15 @@ private void updateTextViews(@StringRes int contentDescription) {
addView(textView);
}

textView.setVisibility(VISIBLE);
textView.setText(values[i]);
textView.setTag(R.id.material_value_index, i);

int level = (i / INITIAL_CAPACITY) + LEVEL_1;
textView.setTag(R.id.material_clock_level, level);
if (level > LEVEL_1) {
isMultiLevel = true;
}

ViewCompat.setAccessibilityDelegate(textView, valueAccessibilityDelegate);

textView.setTextColor(textColor);
Expand All @@ -236,6 +246,16 @@ private void updateTextViews(@StringRes int contentDescription) {
textView.setContentDescription(res.getString(contentDescription, values[i]));
}
}

clockHandView.setMultiLevel(isMultiLevel);
}

@Override
protected void updateLayoutParams() {
super.updateLayoutParams();
for (int i = 0; i < textViewPool.size(); ++i) {
textViewPool.get(i).setVisibility(VISIBLE);
}
}

@Override
Expand Down Expand Up @@ -271,28 +291,52 @@ public void setHandRotation(@FloatRange(from = 0f, to = 360f) float rotation) {

private void findIntersectingTextView() {
RectF selectorBox = clockHandView.getCurrentSelectorBox();
TextView selected = getSelectedTextView(selectorBox);
for (int i = 0; i < textViewPool.size(); ++i) {
TextView tv = textViewPool.get(i);
if (tv == null) {
continue;
}
tv.getDrawingRect(textViewRect);
offsetDescendantRectToMyCoords(tv, textViewRect);

// set selection
tv.setSelected(selectorBox.contains(textViewRect.centerX(), textViewRect.centerY()));
tv.setSelected(tv == selected);

// set gradient
RadialGradient radialGradient = getGradientForTextView(selectorBox, textViewRect, tv);
RadialGradient radialGradient = getGradientForTextView(selectorBox, tv);
tv.getPaint().setShader(radialGradient);
tv.invalidate();
}
}

@Nullable
private RadialGradient getGradientForTextView(RectF selectorBox, Rect tvBox, TextView tv) {
scratch.set(tvBox);
scratch.offset(tv.getPaddingLeft(), tv.getPaddingTop());
private TextView getSelectedTextView(RectF selectorBox) {
float minArea = Float.MAX_VALUE;
TextView selected = null;

for (int i = 0; i < textViewPool.size(); ++i) {
TextView tv = textViewPool.get(i);
if (tv == null) {
continue;
}
tv.getHitRect(textViewRect);
scratch.set(textViewRect);
scratch.union(selectorBox);
float area = scratch.width() * scratch.height();
if (area < minArea) { // the smallest enclosing rectangle is the selection (most overlap)
minArea = area;
selected = tv;
}
}

return selected;
}

@Nullable
private RadialGradient getGradientForTextView(RectF selectorBox, TextView tv) {
tv.getHitRect(textViewRect);
scratch.set(textViewRect);
tv.getLineBounds(0, scratchLineBounds);
scratch.inset(scratchLineBounds.left, scratchLineBounds.top);
if (!RectF.intersects(selectorBox, scratch)) {
return null;
}
Expand Down Expand Up @@ -334,4 +378,13 @@ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
private static float max3(float a, float b, float c) {
return max(max(a, b), c);
}

@Level
int getCurrentLevel() {
return clockHandView.getCurrentLevel();
}

void setCurrentLevel(@Level int level) {
clockHandView.setCurrentLevel(level);
}
}
82 changes: 63 additions & 19 deletions lib/java/com/google/android/material/timepicker/ClockHandView.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@

import com.google.android.material.R;

import static com.google.android.material.timepicker.RadialViewGroup.LEVEL_1;
import static com.google.android.material.timepicker.RadialViewGroup.LEVEL_2;
import static com.google.android.material.timepicker.RadialViewGroup.LEVEL_RADIUS_RATIO;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Resources;
Expand All @@ -40,19 +42,23 @@
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.core.view.ViewCompat;
import com.google.android.material.internal.ViewUtils;
import com.google.android.material.math.MathUtils;
import com.google.android.material.timepicker.RadialViewGroup.Level;
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 ANIMATION_DURATION = 200;
private ValueAnimator rotationAnimator;
private final ValueAnimator rotationAnimator = new ValueAnimator();
private boolean animatingOnTouchUp;
private float downX;
private float downY;
private boolean isInTapRegion;
private int scaledTouchSlop;
private final int scaledTouchSlop;
private boolean isMultiLevel;

/** A listener whenever the hand is rotated. */
public interface OnRotateListener {
Expand Down Expand Up @@ -83,6 +89,8 @@ public interface OnActionUpListener {
private double degRad;
private int circleRadius;

@Level private int currentLevel = LEVEL_1;

public ClockHandView(Context context) {
this(context, null);
}
Expand Down Expand Up @@ -118,8 +126,10 @@ public ClockHandView(Context context, @Nullable AttributeSet attrs, int defStyle
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
// Refresh selector position.
setHandRotation(getHandRotation());
if (!rotationAnimator.isRunning()) {
// Refresh selector position.
setHandRotation(getHandRotation());
}
}

public void setHandRotation(@FloatRange(from = 0f, to = 360f) float degrees) {
Expand All @@ -137,15 +147,13 @@ public void setHandRotation(@FloatRange(from = 0f, to = 360f) float degrees, boo
}

Pair<Float, Float> animationValues = getValuesForAnimation(degrees);
rotationAnimator = ValueAnimator.ofFloat(animationValues.first, animationValues.second);
rotationAnimator.setFloatValues(animationValues.first, animationValues.second);
rotationAnimator.setDuration(ANIMATION_DURATION);
rotationAnimator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
setHandRotationInternal(animatedValue, true);
}
});
rotationAnimator.addUpdateListener(
animation -> {
float animatedValue = (float) animation.getAnimatedValue();
setHandRotationInternal(animatedValue, true);
});

rotationAnimator.addListener(new AnimatorListenerAdapter() {
@Override
Expand Down Expand Up @@ -186,8 +194,9 @@ private void setHandRotationInternal(
degRad = Math.toRadians(angDeg);
int yCenter = getHeight() / 2;
int xCenter = getWidth() / 2;
float selCenterX = xCenter + circleRadius * (float) Math.cos(degRad);
float selCenterY = yCenter + circleRadius * (float) Math.sin(degRad);
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,
Expand Down Expand Up @@ -230,8 +239,9 @@ private void drawSelector(Canvas canvas) {
int xCenter = getWidth() / 2;

// Calculate the current radius at which to place the selection circle.
float selCenterX = xCenter + circleRadius * (float) Math.cos(degRad);
float selCenterY = yCenter + circleRadius * (float) Math.sin(degRad);
int leveledCircleRadius = getLeveledCircleRadius(currentLevel);
float selCenterX = xCenter + leveledCircleRadius * (float) Math.cos(degRad);
float selCenterY = yCenter + leveledCircleRadius * (float) Math.sin(degRad);

// Draw the selection circle.
paint.setStrokeWidth(0);
Expand All @@ -241,7 +251,7 @@ private void drawSelector(Canvas canvas) {
// edge of the selection circle.
double sin = Math.sin(degRad);
double cos = Math.cos(degRad);
float lineLength = circleRadius - selectorRadius;
float lineLength = leveledCircleRadius - selectorRadius;
float linePointX = xCenter + (int) (lineLength * cos);
float linePointY = yCenter + (int) (lineLength * sin);

Expand Down Expand Up @@ -299,8 +309,10 @@ public boolean onTouchEvent(MotionEvent event) {
if (changedDuringTouch) {
forceSelection = true;
}

actionUp = action == MotionEvent.ACTION_UP;
if (isMultiLevel) {
adjustLevel(x, y);
}
break;
default:
break;
Expand All @@ -314,6 +326,15 @@ public boolean onTouchEvent(MotionEvent event) {
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 handleTouchInput(
float x, float y, boolean forceSelection, boolean touchDown, boolean actionUp) {
int degrees = getDegreesFromXY(x, y);
Expand Down Expand Up @@ -341,4 +362,27 @@ private int getDegreesFromXY(float x, float y) {
}
return degrees;
}

@Level
int getCurrentLevel() {
return currentLevel;
}

void setCurrentLevel(@Level int level) {
currentLevel = level;
invalidate();
}

void setMultiLevel(boolean isMultiLevel) {
if (this.isMultiLevel && !isMultiLevel) {
currentLevel = LEVEL_1; // reset
}
this.isMultiLevel = isMultiLevel;
invalidate();
}

@Dimension
private int getLeveledCircleRadius(@Level int level) {
return level == LEVEL_2 ? Math.round(circleRadius * LEVEL_RADIUS_RATIO) : circleRadius;
}
}
Loading

0 comments on commit cbc0711

Please sign in to comment.