/* * Copyright (C) 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.widget; import android.animation.Animator; import android.animation.ValueAnimator; import android.annotation.NonNull; import android.annotation.Nullable; import android.content.res.ColorStateList; import android.graphics.Color; import android.text.TextUtils; import android.text.method.TransformationMethod; import android.text.method.TranslationTransformationMethod; import android.util.Log; import android.view.View; import android.view.translation.UiTranslationManager; import android.view.translation.ViewTranslationCallback; import android.view.translation.ViewTranslationRequest; import android.view.translation.ViewTranslationResponse; /** * Default implementation for {@link ViewTranslationCallback} for {@link TextView} components. * This class handles how to display the translated information for {@link TextView}. * * @hide */ public class TextViewTranslationCallback implements ViewTranslationCallback { private static final String TAG = "TextViewTranslationCb"; private static final boolean DEBUG = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG); private TranslationTransformationMethod mTranslationTransformation; private boolean mIsShowingTranslation = false; private boolean mIsTextPaddingEnabled = false; private CharSequence mPaddedText; private int mAnimationDurationMillis = 250; // default value private CharSequence mContentDescription; private void clearTranslationTransformation() { if (DEBUG) { Log.v(TAG, "clearTranslationTransformation: " + mTranslationTransformation); } mTranslationTransformation = null; } /** * {@inheritDoc} */ @Override public boolean onShowTranslation(@NonNull View view) { ViewTranslationResponse response = view.getViewTranslationResponse(); if (response == null) { Log.e(TAG, "onShowTranslation() shouldn't be called before " + "onViewTranslationResponse()."); return false; } if (mTranslationTransformation == null) { TransformationMethod originalTranslationMethod = ((TextView) view).getTransformationMethod(); mTranslationTransformation = new TranslationTransformationMethod(response, originalTranslationMethod); } final TransformationMethod transformation = mTranslationTransformation; runWithAnimation( (TextView) view, () -> { mIsShowingTranslation = true; // TODO(b/178353965): well-handle setTransformationMethod. ((TextView) view).setTransformationMethod(transformation); }); if (response.getKeys().contains(ViewTranslationRequest.ID_CONTENT_DESCRIPTION)) { CharSequence translatedContentDescription = response.getValue(ViewTranslationRequest.ID_CONTENT_DESCRIPTION).getText(); if (!TextUtils.isEmpty(translatedContentDescription)) { mContentDescription = view.getContentDescription(); view.setContentDescription(translatedContentDescription); } } return true; } /** * {@inheritDoc} */ @Override public boolean onHideTranslation(@NonNull View view) { if (view.getViewTranslationResponse() == null) { Log.e(TAG, "onHideTranslation() shouldn't be called before " + "onViewTranslationResponse()."); return false; } // Restore to original text content. if (mTranslationTransformation != null) { final TransformationMethod transformation = mTranslationTransformation.getOriginalTransformationMethod(); runWithAnimation( (TextView) view, () -> { mIsShowingTranslation = false; ((TextView) view).setTransformationMethod(transformation); }); if (!TextUtils.isEmpty(mContentDescription)) { view.setContentDescription(mContentDescription); } } else { if (DEBUG) { Log.w(TAG, "onHideTranslation(): no translated text."); } return false; } return true; } /** * {@inheritDoc} */ @Override public boolean onClearTranslation(@NonNull View view) { // Restore to original text content and clear TranslationTransformation if (mTranslationTransformation != null) { onHideTranslation(view); clearTranslationTransformation(); mPaddedText = null; mContentDescription = null; } else { if (DEBUG) { Log.w(TAG, "onClearTranslation(): no translated text."); } return false; } return true; } boolean isShowingTranslation() { return mIsShowingTranslation; } @Override public void enableContentPadding() { mIsTextPaddingEnabled = true; } /** * Returns whether readers of the view text should receive padded text for compatibility * reasons. The view's original text will be padded to match the length of the translated text. */ boolean isTextPaddingEnabled() { return mIsTextPaddingEnabled; } /** * Returns the view's original text with padding added. If the translated text isn't longer than * the original text, returns the original text itself. * * @param text the view's original text * @param translatedText the view's translated text * @see #isTextPaddingEnabled() */ @Nullable CharSequence getPaddedText(CharSequence text, CharSequence translatedText) { if (text == null) { return null; } if (mPaddedText == null) { mPaddedText = computePaddedText(text, translatedText); } return mPaddedText; } @NonNull private CharSequence computePaddedText(CharSequence text, CharSequence translatedText) { if (translatedText == null) { return text; } int newLength = translatedText.length(); if (newLength <= text.length()) { return text; } StringBuilder sb = new StringBuilder(newLength); sb.append(text); for (int i = text.length(); i < newLength; i++) { sb.append(COMPAT_PAD_CHARACTER); } return sb; } private static final char COMPAT_PAD_CHARACTER = '\u2002'; @Override public void setAnimationDurationMillis(int durationMillis) { mAnimationDurationMillis = durationMillis; } /** * Applies a simple text alpha animation when toggling between original and translated text. The * text is fully faded out, then swapped to the new text, then the fading is reversed. * * @param runnable the operation to run on the view after the text is faded out, to change to * displaying the original or translated text. */ private void runWithAnimation(TextView view, Runnable runnable) { if (mAnimator != null) { mAnimator.end(); // Note: mAnimator is now null; do not use again here. } int fadedOutColor = colorWithAlpha(view.getCurrentTextColor(), 0); mAnimator = ValueAnimator.ofArgb(view.getCurrentTextColor(), fadedOutColor); mAnimator.addUpdateListener( // Note that if the text has a ColorStateList, this replaces it with a single color // for all states. The original ColorStateList is restored when the animation ends // (see below). (valueAnimator) -> view.setTextColor((Integer) valueAnimator.getAnimatedValue())); mAnimator.setRepeatMode(ValueAnimator.REVERSE); mAnimator.setRepeatCount(1); mAnimator.setDuration(mAnimationDurationMillis); final ColorStateList originalColors = view.getTextColors(); mAnimator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { view.setTextColor(originalColors); mAnimator = null; } @Override public void onAnimationCancel(Animator animation) { } @Override public void onAnimationRepeat(Animator animation) { runnable.run(); } }); mAnimator.start(); } private ValueAnimator mAnimator; /** * Returns {@code color} with alpha changed to {@code newAlpha} */ private static int colorWithAlpha(int color, int newAlpha) { return Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color)); } }