355 lines
14 KiB
Java
355 lines
14 KiB
Java
/*
|
|
* 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));
|
|
}
|
|
}
|