From 6758b3f7e768da925427deec4c60c0d5509fde55 Mon Sep 17 00:00:00 2001 From: Mark Punzalan Date: Thu, 19 May 2022 09:26:51 +0000 Subject: [PATCH] Translate selectable TextViews by temporarily disabling selectable. The text is not selectable while translated. Making translated text selectable requires many more changes. This was tested in Nextdoor in the feed/posts activity. When there is a "Read more" link in the post, the link doesn't work while translated, and doesn't behave the same way after translation is paused. Bug: 202966891 Test: atest CtsTranslationTestCases Test: Manually - with Nextdoor on feed and chat activities Change-Id: I6e8f532d427d85ff22df0deb248d8416a15f4821 --- .../translation/ViewTranslationCallback.java | 9 +++ core/java/android/widget/TextView.java | 23 ++---- .../widget/TextViewTranslationCallback.java | 76 +++++++++++++++++-- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/core/java/android/view/translation/ViewTranslationCallback.java b/core/java/android/view/translation/ViewTranslationCallback.java index 3936b639592df..a95d95fe20a2a 100644 --- a/core/java/android/view/translation/ViewTranslationCallback.java +++ b/core/java/android/view/translation/ViewTranslationCallback.java @@ -52,6 +52,15 @@ public interface ViewTranslationCallback { * the original text instead of the translated text or use a different approach to display the * translated text. * + *

NOTE: In Android version {@link android.os.Build.VERSION_CODES#TIRAMISU} and later, + * the implementation must be able to handle a selectable {@link android.widget.TextView} + * (i.e., {@link android.widget.TextView#isTextSelectable()} returns {@code true}. The default + * callback implementation for TextView uses a {@link android.text.method.TransformationMethod} + * to show the translated text, which will cause a crash when the translated text is selected. + * Therefore, the default callback temporarily makes the TextView non-selectable while the + * translation text is shown. This is one approach for handling selectable TextViews a + * TransformationMethod is used. + * * See {@link View#onViewTranslationResponse} for how to get the translated information. * * @return {@code true} if the View handles showing the translation. diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java index 93f72640bd3fa..e745b8cf769b1 100644 --- a/core/java/android/widget/TextView.java +++ b/core/java/android/widget/TextView.java @@ -14240,13 +14240,13 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener * Collects a {@link ViewTranslationRequest} which represents the content to be translated in * the view. * - *

NOTE: When overriding the method, it should not translate the password. If the subclass - * uses {@link TransformationMethod} to display the translated result, it's also not recommend - * to translate text is selectable or editable. + *

NOTE: When overriding the method, it should not collect a request to translate this + * TextView if it is displaying a password. * * @param supportedFormats the supported translation format. The value could be {@link * android.view.translation.TranslationSpec#DATA_FORMAT_TEXT}. - * @return the {@link ViewTranslationRequest} which contains the information to be translated. + * @param requestsCollector {@link Consumer} to receiver the {@link ViewTranslationRequest} + * which contains the information to be translated. */ @Override public void onCreateViewTranslationRequest(@NonNull int[] supportedFormats, @@ -14268,18 +14268,9 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener return; } boolean isPassword = isAnyPasswordInputType() || hasPasswordTransformationMethod(); - // 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 - // it, it needs broader changes to text APIs, we only allow to translate non selectable - // and editable text in S. - if (isTextEditable() || isPassword || isTextSelectable()) { - if (UiTranslationController.DEBUG) { - Log.w(LOG_TAG, "Cannot create translation request. editable = " - + isTextEditable() + ", isPassword = " + isPassword + ", selectable = " - + isTextSelectable()); - } + if (isTextEditable() || isPassword) { + Log.w(LOG_TAG, "Cannot create translation request. editable = " + + isTextEditable() + ", isPassword = " + isPassword); return; } // TODO(b/176488462): apply the view's important for translation diff --git a/core/java/android/widget/TextViewTranslationCallback.java b/core/java/android/widget/TextViewTranslationCallback.java index 1713d842560b0..5ad45790dae85 100644 --- a/core/java/android/widget/TextViewTranslationCallback.java +++ b/core/java/android/widget/TextViewTranslationCallback.java @@ -32,6 +32,8 @@ 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}. @@ -48,6 +50,11 @@ public class TextViewTranslationCallback implements ViewTranslationCallback { 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 @@ -81,21 +88,50 @@ public class TextViewTranslationCallback implements ViewTranslationCallback { // 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 = - ((TextView) view).getTransformationMethod(); + theTextView.getTransformationMethod(); mTranslationTransformation = new TranslationTransformationMethod(response, originalTranslationMethod); } final TransformationMethod transformation = mTranslationTransformation; + WeakReference textViewRef = new WeakReference<>(theTextView); runChangeTextWithAnimationIfNeeded( - (TextView) view, + theTextView, () -> { mIsShowingTranslation = true; mAnimationRunning = false; - // TODO(b/178353965): well-handle setTransformationMethod. - ((TextView) view).setTransformationMethod(transformation); + + 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 = @@ -122,12 +158,34 @@ public class TextViewTranslationCallback implements ViewTranslationCallback { if (mTranslationTransformation != null) { final TransformationMethod transformation = mTranslationTransformation.getOriginalTransformationMethod(); + TextView theTextView = (TextView) view; + WeakReference textViewRef = new WeakReference<>(theTextView); runChangeTextWithAnimationIfNeeded( - (TextView) view, + theTextView, () -> { mIsShowingTranslation = false; mAnimationRunning = false; - ((TextView) view).setTransformationMethod(transformation); + + 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); @@ -258,6 +316,7 @@ public class TextViewTranslationCallback implements ViewTranslationCallback { mAnimator.setRepeatCount(1); mAnimator.setDuration(mAnimationDurationMillis); final ColorStateList originalColors = view.getTextColors(); + WeakReference viewRef = new WeakReference<>(view); mAnimator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { @@ -265,7 +324,10 @@ public class TextViewTranslationCallback implements ViewTranslationCallback { @Override public void onAnimationEnd(Animator animation) { - view.setTextColor(originalColors); + TextView view = viewRef.get(); + if (view != null) { + view.setTextColor(originalColors); + } mAnimator = null; }