1 /*
2  * Copyright (C) 2020 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 com.android.keyguard;
18 
19 import android.annotation.FloatRange;
20 import android.annotation.IntRange;
21 import android.content.Context;
22 import android.content.res.Resources;
23 import android.content.res.TypedArray;
24 import android.graphics.Canvas;
25 import android.text.format.DateFormat;
26 import android.util.AttributeSet;
27 import android.widget.TextView;
28 
29 import com.android.systemui.R;
30 
31 import java.util.Calendar;
32 import java.util.Locale;
33 import java.util.TimeZone;
34 
35 import kotlin.Unit;
36 
37 /**
38  * Displays the time with the hour positioned above the minutes. (ie: 09 above 30 is 9:30)
39  * The time's text color is a gradient that changes its colors based on its controller.
40  */
41 public class AnimatableClockView extends TextView {
42     private static final CharSequence DOUBLE_LINE_FORMAT_12_HOUR = "hh\nmm";
43     private static final CharSequence DOUBLE_LINE_FORMAT_24_HOUR = "HH\nmm";
44     private static final long DOZE_ANIM_DURATION = 300;
45     private static final long APPEAR_ANIM_DURATION = 350;
46     private static final long CHARGE_ANIM_DURATION_PHASE_0 = 500;
47     private static final long CHARGE_ANIM_DURATION_PHASE_1 = 1000;
48 
49     private final Calendar mTime = Calendar.getInstance();
50 
51     private final int mDozingWeight;
52     private final int mLockScreenWeight;
53     private CharSequence mFormat;
54     private CharSequence mDescFormat;
55     private int mDozingColor;
56     private int mLockScreenColor;
57     private float mLineSpacingScale = 1f;
58     private int mChargeAnimationDelay = 0;
59 
60     private TextAnimator mTextAnimator = null;
61     private Runnable mOnTextAnimatorInitialized;
62 
63     private boolean mIsSingleLine;
64 
AnimatableClockView(Context context)65     public AnimatableClockView(Context context) {
66         this(context, null, 0, 0);
67     }
68 
AnimatableClockView(Context context, AttributeSet attrs)69     public AnimatableClockView(Context context, AttributeSet attrs) {
70         this(context, attrs, 0, 0);
71     }
72 
AnimatableClockView(Context context, AttributeSet attrs, int defStyleAttr)73     public AnimatableClockView(Context context, AttributeSet attrs, int defStyleAttr) {
74         this(context, attrs, defStyleAttr, 0);
75     }
76 
AnimatableClockView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)77     public AnimatableClockView(Context context, AttributeSet attrs, int defStyleAttr,
78             int defStyleRes) {
79         super(context, attrs, defStyleAttr, defStyleRes);
80         TypedArray ta = context.obtainStyledAttributes(
81                 attrs, R.styleable.AnimatableClockView, defStyleAttr, defStyleRes);
82         try {
83             mDozingWeight = ta.getInt(R.styleable.AnimatableClockView_dozeWeight, 100);
84             mLockScreenWeight = ta.getInt(R.styleable.AnimatableClockView_lockScreenWeight, 300);
85             mChargeAnimationDelay = ta.getInt(
86                     R.styleable.AnimatableClockView_chargeAnimationDelay, 200);
87         } finally {
88             ta.recycle();
89         }
90 
91         ta = context.obtainStyledAttributes(
92                 attrs, android.R.styleable.TextView, defStyleAttr, defStyleRes);
93         try {
94             mIsSingleLine = ta.getBoolean(android.R.styleable.TextView_singleLine, false);
95         } finally {
96             ta.recycle();
97         }
98 
99         refreshFormat();
100     }
101 
102     @Override
onAttachedToWindow()103     public void onAttachedToWindow() {
104         super.onAttachedToWindow();
105         refreshFormat();
106     }
107 
108     @Override
onDetachedFromWindow()109     public void onDetachedFromWindow() {
110         super.onDetachedFromWindow();
111     }
112 
getDozingWeight()113     int getDozingWeight() {
114         if (useBoldedVersion()) {
115             return mDozingWeight + 100;
116         }
117         return mDozingWeight;
118     }
119 
getLockScreenWeight()120     int getLockScreenWeight() {
121         if (useBoldedVersion()) {
122             return mLockScreenWeight + 100;
123         }
124         return mLockScreenWeight;
125     }
126 
127     /**
128      * Whether to use a bolded version based on the user specified fontWeightAdjustment.
129      */
useBoldedVersion()130     boolean useBoldedVersion() {
131         // "Bold text" fontWeightAdjustment is 300.
132         return getResources().getConfiguration().fontWeightAdjustment > 100;
133     }
134 
refreshTime()135     void refreshTime() {
136         mTime.setTimeInMillis(System.currentTimeMillis());
137         setText(DateFormat.format(mFormat, mTime));
138         setContentDescription(DateFormat.format(mDescFormat, mTime));
139     }
140 
onTimeZoneChanged(TimeZone timeZone)141     void onTimeZoneChanged(TimeZone timeZone) {
142         mTime.setTimeZone(timeZone);
143         refreshFormat();
144     }
145 
146     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)147     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
148         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
149         if (mTextAnimator == null) {
150             mTextAnimator = new TextAnimator(
151                     getLayout(),
152                     () -> {
153                         invalidate();
154                         return Unit.INSTANCE;
155                     });
156             if (mOnTextAnimatorInitialized != null) {
157                 mOnTextAnimatorInitialized.run();
158                 mOnTextAnimatorInitialized = null;
159             }
160         } else {
161             mTextAnimator.updateLayout(getLayout());
162         }
163     }
164 
165     @Override
onDraw(Canvas canvas)166     protected void onDraw(Canvas canvas) {
167         mTextAnimator.draw(canvas);
168     }
169 
setLineSpacingScale(float scale)170     void setLineSpacingScale(float scale) {
171         mLineSpacingScale = scale;
172         setLineSpacing(0, mLineSpacingScale);
173     }
174 
setColors(int dozingColor, int lockScreenColor)175     void setColors(int dozingColor, int lockScreenColor) {
176         mDozingColor = dozingColor;
177         mLockScreenColor = lockScreenColor;
178     }
179 
animateAppearOnLockscreen()180     void animateAppearOnLockscreen() {
181         if (mTextAnimator == null) {
182             return;
183         }
184 
185         setTextStyle(
186                 getDozingWeight(),
187                 -1 /* text size, no update */,
188                 mLockScreenColor,
189                 false /* animate */,
190                 0 /* duration */,
191                 0 /* delay */,
192                 null /* onAnimationEnd */);
193 
194         setTextStyle(
195                 getLockScreenWeight(),
196                 -1 /* text size, no update */,
197                 mLockScreenColor,
198                 true, /* animate */
199                 APPEAR_ANIM_DURATION,
200                 0 /* delay */,
201                 null /* onAnimationEnd */);
202     }
203 
animateCharge(DozeStateGetter dozeStateGetter)204     void animateCharge(DozeStateGetter dozeStateGetter) {
205         if (mTextAnimator == null || mTextAnimator.isRunning()) {
206             // Skip charge animation if dozing animation is already playing.
207             return;
208         }
209         Runnable startAnimPhase2 = () -> setTextStyle(
210                 dozeStateGetter.isDozing() ? getDozingWeight() : getLockScreenWeight() /* weight */,
211                 -1,
212                 null,
213                 true /* animate */,
214                 CHARGE_ANIM_DURATION_PHASE_1,
215                 0 /* delay */,
216                 null /* onAnimationEnd */);
217         setTextStyle(dozeStateGetter.isDozing()
218                         ? getLockScreenWeight()
219                         : getDozingWeight()/* weight */,
220                 -1,
221                 null,
222                 true /* animate */,
223                 CHARGE_ANIM_DURATION_PHASE_0,
224                 mChargeAnimationDelay,
225                 startAnimPhase2);
226     }
227 
animateDoze(boolean isDozing, boolean animate)228     void animateDoze(boolean isDozing, boolean animate) {
229         setTextStyle(isDozing ? getDozingWeight() : getLockScreenWeight() /* weight */,
230                 -1,
231                 isDozing ? mDozingColor : mLockScreenColor,
232                 animate,
233                 DOZE_ANIM_DURATION,
234                 0 /* delay */,
235                 null /* onAnimationEnd */);
236     }
237 
238     /**
239      * Set text style with an optional animation.
240      *
241      * By passing -1 to weight, the view preserves its current weight.
242      * By passing -1 to textSize, the view preserves its current text size.
243      *
244      * @param weight text weight.
245      * @param textSize font size.
246      * @param animate true to animate the text style change, otherwise false.
247      */
setTextStyle( @ntRangefrom = 0, to = 1000) int weight, @FloatRange(from = 0) float textSize, Integer color, boolean animate, long duration, long delay, Runnable onAnimationEnd)248     private void setTextStyle(
249             @IntRange(from = 0, to = 1000) int weight,
250             @FloatRange(from = 0) float textSize,
251             Integer color,
252             boolean animate,
253             long duration,
254             long delay,
255             Runnable onAnimationEnd) {
256         if (mTextAnimator != null) {
257             mTextAnimator.setTextStyle(weight, textSize, color, animate, duration, null,
258                     delay, onAnimationEnd);
259         } else {
260             // when the text animator is set, update its start values
261             mOnTextAnimatorInitialized =
262                     () -> mTextAnimator.setTextStyle(
263                             weight, textSize, color, false, duration, null,
264                             delay, onAnimationEnd);
265         }
266     }
267 
refreshFormat()268     void refreshFormat() {
269         Patterns.update(mContext);
270 
271         final boolean use24HourFormat = DateFormat.is24HourFormat(getContext());
272         if (mIsSingleLine && use24HourFormat) {
273             mFormat = Patterns.sClockView24;
274         } else if (!mIsSingleLine && use24HourFormat) {
275             mFormat = DOUBLE_LINE_FORMAT_24_HOUR;
276         } else if (mIsSingleLine && !use24HourFormat) {
277             mFormat = Patterns.sClockView12;
278         } else {
279             mFormat = DOUBLE_LINE_FORMAT_12_HOUR;
280         }
281 
282         mDescFormat = use24HourFormat ? Patterns.sClockView24 : Patterns.sClockView12;
283         refreshTime();
284     }
285 
286     // DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often.
287     // This is an optimization to ensure we only recompute the patterns when the inputs change.
288     private static final class Patterns {
289         static String sClockView12;
290         static String sClockView24;
291         static String sCacheKey;
292 
update(Context context)293         static void update(Context context) {
294             final Locale locale = Locale.getDefault();
295             final Resources res = context.getResources();
296             final String clockView12Skel = res.getString(R.string.clock_12hr_format);
297             final String clockView24Skel = res.getString(R.string.clock_24hr_format);
298             final String key = locale.toString() + clockView12Skel + clockView24Skel;
299             if (key.equals(sCacheKey)) return;
300             sClockView12 = DateFormat.getBestDateTimePattern(locale, clockView12Skel);
301 
302             // CLDR insists on adding an AM/PM indicator even though it wasn't in the skeleton
303             // format.  The following code removes the AM/PM indicator if we didn't want it.
304             if (!clockView12Skel.contains("a")) {
305                 sClockView12 = sClockView12.replaceAll("a", "").trim();
306             }
307             sClockView24 = DateFormat.getBestDateTimePattern(locale, clockView24Skel);
308             sCacheKey = key;
309         }
310     }
311 
312     interface DozeStateGetter {
isDozing()313         boolean isDozing();
314     }
315 }
316