| /* |
| * 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; |
| |
| import java.lang.ref.WeakReference; |
| |
| /** |
| * 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 mAnimationRunning = false; |
| private boolean mIsTextPaddingEnabled = false; |
| private boolean mOriginalIsTextSelectable = false; |
| private int mOriginalFocusable = 0; |
| private boolean mOriginalFocusableInTouchMode = false; |
| private boolean mOriginalClickable = false; |
| private boolean mOriginalLongClickable = 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) { |
| if (mIsShowingTranslation) { |
| if (DEBUG) { |
| Log.d(TAG, view + " is already showing translated text."); |
| } |
| return false; |
| } |
| ViewTranslationResponse response = view.getViewTranslationResponse(); |
| if (response == null) { |
| Log.e(TAG, "onShowTranslation() shouldn't be called before " |
| + "onViewTranslationResponse()."); |
| return false; |
| } |
| // It is possible user changes text and new translation response returns, system should |
| // update the translation response to keep the result up to date. |
| // Because TextView.setTransformationMethod() will skip the same TransformationMethod |
| // instance, we should create a new one to let new translation can work. |
| TextView theTextView = (TextView) view; |
| if (mTranslationTransformation == null |
| || !response.equals(mTranslationTransformation.getViewTranslationResponse())) { |
| TransformationMethod originalTranslationMethod = |
| theTextView.getTransformationMethod(); |
| mTranslationTransformation = new TranslationTransformationMethod(response, |
| originalTranslationMethod); |
| } |
| final TransformationMethod transformation = mTranslationTransformation; |
| WeakReference<TextView> textViewRef = new WeakReference<>(theTextView); |
| runChangeTextWithAnimationIfNeeded( |
| theTextView, |
| () -> { |
| mIsShowingTranslation = true; |
| mAnimationRunning = false; |
| |
| TextView textView = textViewRef.get(); |
| if (textView == null) { |
| return; |
| } |
| // TODO(b/177214256): support selectable text translation. |
| // We use the TransformationMethod to implement showing the translated text. The |
| // TextView does not support the text length change for TransformationMethod. |
| // If the text is selectable or editable, it will crash while selecting the |
| // text. To support being able to select translated text, we need broader |
| // changes to text APIs. For now, the callback makes the text non-selectable |
| // while translated, and makes it selectable again after translation. |
| mOriginalIsTextSelectable = textView.isTextSelectable(); |
| if (mOriginalIsTextSelectable) { |
| // According to documentation for `setTextIsSelectable()`, it sets the |
| // flags focusable, focusableInTouchMode, clickable, and longClickable |
| // to the same value. We get the original values to restore when translation |
| // is hidden. |
| mOriginalFocusableInTouchMode = textView.isFocusableInTouchMode(); |
| mOriginalFocusable = textView.getFocusable(); |
| mOriginalClickable = textView.isClickable(); |
| mOriginalLongClickable = textView.isLongClickable(); |
| textView.setTextIsSelectable(false); |
| } |
| |
| // TODO(b/233406028): We should NOT restore the original |
| // TransformationMethod and selectable state if it was changed WHILE |
| // translation was being shown. |
| textView.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(); |
| TextView theTextView = (TextView) view; |
| WeakReference<TextView> textViewRef = new WeakReference<>(theTextView); |
| runChangeTextWithAnimationIfNeeded( |
| theTextView, |
| () -> { |
| mIsShowingTranslation = false; |
| mAnimationRunning = false; |
| |
| TextView textView = textViewRef.get(); |
| if (textView == null) { |
| return; |
| } |
| // TODO(b/233406028): We should NOT restore the original |
| // TransformationMethod and selectable state if it was changed WHILE |
| // translation was being shown. |
| textView.setTransformationMethod(transformation); |
| |
| if (mOriginalIsTextSelectable && !textView.isTextSelectable()) { |
| // According to documentation for `setTextIsSelectable()`, it sets the |
| // flags focusable, focusableInTouchMode, clickable, and longClickable |
| // to the same value, and you must call `setFocusable()`, etc. to |
| // restore all previous flag values. |
| textView.setTextIsSelectable(true); |
| textView.setFocusableInTouchMode(mOriginalFocusableInTouchMode); |
| textView.setFocusable(mOriginalFocusable); |
| textView.setClickable(mOriginalClickable); |
| textView.setLongClickable(mOriginalLongClickable); |
| } |
| }); |
| 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; |
| } |
| |
| public boolean isShowingTranslation() { |
| return mIsShowingTranslation; |
| } |
| |
| /** |
| * Returns whether the view is running animation to show or hide the translation. |
| */ |
| public boolean isAnimationRunning() { |
| return mAnimationRunning; |
| } |
| |
| @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 changeTextRunnable the operation to run on the view after the text is faded out, to |
| * change to displaying the original or translated text. |
| */ |
| private void runChangeTextWithAnimationIfNeeded(TextView view, Runnable changeTextRunnable) { |
| boolean areAnimatorsEnabled = ValueAnimator.areAnimatorsEnabled(); |
| if (!areAnimatorsEnabled) { |
| // The animation is disabled, just change display text |
| changeTextRunnable.run(); |
| return; |
| } |
| if (mAnimator != null) { |
| mAnimator.end(); |
| // Note: mAnimator is now null; do not use again here. |
| } |
| mAnimationRunning = true; |
| 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(); |
| WeakReference<TextView> viewRef = new WeakReference<>(view); |
| mAnimator.addListener(new Animator.AnimatorListener() { |
| @Override |
| public void onAnimationStart(Animator animation) { |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| TextView view = viewRef.get(); |
| if (view != null) { |
| view.setTextColor(originalColors); |
| } |
| mAnimator = null; |
| } |
| |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| } |
| |
| @Override |
| public void onAnimationRepeat(Animator animation) { |
| changeTextRunnable.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)); |
| } |
| } |