1 /*
2  * Copyright (C) 2021 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 android.widget;
18 
19 import android.animation.Animator;
20 import android.animation.ValueAnimator;
21 import android.annotation.NonNull;
22 import android.annotation.Nullable;
23 import android.content.res.ColorStateList;
24 import android.graphics.Color;
25 import android.text.TextUtils;
26 import android.text.method.TransformationMethod;
27 import android.text.method.TranslationTransformationMethod;
28 import android.util.Log;
29 import android.view.View;
30 import android.view.translation.UiTranslationManager;
31 import android.view.translation.ViewTranslationCallback;
32 import android.view.translation.ViewTranslationRequest;
33 import android.view.translation.ViewTranslationResponse;
34 
35 /**
36  * Default implementation for {@link ViewTranslationCallback} for {@link TextView} components.
37  * This class handles how to display the translated information for {@link TextView}.
38  *
39  * @hide
40  */
41 public class TextViewTranslationCallback implements ViewTranslationCallback {
42 
43     private static final String TAG = "TextViewTranslationCb";
44 
45     private static final boolean DEBUG = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG);
46 
47     private TranslationTransformationMethod mTranslationTransformation;
48     private boolean mIsShowingTranslation = false;
49     private boolean mAnimationRunning = false;
50     private boolean mIsTextPaddingEnabled = false;
51     private CharSequence mPaddedText;
52     private int mAnimationDurationMillis = 250; // default value
53 
54     private CharSequence mContentDescription;
55 
clearTranslationTransformation()56     private void clearTranslationTransformation() {
57         if (DEBUG) {
58             Log.v(TAG, "clearTranslationTransformation: " + mTranslationTransformation);
59         }
60         mTranslationTransformation = null;
61     }
62 
63     /**
64      * {@inheritDoc}
65      */
66     @Override
onShowTranslation(@onNull View view)67     public boolean onShowTranslation(@NonNull View view) {
68         if (mIsShowingTranslation) {
69             if (DEBUG) {
70                 Log.d(TAG, view + " is already showing translated text.");
71             }
72             return false;
73         }
74         ViewTranslationResponse response = view.getViewTranslationResponse();
75         if (response == null) {
76             Log.e(TAG, "onShowTranslation() shouldn't be called before "
77                     + "onViewTranslationResponse().");
78             return false;
79         }
80         // It is possible user changes text and new translation response returns, system should
81         // update the translation response to keep the result up to date.
82         // Because TextView.setTransformationMethod() will skip the same TransformationMethod
83         // instance, we should create a new one to let new translation can work.
84         if (mTranslationTransformation == null
85                 || !response.equals(mTranslationTransformation.getViewTranslationResponse())) {
86             TransformationMethod originalTranslationMethod =
87                     ((TextView) view).getTransformationMethod();
88             mTranslationTransformation = new TranslationTransformationMethod(response,
89                     originalTranslationMethod);
90         }
91         final TransformationMethod transformation = mTranslationTransformation;
92         runWithAnimation(
93                 (TextView) view,
94                 () -> {
95                     mIsShowingTranslation = true;
96                     mAnimationRunning = false;
97                     // TODO(b/178353965): well-handle setTransformationMethod.
98                     ((TextView) view).setTransformationMethod(transformation);
99                 });
100         if (response.getKeys().contains(ViewTranslationRequest.ID_CONTENT_DESCRIPTION)) {
101             CharSequence translatedContentDescription =
102                     response.getValue(ViewTranslationRequest.ID_CONTENT_DESCRIPTION).getText();
103             if (!TextUtils.isEmpty(translatedContentDescription)) {
104                 mContentDescription = view.getContentDescription();
105                 view.setContentDescription(translatedContentDescription);
106             }
107         }
108         return true;
109     }
110 
111     /**
112      * {@inheritDoc}
113      */
114     @Override
onHideTranslation(@onNull View view)115     public boolean onHideTranslation(@NonNull View view) {
116         if (view.getViewTranslationResponse() == null) {
117             Log.e(TAG, "onHideTranslation() shouldn't be called before "
118                     + "onViewTranslationResponse().");
119             return false;
120         }
121         // Restore to original text content.
122         if (mTranslationTransformation != null) {
123             final TransformationMethod transformation =
124                     mTranslationTransformation.getOriginalTransformationMethod();
125             runWithAnimation(
126                     (TextView) view,
127                     () -> {
128                         mIsShowingTranslation = false;
129                         mAnimationRunning = false;
130                         ((TextView) view).setTransformationMethod(transformation);
131                     });
132             if (!TextUtils.isEmpty(mContentDescription)) {
133                 view.setContentDescription(mContentDescription);
134             }
135         } else {
136             if (DEBUG) {
137                 Log.w(TAG, "onHideTranslation(): no translated text.");
138             }
139             return false;
140         }
141         return true;
142     }
143 
144     /**
145      * {@inheritDoc}
146      */
147     @Override
onClearTranslation(@onNull View view)148     public boolean onClearTranslation(@NonNull View view) {
149         // Restore to original text content and clear TranslationTransformation
150         if (mTranslationTransformation != null) {
151             onHideTranslation(view);
152             clearTranslationTransformation();
153             mPaddedText = null;
154             mContentDescription = null;
155         } else {
156             if (DEBUG) {
157                 Log.w(TAG, "onClearTranslation(): no translated text.");
158             }
159             return false;
160         }
161         return true;
162     }
163 
isShowingTranslation()164     public boolean isShowingTranslation() {
165         return mIsShowingTranslation;
166     }
167 
168     /**
169      * Returns whether the view is running animation to show or hide the translation.
170      */
isAnimationRunning()171     public boolean isAnimationRunning() {
172         return mAnimationRunning;
173     }
174 
175     @Override
enableContentPadding()176     public void enableContentPadding() {
177         mIsTextPaddingEnabled = true;
178     }
179 
180     /**
181      * Returns whether readers of the view text should receive padded text for compatibility
182      * reasons. The view's original text will be padded to match the length of the translated text.
183      */
isTextPaddingEnabled()184     boolean isTextPaddingEnabled() {
185         return mIsTextPaddingEnabled;
186     }
187 
188     /**
189      * Returns the view's original text with padding added. If the translated text isn't longer than
190      * the original text, returns the original text itself.
191      *
192      * @param text the view's original text
193      * @param translatedText the view's translated text
194      * @see #isTextPaddingEnabled()
195      */
196     @Nullable
getPaddedText(CharSequence text, CharSequence translatedText)197     CharSequence getPaddedText(CharSequence text, CharSequence translatedText) {
198         if (text == null) {
199             return null;
200         }
201         if (mPaddedText == null) {
202             mPaddedText = computePaddedText(text, translatedText);
203         }
204         return mPaddedText;
205     }
206 
207     @NonNull
computePaddedText(CharSequence text, CharSequence translatedText)208     private CharSequence computePaddedText(CharSequence text, CharSequence translatedText) {
209         if (translatedText == null) {
210             return text;
211         }
212         int newLength = translatedText.length();
213         if (newLength <= text.length()) {
214             return text;
215         }
216         StringBuilder sb = new StringBuilder(newLength);
217         sb.append(text);
218         for (int i = text.length(); i < newLength; i++) {
219             sb.append(COMPAT_PAD_CHARACTER);
220         }
221         return sb;
222     }
223 
224     private static final char COMPAT_PAD_CHARACTER = '\u2002';
225 
226     @Override
setAnimationDurationMillis(int durationMillis)227     public void setAnimationDurationMillis(int durationMillis) {
228         mAnimationDurationMillis = durationMillis;
229     }
230 
231     /**
232      * Applies a simple text alpha animation when toggling between original and translated text. The
233      * text is fully faded out, then swapped to the new text, then the fading is reversed.
234      *
235      * @param runnable the operation to run on the view after the text is faded out, to change to
236      * displaying the original or translated text.
237      */
runWithAnimation(TextView view, Runnable runnable)238     private void runWithAnimation(TextView view, Runnable runnable) {
239         if (mAnimator != null) {
240             mAnimator.end();
241             // Note: mAnimator is now null; do not use again here.
242         }
243         mAnimationRunning = true;
244         int fadedOutColor = colorWithAlpha(view.getCurrentTextColor(), 0);
245         mAnimator = ValueAnimator.ofArgb(view.getCurrentTextColor(), fadedOutColor);
246         mAnimator.addUpdateListener(
247                 // Note that if the text has a ColorStateList, this replaces it with a single color
248                 // for all states. The original ColorStateList is restored when the animation ends
249                 // (see below).
250                 (valueAnimator) -> view.setTextColor((Integer) valueAnimator.getAnimatedValue()));
251         mAnimator.setRepeatMode(ValueAnimator.REVERSE);
252         mAnimator.setRepeatCount(1);
253         mAnimator.setDuration(mAnimationDurationMillis);
254         final ColorStateList originalColors = view.getTextColors();
255         mAnimator.addListener(new Animator.AnimatorListener() {
256             @Override
257             public void onAnimationStart(Animator animation) {
258             }
259 
260             @Override
261             public void onAnimationEnd(Animator animation) {
262                 view.setTextColor(originalColors);
263                 mAnimator = null;
264             }
265 
266             @Override
267             public void onAnimationCancel(Animator animation) {
268             }
269 
270             @Override
271             public void onAnimationRepeat(Animator animation) {
272                 runnable.run();
273             }
274         });
275         mAnimator.start();
276     }
277 
278     private ValueAnimator mAnimator;
279 
280     /**
281      * Returns {@code color} with alpha changed to {@code newAlpha}
282      */
colorWithAlpha(int color, int newAlpha)283     private static int colorWithAlpha(int color, int newAlpha) {
284         return Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color));
285     }
286 }
287