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 mIsTextPaddingEnabled = false; 50 private CharSequence mPaddedText; 51 private int mAnimationDurationMillis = 250; // default value 52 53 private CharSequence mContentDescription; 54 clearTranslationTransformation()55 private void clearTranslationTransformation() { 56 if (DEBUG) { 57 Log.v(TAG, "clearTranslationTransformation: " + mTranslationTransformation); 58 } 59 mTranslationTransformation = null; 60 } 61 62 /** 63 * {@inheritDoc} 64 */ 65 @Override onShowTranslation(@onNull View view)66 public boolean onShowTranslation(@NonNull View view) { 67 ViewTranslationResponse response = view.getViewTranslationResponse(); 68 if (response == null) { 69 Log.e(TAG, "onShowTranslation() shouldn't be called before " 70 + "onViewTranslationResponse()."); 71 return false; 72 } 73 if (mTranslationTransformation == null) { 74 TransformationMethod originalTranslationMethod = 75 ((TextView) view).getTransformationMethod(); 76 mTranslationTransformation = new TranslationTransformationMethod(response, 77 originalTranslationMethod); 78 } 79 final TransformationMethod transformation = mTranslationTransformation; 80 runWithAnimation( 81 (TextView) view, 82 () -> { 83 mIsShowingTranslation = true; 84 // TODO(b/178353965): well-handle setTransformationMethod. 85 ((TextView) view).setTransformationMethod(transformation); 86 }); 87 if (response.getKeys().contains(ViewTranslationRequest.ID_CONTENT_DESCRIPTION)) { 88 CharSequence translatedContentDescription = 89 response.getValue(ViewTranslationRequest.ID_CONTENT_DESCRIPTION).getText(); 90 if (!TextUtils.isEmpty(translatedContentDescription)) { 91 mContentDescription = view.getContentDescription(); 92 view.setContentDescription(translatedContentDescription); 93 } 94 } 95 return true; 96 } 97 98 /** 99 * {@inheritDoc} 100 */ 101 @Override onHideTranslation(@onNull View view)102 public boolean onHideTranslation(@NonNull View view) { 103 if (view.getViewTranslationResponse() == null) { 104 Log.e(TAG, "onHideTranslation() shouldn't be called before " 105 + "onViewTranslationResponse()."); 106 return false; 107 } 108 // Restore to original text content. 109 if (mTranslationTransformation != null) { 110 final TransformationMethod transformation = 111 mTranslationTransformation.getOriginalTransformationMethod(); 112 runWithAnimation( 113 (TextView) view, 114 () -> { 115 mIsShowingTranslation = false; 116 ((TextView) view).setTransformationMethod(transformation); 117 }); 118 if (!TextUtils.isEmpty(mContentDescription)) { 119 view.setContentDescription(mContentDescription); 120 } 121 } else { 122 if (DEBUG) { 123 Log.w(TAG, "onHideTranslation(): no translated text."); 124 } 125 return false; 126 } 127 return true; 128 } 129 130 /** 131 * {@inheritDoc} 132 */ 133 @Override onClearTranslation(@onNull View view)134 public boolean onClearTranslation(@NonNull View view) { 135 // Restore to original text content and clear TranslationTransformation 136 if (mTranslationTransformation != null) { 137 onHideTranslation(view); 138 clearTranslationTransformation(); 139 mPaddedText = null; 140 mContentDescription = null; 141 } else { 142 if (DEBUG) { 143 Log.w(TAG, "onClearTranslation(): no translated text."); 144 } 145 return false; 146 } 147 return true; 148 } 149 isShowingTranslation()150 boolean isShowingTranslation() { 151 return mIsShowingTranslation; 152 } 153 154 @Override enableContentPadding()155 public void enableContentPadding() { 156 mIsTextPaddingEnabled = true; 157 } 158 159 /** 160 * Returns whether readers of the view text should receive padded text for compatibility 161 * reasons. The view's original text will be padded to match the length of the translated text. 162 */ isTextPaddingEnabled()163 boolean isTextPaddingEnabled() { 164 return mIsTextPaddingEnabled; 165 } 166 167 /** 168 * Returns the view's original text with padding added. If the translated text isn't longer than 169 * the original text, returns the original text itself. 170 * 171 * @param text the view's original text 172 * @param translatedText the view's translated text 173 * @see #isTextPaddingEnabled() 174 */ 175 @Nullable getPaddedText(CharSequence text, CharSequence translatedText)176 CharSequence getPaddedText(CharSequence text, CharSequence translatedText) { 177 if (text == null) { 178 return null; 179 } 180 if (mPaddedText == null) { 181 mPaddedText = computePaddedText(text, translatedText); 182 } 183 return mPaddedText; 184 } 185 186 @NonNull computePaddedText(CharSequence text, CharSequence translatedText)187 private CharSequence computePaddedText(CharSequence text, CharSequence translatedText) { 188 if (translatedText == null) { 189 return text; 190 } 191 int newLength = translatedText.length(); 192 if (newLength <= text.length()) { 193 return text; 194 } 195 StringBuilder sb = new StringBuilder(newLength); 196 sb.append(text); 197 for (int i = text.length(); i < newLength; i++) { 198 sb.append(COMPAT_PAD_CHARACTER); 199 } 200 return sb; 201 } 202 203 private static final char COMPAT_PAD_CHARACTER = '\u2002'; 204 205 @Override setAnimationDurationMillis(int durationMillis)206 public void setAnimationDurationMillis(int durationMillis) { 207 mAnimationDurationMillis = durationMillis; 208 } 209 210 /** 211 * Applies a simple text alpha animation when toggling between original and translated text. The 212 * text is fully faded out, then swapped to the new text, then the fading is reversed. 213 * 214 * @param runnable the operation to run on the view after the text is faded out, to change to 215 * displaying the original or translated text. 216 */ runWithAnimation(TextView view, Runnable runnable)217 private void runWithAnimation(TextView view, Runnable runnable) { 218 if (mAnimator != null) { 219 mAnimator.end(); 220 // Note: mAnimator is now null; do not use again here. 221 } 222 int fadedOutColor = colorWithAlpha(view.getCurrentTextColor(), 0); 223 mAnimator = ValueAnimator.ofArgb(view.getCurrentTextColor(), fadedOutColor); 224 mAnimator.addUpdateListener( 225 // Note that if the text has a ColorStateList, this replaces it with a single color 226 // for all states. The original ColorStateList is restored when the animation ends 227 // (see below). 228 (valueAnimator) -> view.setTextColor((Integer) valueAnimator.getAnimatedValue())); 229 mAnimator.setRepeatMode(ValueAnimator.REVERSE); 230 mAnimator.setRepeatCount(1); 231 mAnimator.setDuration(mAnimationDurationMillis); 232 final ColorStateList originalColors = view.getTextColors(); 233 mAnimator.addListener(new Animator.AnimatorListener() { 234 @Override 235 public void onAnimationStart(Animator animation) { 236 } 237 238 @Override 239 public void onAnimationEnd(Animator animation) { 240 view.setTextColor(originalColors); 241 mAnimator = null; 242 } 243 244 @Override 245 public void onAnimationCancel(Animator animation) { 246 } 247 248 @Override 249 public void onAnimationRepeat(Animator animation) { 250 runnable.run(); 251 } 252 }); 253 mAnimator.start(); 254 } 255 256 private ValueAnimator mAnimator; 257 258 /** 259 * Returns {@code color} with alpha changed to {@code newAlpha} 260 */ colorWithAlpha(int color, int newAlpha)261 private static int colorWithAlpha(int color, int newAlpha) { 262 return Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color)); 263 } 264 } 265