1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.wear.widget;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.content.res.TypedArray;
22 import android.graphics.Color;
23 import android.graphics.Paint;
24 import android.util.AttributeSet;
25 import android.util.TypedValue;
26 import android.view.Gravity;
27 import android.view.View;
28 import android.widget.FrameLayout;
29 
30 import androidx.annotation.ColorInt;
31 import androidx.core.content.ContextCompat;
32 import androidx.swiperefreshlayout.widget.CircularProgressDrawable;
33 import androidx.wear.R;
34 
35 import org.jspecify.annotations.NonNull;
36 import org.jspecify.annotations.Nullable;
37 
38 /**
39  * {@link CircularProgressLayout} adds a circular countdown timer behind the view it contains,
40  * typically used to automatically confirm an operation after a short delay has elapsed.
41  *
42  * <p>The developer can specify a countdown interval via {@link #setTotalTime(long)} and a listener
43  * via {@link #setOnTimerFinishedListener(OnTimerFinishedListener)} to be called when the time has
44  * elapsed after {@link #startTimer()} has been called. Tap action can be received via {@link
45  * #setOnClickListener(OnClickListener)} and can be used to cancel the timer via {@link
46  * #stopTimer()} method.
47  *
48  * <p>Alternatively, this layout can be used to show indeterminate progress by calling {@link
49  * #setIndeterminate(boolean)} method.
50  */
51 public class CircularProgressLayout extends FrameLayout {
52 
53     /**
54      * Update interval for 60 fps.
55      */
56     private static final long DEFAULT_UPDATE_INTERVAL = 1000 / 60;
57 
58     /**
59      * Starting rotation for the progress indicator. Geometric clockwise [0..360] degree range
60      * correspond to [0..1] range. 0.75 corresponds to 12 o'clock direction on a watch.
61      */
62     private static final float DEFAULT_ROTATION = 0.75f;
63 
64     /**
65      * Used as background of this layout.
66      */
67     private CircularProgressDrawable mProgressDrawable;
68 
69     /**
70      * Used to control this layout.
71      */
72     private CircularProgressLayoutController mController;
73 
74     /**
75      * Angle for the progress to start from.
76      */
77     private float mStartingRotation = DEFAULT_ROTATION;
78 
79     /**
80      * Duration of the timer in milliseconds.
81      */
82     private long mTotalTime;
83 
84 
85     /**
86      * Interface to implement for listening to {@link
87      * OnTimerFinishedListener#onTimerFinished(CircularProgressLayout)} event.
88      */
89     public interface OnTimerFinishedListener {
90 
91         /**
92          * Called when the timer started by {@link #startTimer()} method finishes.
93          *
94          * @param layout {@link CircularProgressLayout} that calls this method.
95          */
onTimerFinished(CircularProgressLayout layout)96         void onTimerFinished(CircularProgressLayout layout);
97     }
98 
CircularProgressLayout(Context context)99     public CircularProgressLayout(Context context) {
100         this(context, null);
101     }
102 
CircularProgressLayout(Context context, AttributeSet attrs)103     public CircularProgressLayout(Context context, AttributeSet attrs) {
104         this(context, attrs, 0);
105     }
106 
CircularProgressLayout(Context context, AttributeSet attrs, int defStyleAttr)107     public CircularProgressLayout(Context context, AttributeSet attrs, int defStyleAttr) {
108         this(context, attrs, defStyleAttr, 0);
109     }
110 
CircularProgressLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)111     public CircularProgressLayout(Context context, AttributeSet attrs, int defStyleAttr,
112             int defStyleRes) {
113         super(context, attrs, defStyleAttr, defStyleRes);
114 
115         mProgressDrawable = new CircularProgressDrawable(context);
116         mProgressDrawable.setProgressRotation(DEFAULT_ROTATION);
117         mProgressDrawable.setStrokeCap(Paint.Cap.BUTT);
118         setBackground(mProgressDrawable);
119 
120         // If a child view is added, make it center aligned so it fits in the progress drawable.
121         setOnHierarchyChangeListener(new OnHierarchyChangeListener() {
122             @Override
123             public void onChildViewAdded(View parent, View child) {
124                 // Ensure that child view is aligned in center
125                 LayoutParams params = (LayoutParams) child.getLayoutParams();
126                 params.gravity = Gravity.CENTER;
127                 child.setLayoutParams(params);
128             }
129 
130             @Override
131             public void onChildViewRemoved(View parent, View child) {
132 
133             }
134         });
135 
136         mController = new CircularProgressLayoutController(this);
137 
138         Resources r = context.getResources();
139         TypedArray a = r.obtainAttributes(attrs, R.styleable.CircularProgressLayout);
140 
141         if (a.getType(R.styleable.CircularProgressLayout_colorSchemeColors) == TypedValue
142                 .TYPE_REFERENCE || !a.hasValue(
143                 R.styleable.CircularProgressLayout_colorSchemeColors)) {
144             int arrayResId = a.getResourceId(R.styleable.CircularProgressLayout_colorSchemeColors,
145                     R.array.circular_progress_layout_color_scheme_colors);
146             setColorSchemeColors(getColorListFromResources(r, arrayResId));
147         } else {
148             setColorSchemeColors(a.getColor(R.styleable.CircularProgressLayout_colorSchemeColors,
149                     Color.BLACK));
150         }
151 
152         setStrokeWidth(a.getDimensionPixelSize(R.styleable.CircularProgressLayout_strokeWidth,
153                 r.getDimensionPixelSize(
154                         R.dimen.circular_progress_layout_stroke_width)));
155 
156         setBackgroundColor(a.getColor(R.styleable.CircularProgressLayout_backgroundColor,
157                 ContextCompat.getColor(context,
158                         R.color.circular_progress_layout_background_color)));
159 
160         setIndeterminate(a.getBoolean(R.styleable.CircularProgressLayout_indeterminate, false));
161 
162         a.recycle();
163     }
164 
getColorListFromResources(Resources resources, int arrayResId)165     private int[] getColorListFromResources(Resources resources, int arrayResId) {
166         TypedArray colorArray = resources.obtainTypedArray(arrayResId);
167         int[] colors = new int[colorArray.length()];
168         for (int i = 0; i < colorArray.length(); i++) {
169             colors[i] = colorArray.getColor(i, 0);
170         }
171         colorArray.recycle();
172         return colors;
173     }
174 
175     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)176     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
177         super.onLayout(changed, left, top, right, bottom);
178         if (getChildCount() != 0) {
179             View childView = getChildAt(0);
180             // Wrap the drawable around the child view
181             mProgressDrawable.setCenterRadius(
182                     Math.min(childView.getWidth(), childView.getHeight()) / 2f);
183         } else {
184             // Fill the bounds if no child view is present
185             mProgressDrawable.setCenterRadius(0f);
186         }
187     }
188 
189     @Override
onDetachedFromWindow()190     protected void onDetachedFromWindow() {
191         super.onDetachedFromWindow();
192         mController.reset();
193     }
194 
195     /**
196      * Sets the background color of the {@link CircularProgressDrawable}, which is drawn as a circle
197      * inside the progress drawable. Colors are in ARGB format defined in {@link Color}.
198      *
199      * @param color an ARGB color
200      */
201     @Override
setBackgroundColor(@olorInt int color)202     public void setBackgroundColor(@ColorInt int color) {
203         mProgressDrawable.setBackgroundColor(color);
204     }
205 
206     /**
207      * Returns the background color of the {@link CircularProgressDrawable}.
208      *
209      * @return an ARGB color
210      */
211     @ColorInt
getBackgroundColor()212     public int getBackgroundColor() {
213         return mProgressDrawable.getBackgroundColor();
214     }
215 
216     /**
217      * Returns the {@link CircularProgressDrawable} used as background of this layout.
218      *
219      * @return {@link CircularProgressDrawable}
220      */
getProgressDrawable()221     public @NonNull CircularProgressDrawable getProgressDrawable() {
222         return mProgressDrawable;
223     }
224 
225     /**
226      * Sets if progress should be shown as an indeterminate spinner.
227      *
228      * @param indeterminate {@code true} if indeterminate spinner should be shown, {@code false}
229      *                      otherwise.
230      */
setIndeterminate(boolean indeterminate)231     public void setIndeterminate(boolean indeterminate) {
232         mController.setIndeterminate(indeterminate);
233     }
234 
235     /**
236      * Returns if progress is showing as an indeterminate spinner.
237      *
238      * @return {@code true} if indeterminate spinner is shown, {@code false} otherwise.
239      */
isIndeterminate()240     public boolean isIndeterminate() {
241         return mController.isIndeterminate();
242     }
243 
244     /**
245      * Sets the total time in milliseconds for the timer to countdown to. Calling this method while
246      * the timer is already running will not change the duration of the current timer.
247      *
248      * @param totalTime total time in milliseconds
249      */
setTotalTime(long totalTime)250     public void setTotalTime(long totalTime) {
251         if (totalTime <= 0) {
252             throw new IllegalArgumentException("Total time should be greater than zero.");
253         }
254         mTotalTime = totalTime;
255     }
256 
257     /**
258      * Returns the total time in milliseconds for the timer to countdown to.
259      *
260      * @return total time in milliseconds
261      */
getTotalTime()262     public long getTotalTime() {
263         return mTotalTime;
264     }
265 
266     /**
267      * Starts the timer countdown. Once the countdown is finished, if there is an {@link
268      * OnTimerFinishedListener} registered by {@link
269      * #setOnTimerFinishedListener(OnTimerFinishedListener)} method, its
270      * {@link OnTimerFinishedListener#onTimerFinished(CircularProgressLayout)} method is called. If
271      * this method is called while there is already a running timer, it will restart the timer.
272      */
startTimer()273     public void startTimer() {
274         mController.startTimer(mTotalTime, DEFAULT_UPDATE_INTERVAL);
275         mProgressDrawable.setProgressRotation(mStartingRotation);
276     }
277 
278     /**
279      * Stops the timer countdown. If there is no timer running, calling this method will not do
280      * anything.
281      */
stopTimer()282     public void stopTimer() {
283         mController.stopTimer();
284     }
285 
286     /**
287      * Returns if the timer is running.
288      *
289      * @return {@code true} if the timer is running, {@code false} otherwise
290      */
isTimerRunning()291     public boolean isTimerRunning() {
292         return mController.isTimerRunning();
293     }
294 
295     /**
296      * Sets the starting rotation for the progress drawable to start from. Default starting rotation
297      * is {@code 0.75} and it corresponds clockwise geometric 270 degrees (12 o'clock on a watch)
298      *
299      * @param rotation starting rotation from [0..1]
300      */
setStartingRotation(float rotation)301     public void setStartingRotation(float rotation) {
302         mStartingRotation = rotation;
303     }
304 
305     /**
306      * Returns the starting rotation of the progress drawable.
307      *
308      * @return starting rotation from [0..1]
309      */
getStartingRotation()310     public float getStartingRotation() {
311         return mStartingRotation;
312     }
313 
314     /**
315      * Sets the stroke width of the progress drawable in pixels.
316      *
317      * @param strokeWidth stroke width in pixels
318      */
setStrokeWidth(float strokeWidth)319     public void setStrokeWidth(float strokeWidth) {
320         mProgressDrawable.setStrokeWidth(strokeWidth);
321     }
322 
323     /**
324      * Returns the stroke width of the progress drawable in pixels.
325      *
326      * @return stroke width in pixels
327      */
getStrokeWidth()328     public float getStrokeWidth() {
329         return mProgressDrawable.getStrokeWidth();
330     }
331 
332     /**
333      * Sets the color scheme colors of the progress drawable, which is equivalent to calling {@link
334      * CircularProgressDrawable#setColorSchemeColors(int...)} method on background drawable of this
335      * layout.
336      *
337      * @param colors list of ARGB colors
338      */
setColorSchemeColors(int... colors)339     public void setColorSchemeColors(int... colors) {
340         mProgressDrawable.setColorSchemeColors(colors);
341     }
342 
343     /**
344      * Returns the color scheme colors of the progress drawable
345      *
346      * @return list of ARGB colors
347      */
getColorSchemeColors()348     public int[] getColorSchemeColors() {
349         return mProgressDrawable.getColorSchemeColors();
350     }
351 
352     /**
353      * Returns the {@link OnTimerFinishedListener} that is registered to this layout.
354      *
355      * @return registered {@link OnTimerFinishedListener}
356      */
getOnTimerFinishedListener()357     public @Nullable OnTimerFinishedListener getOnTimerFinishedListener() {
358         return mController.getOnTimerFinishedListener();
359     }
360 
361     /**
362      * Sets the {@link OnTimerFinishedListener} to be notified when timer countdown is finished.
363      *
364      * @param listener {@link OnTimerFinishedListener} to be notified, or {@code null} to clear
365      */
setOnTimerFinishedListener(@ullable OnTimerFinishedListener listener)366     public void setOnTimerFinishedListener(@Nullable OnTimerFinishedListener listener) {
367         mController.setOnTimerFinishedListener(listener);
368     }
369 }
370