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 import java.lang.ref.WeakReference; 36 37 /** 38 * Default implementation for {@link ViewTranslationCallback} for {@link TextView} components. 39 * This class handles how to display the translated information for {@link TextView}. 40 * 41 * @hide 42 */ 43 public class TextViewTranslationCallback implements ViewTranslationCallback { 44 45 private static final String TAG = "TextViewTranslationCb"; 46 47 private static final boolean DEBUG = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG); 48 49 private TranslationTransformationMethod mTranslationTransformation; 50 private boolean mIsShowingTranslation = false; 51 private boolean mAnimationRunning = false; 52 private boolean mIsTextPaddingEnabled = false; 53 private boolean mOriginalIsTextSelectable = false; 54 private int mOriginalFocusable = 0; 55 private boolean mOriginalFocusableInTouchMode = false; 56 private boolean mOriginalClickable = false; 57 private boolean mOriginalLongClickable = false; 58 private CharSequence mPaddedText; 59 private int mAnimationDurationMillis = 250; // default value 60 61 private CharSequence mContentDescription; 62 clearTranslationTransformation()63 private void clearTranslationTransformation() { 64 if (DEBUG) { 65 Log.v(TAG, "clearTranslationTransformation: " + mTranslationTransformation); 66 } 67 mTranslationTransformation = null; 68 } 69 70 /** 71 * {@inheritDoc} 72 */ 73 @Override onShowTranslation(@onNull View view)74 public boolean onShowTranslation(@NonNull View view) { 75 if (mIsShowingTranslation) { 76 if (DEBUG) { 77 Log.d(TAG, view + " is already showing translated text."); 78 } 79 return false; 80 } 81 ViewTranslationResponse response = view.getViewTranslationResponse(); 82 if (response == null) { 83 Log.e(TAG, "onShowTranslation() shouldn't be called before " 84 + "onViewTranslationResponse()."); 85 return false; 86 } 87 // It is possible user changes text and new translation response returns, system should 88 // update the translation response to keep the result up to date. 89 // Because TextView.setTransformationMethod() will skip the same TransformationMethod 90 // instance, we should create a new one to let new translation can work. 91 TextView theTextView = (TextView) view; 92 if (mTranslationTransformation == null 93 || !response.equals(mTranslationTransformation.getViewTranslationResponse())) { 94 TransformationMethod originalTranslationMethod = 95 theTextView.getTransformationMethod(); 96 mTranslationTransformation = new TranslationTransformationMethod(response, 97 originalTranslationMethod); 98 } 99 final TransformationMethod transformation = mTranslationTransformation; 100 WeakReference<TextView> textViewRef = new WeakReference<>(theTextView); 101 runChangeTextWithAnimationIfNeeded( 102 theTextView, 103 () -> { 104 mIsShowingTranslation = true; 105 mAnimationRunning = false; 106 107 TextView textView = textViewRef.get(); 108 if (textView == null) { 109 return; 110 } 111 // TODO(b/177214256): support selectable text translation. 112 // We use the TransformationMethod to implement showing the translated text. The 113 // TextView does not support the text length change for TransformationMethod. 114 // If the text is selectable or editable, it will crash while selecting the 115 // text. To support being able to select translated text, we need broader 116 // changes to text APIs. For now, the callback makes the text non-selectable 117 // while translated, and makes it selectable again after translation. 118 mOriginalIsTextSelectable = textView.isTextSelectable(); 119 if (mOriginalIsTextSelectable) { 120 // According to documentation for `setTextIsSelectable()`, it sets the 121 // flags focusable, focusableInTouchMode, clickable, and longClickable 122 // to the same value. We get the original values to restore when translation 123 // is hidden. 124 mOriginalFocusableInTouchMode = textView.isFocusableInTouchMode(); 125 mOriginalFocusable = textView.getFocusable(); 126 mOriginalClickable = textView.isClickable(); 127 mOriginalLongClickable = textView.isLongClickable(); 128 textView.setTextIsSelectable(false); 129 } 130 131 // TODO(b/233406028): We should NOT restore the original 132 // TransformationMethod and selectable state if it was changed WHILE 133 // translation was being shown. 134 textView.setTransformationMethod(transformation); 135 }); 136 if (response.getKeys().contains(ViewTranslationRequest.ID_CONTENT_DESCRIPTION)) { 137 CharSequence translatedContentDescription = 138 response.getValue(ViewTranslationRequest.ID_CONTENT_DESCRIPTION).getText(); 139 if (!TextUtils.isEmpty(translatedContentDescription)) { 140 mContentDescription = view.getContentDescription(); 141 view.setContentDescription(translatedContentDescription); 142 } 143 } 144 return true; 145 } 146 147 /** 148 * {@inheritDoc} 149 */ 150 @Override onHideTranslation(@onNull View view)151 public boolean onHideTranslation(@NonNull View view) { 152 if (view.getViewTranslationResponse() == null) { 153 Log.e(TAG, "onHideTranslation() shouldn't be called before " 154 + "onViewTranslationResponse()."); 155 return false; 156 } 157 // Restore to original text content. 158 if (mTranslationTransformation != null) { 159 final TransformationMethod transformation = 160 mTranslationTransformation.getOriginalTransformationMethod(); 161 TextView theTextView = (TextView) view; 162 WeakReference<TextView> textViewRef = new WeakReference<>(theTextView); 163 runChangeTextWithAnimationIfNeeded( 164 theTextView, 165 () -> { 166 mIsShowingTranslation = false; 167 mAnimationRunning = false; 168 169 TextView textView = textViewRef.get(); 170 if (textView == null) { 171 return; 172 } 173 // TODO(b/233406028): We should NOT restore the original 174 // TransformationMethod and selectable state if it was changed WHILE 175 // translation was being shown. 176 textView.setTransformationMethod(transformation); 177 178 if (mOriginalIsTextSelectable && !textView.isTextSelectable()) { 179 // According to documentation for `setTextIsSelectable()`, it sets the 180 // flags focusable, focusableInTouchMode, clickable, and longClickable 181 // to the same value, and you must call `setFocusable()`, etc. to 182 // restore all previous flag values. 183 textView.setTextIsSelectable(true); 184 textView.setFocusableInTouchMode(mOriginalFocusableInTouchMode); 185 textView.setFocusable(mOriginalFocusable); 186 textView.setClickable(mOriginalClickable); 187 textView.setLongClickable(mOriginalLongClickable); 188 } 189 }); 190 if (!TextUtils.isEmpty(mContentDescription)) { 191 view.setContentDescription(mContentDescription); 192 } 193 } else { 194 if (DEBUG) { 195 Log.w(TAG, "onHideTranslation(): no translated text."); 196 } 197 return false; 198 } 199 return true; 200 } 201 202 /** 203 * {@inheritDoc} 204 */ 205 @Override onClearTranslation(@onNull View view)206 public boolean onClearTranslation(@NonNull View view) { 207 // Restore to original text content and clear TranslationTransformation 208 if (mTranslationTransformation != null) { 209 onHideTranslation(view); 210 clearTranslationTransformation(); 211 mPaddedText = null; 212 mContentDescription = null; 213 } else { 214 if (DEBUG) { 215 Log.w(TAG, "onClearTranslation(): no translated text."); 216 } 217 return false; 218 } 219 return true; 220 } 221 isShowingTranslation()222 public boolean isShowingTranslation() { 223 return mIsShowingTranslation; 224 } 225 226 /** 227 * Returns whether the view is running animation to show or hide the translation. 228 */ isAnimationRunning()229 public boolean isAnimationRunning() { 230 return mAnimationRunning; 231 } 232 233 @Override enableContentPadding()234 public void enableContentPadding() { 235 mIsTextPaddingEnabled = true; 236 } 237 238 /** 239 * Returns whether readers of the view text should receive padded text for compatibility 240 * reasons. The view's original text will be padded to match the length of the translated text. 241 */ isTextPaddingEnabled()242 boolean isTextPaddingEnabled() { 243 return mIsTextPaddingEnabled; 244 } 245 246 /** 247 * Returns the view's original text with padding added. If the translated text isn't longer than 248 * the original text, returns the original text itself. 249 * 250 * @param text the view's original text 251 * @param translatedText the view's translated text 252 * @see #isTextPaddingEnabled() 253 */ 254 @Nullable getPaddedText(CharSequence text, CharSequence translatedText)255 CharSequence getPaddedText(CharSequence text, CharSequence translatedText) { 256 if (text == null) { 257 return null; 258 } 259 if (mPaddedText == null) { 260 mPaddedText = computePaddedText(text, translatedText); 261 } 262 return mPaddedText; 263 } 264 265 @NonNull computePaddedText(CharSequence text, CharSequence translatedText)266 private CharSequence computePaddedText(CharSequence text, CharSequence translatedText) { 267 if (translatedText == null) { 268 return text; 269 } 270 int newLength = translatedText.length(); 271 if (newLength <= text.length()) { 272 return text; 273 } 274 StringBuilder sb = new StringBuilder(newLength); 275 sb.append(text); 276 for (int i = text.length(); i < newLength; i++) { 277 sb.append(COMPAT_PAD_CHARACTER); 278 } 279 return sb; 280 } 281 282 private static final char COMPAT_PAD_CHARACTER = '\u2002'; 283 284 @Override setAnimationDurationMillis(int durationMillis)285 public void setAnimationDurationMillis(int durationMillis) { 286 mAnimationDurationMillis = durationMillis; 287 } 288 289 /** 290 * Applies a simple text alpha animation when toggling between original and translated text. The 291 * text is fully faded out, then swapped to the new text, then the fading is reversed. 292 * 293 * @param changeTextRunnable the operation to run on the view after the text is faded out, to 294 * change to displaying the original or translated text. 295 */ runChangeTextWithAnimationIfNeeded(TextView view, Runnable changeTextRunnable)296 private void runChangeTextWithAnimationIfNeeded(TextView view, Runnable changeTextRunnable) { 297 boolean areAnimatorsEnabled = ValueAnimator.areAnimatorsEnabled(); 298 if (!areAnimatorsEnabled) { 299 // The animation is disabled, just change display text 300 changeTextRunnable.run(); 301 return; 302 } 303 if (mAnimator != null) { 304 mAnimator.end(); 305 // Note: mAnimator is now null; do not use again here. 306 } 307 mAnimationRunning = true; 308 int fadedOutColor = colorWithAlpha(view.getCurrentTextColor(), 0); 309 mAnimator = ValueAnimator.ofArgb(view.getCurrentTextColor(), fadedOutColor); 310 mAnimator.addUpdateListener( 311 // Note that if the text has a ColorStateList, this replaces it with a single color 312 // for all states. The original ColorStateList is restored when the animation ends 313 // (see below). 314 (valueAnimator) -> view.setTextColor((Integer) valueAnimator.getAnimatedValue())); 315 mAnimator.setRepeatMode(ValueAnimator.REVERSE); 316 mAnimator.setRepeatCount(1); 317 mAnimator.setDuration(mAnimationDurationMillis); 318 final ColorStateList originalColors = view.getTextColors(); 319 WeakReference<TextView> viewRef = new WeakReference<>(view); 320 mAnimator.addListener(new Animator.AnimatorListener() { 321 @Override 322 public void onAnimationStart(Animator animation) { 323 } 324 325 @Override 326 public void onAnimationEnd(Animator animation) { 327 TextView view = viewRef.get(); 328 if (view != null) { 329 view.setTextColor(originalColors); 330 } 331 mAnimator = null; 332 } 333 334 @Override 335 public void onAnimationCancel(Animator animation) { 336 } 337 338 @Override 339 public void onAnimationRepeat(Animator animation) { 340 changeTextRunnable.run(); 341 } 342 }); 343 mAnimator.start(); 344 } 345 346 private ValueAnimator mAnimator; 347 348 /** 349 * Returns {@code color} with alpha changed to {@code newAlpha} 350 */ colorWithAlpha(int color, int newAlpha)351 private static int colorWithAlpha(int color, int newAlpha) { 352 return Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color)); 353 } 354 } 355