/*
* Copyright 2020 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 androidx.wear.watchface.complications.rendering;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.text.LineBreaker;
import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.text.style.LocaleSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.SubscriptSpan;
import android.text.style.SuperscriptSpan;
import android.text.style.TypefaceSpan;
import android.text.style.UnderlineSpan;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
/**
* Renders text onto a canvas.
*
* <p>Watch faces can use TextRenderer to render text on a canvas within specified bounds. This is
* intended for use when rendering complications. The layout is only recalculated if the text, the
* bounds, or the drawing properties change, allowing this to be called efficiently on every frame.
*
* <p>TextRenderer also ensures that a minimum number of characters are always displayed, by
* shrinking the text size if necessary to make the characters fit. The default is to always show at
* least 7 characters, which matches the requirement for the 'short text' fields of complications.
*
* <p>To use, instantiate a TextRenderer object and set the {@link TextPaint} that should be used.
* Only instantiate and set the paint once on each text renderer - you should not do this within the
* draw method. Each distinct piece of text should have its own TextRenderer - so for example you
* might need one for the 'short text' field, and one for the 'short title' field:
*
* <pre>
* mTitleRenderer = new TextRenderer();
* mTitleRenderer.setPaint(mTitlePaint);
* mTextRenderer = new TextRenderer();
* mTextRenderer.setPaint(mTextPaint);</pre>
*
* <p>When drawing a frame, set the current text value on the text renderer (using the current time
* so that any time-dependent text has an up-to-date value), and then call draw on the renderer with
* the bounds you need:
*
* <pre>
* // Set the text every time you draw, even if the ComplicationData has not changed, in case
* // the text includes a time-dependent value.
* mTitleRenderer.setText(
* ComplicationText.getText(context, complicationData.getShortTitle(), currentTimeMillis));
* mTextRenderer.setText(
* ComplicationText.getText(context, complicationData.getShortText(), currentTimeMillis));
*
* // Assuming both the title and text exist.
* mTitleRenderer.draw(canvas, titleBounds);
* mTextRenderer.draw(canvas, textBounds);</pre>
*
* <p>The layout of the text may be customised by calling methods such as {@link #setAlignment},
* {@link #setGravity}, and {@link #setRelativePadding}. These methods may be called on every frame
* - the layout will only be recalculated if necessary.
*
* <p>If the properties of the TextPaint or content of the text have changed, then the TextRenderer
* may not be aware that the layout needs to be updated. In this case a layout update should be
* forced by calling {@link #requestUpdateLayout()}.
*
* <p>TextRenderer also hides characters and styling that may not be suitable for display in ambient
* mode - for example full color emoji.
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
class TextRenderer {
private static final String TAG = "TextRenderer";
private static final int TEXT_SIZE_STEP_SIZE = 1;
private static final int DEFAULT_MINIMUM_CHARACTERS_SHOWN = 7;
private static final int SPACE_CHARACTER = 0x20;
private static final Class<?>[] SPAN_ALLOW_LIST =
new Class<?>[]{
ForegroundColorSpan.class,
LocaleSpan.class,
SubscriptSpan.class,
SuperscriptSpan.class,
StrikethroughSpan.class,
StyleSpan.class,
TypefaceSpan.class,
UnderlineSpan.class
};
private final Rect mBounds = new Rect();
private TextPaint mPaint;
@Nullable private String mAmbientModeText;
@Nullable private CharSequence mOriginalText;
@Nullable private CharSequence mText;
private float mRelativePaddingStart;
private float mRelativePaddingEnd;
private float mRelativePaddingTop;
private float mRelativePaddingBottom;
private StaticLayout mStaticLayout;
private int mGravity = Gravity.CENTER;
private int mMaxLines = 1;
private int mMinCharactersShown = DEFAULT_MINIMUM_CHARACTERS_SHOWN;
private TextUtils.TruncateAt mEllipsize = TextUtils.TruncateAt.END;
private Layout.Alignment mAlignment = Layout.Alignment.ALIGN_CENTER;
private final Rect mWorkingRect = new Rect();
private final Rect mOutputRect = new Rect();
private boolean mInAmbientMode = false;
private boolean mNeedUpdateLayout;
private boolean mNeedCalculateBounds;
/**
* Draws the text onto the {@code canvas} within the specified {@code bounds}.
*
* <p>This will only lay the text out again if the width of the bounds has changed or if the
* parameters have changed. If the properties of the paint have changed, and {@link #setPaint}
* has not been called, {@link #requestUpdateLayout} must be called.
*
* @param canvas the canvas that the text will be drawn onto
* @param bounds boundaries of the text within specified canvas
*/
public void draw(@NonNull Canvas canvas, @NonNull Rect bounds) {
if (TextUtils.isEmpty(mText)) {
return;
}
if (mNeedUpdateLayout || !mBounds.equals(bounds)) {
updateLayout(bounds.width(), bounds.height());
mNeedUpdateLayout = false;
mNeedCalculateBounds = true;
}
if (mNeedCalculateBounds || !mBounds.equals(bounds)) {
mBounds.set(bounds);
calculateBounds();
mNeedCalculateBounds = false;
}
canvas.save();
canvas.translate(mOutputRect.left, mOutputRect.top);
mStaticLayout.draw(canvas);
canvas.restore();
}
/**
* Requests that the text be laid out again when draw is next called. This must be called if the
* properties of the paint or the content of the text have changed but {@link #setPaint} has not
* been called.
*/
public void requestUpdateLayout() {
mNeedUpdateLayout = true;
}
/**
* Sets the text that will be drawn by this renderer.
*
* <p>If the text contains spans, some of them may not be rendered by this class. Supported
* spans are {@link ForegroundColorSpan}, {@link LocaleSpan}, {@link SubscriptSpan}, {@link
* SuperscriptSpan}, {@link StrikethroughSpan}, {@link TypefaceSpan} and {@link UnderlineSpan}.
*/
public void setText(@Nullable CharSequence text) {
if (mOriginalText == text) {
return;
}
mOriginalText = text;
mText = applySpanAllowlist(mOriginalText);
mNeedUpdateLayout = true;
}
@NonNull
@VisibleForTesting
CharSequence applySpanAllowlist(@NonNull CharSequence text) {
if (text instanceof Spanned) {
SpannableStringBuilder builder = new SpannableStringBuilder(text);
Object[] spans = builder.getSpans(0, text.length(), Object.class);
for (Object span : spans) {
if (!isSpanAllowed(span)) {
builder.removeSpan(span);
Log.w(TAG,
"Removing unsupported span of type " + span.getClass().getSimpleName());
}
}
return builder;
} else {
return text;
}
}
private boolean isSpanAllowed(@NonNull Object span) {
for (Class<?> spanClass : SPAN_ALLOW_LIST) {
if (spanClass.isInstance(span)) {
return true;
}
}
return false;
}
/** Sets the paint that will be used to draw the text. */
public void setPaint(@NonNull TextPaint paint) {
mPaint = paint;
mNeedUpdateLayout = true;
}
/**
* Sets the padding which will be applied to the bounds before the text is drawn. The {@code
* start} and {@code end} parameters should be given as a proportion of the width of the bounds,
* and the {@code top} and {@code bottom} parameters should be given as a proportion of the
* height of the bounds.
*/
public void setRelativePadding(float start, float top, float end, float bottom) {
if (mRelativePaddingStart == start
&& mRelativePaddingTop == top
&& mRelativePaddingEnd == end
&& mRelativePaddingBottom == bottom) {
return;
}
mRelativePaddingStart = start;
mRelativePaddingTop = top;
mRelativePaddingEnd = end;
mRelativePaddingBottom = bottom;
mNeedUpdateLayout = true;
}
/**
* Sets the gravity that will be used to position the text within the bounds.
*
* <p>Note that the text should be considered to fill the available width, which means that this
* cannot be used to position the text horizontally. Use {@link #setAlignment} instead for
* horizontal position.
*
* <p>If not called, the default is {@link Gravity#CENTER}.
*
* @param gravity Gravity to position text, should be one of the constants specified in {@link
* android.view.Gravity} class.
*/
public void setGravity(int gravity) {
if (mGravity == gravity) {
return;
}
mGravity = gravity;
mNeedCalculateBounds = true;
}
/**
* Sets the maximum number of lines of text that will be drawn. The default is 1 and if a
* non-positive number is given, it will be ignored.
*/
public void setMaxLines(int maxLines) {
if (mMaxLines == maxLines || maxLines <= 0) {
return;
}
mMaxLines = maxLines;
mNeedUpdateLayout = true;
}
/**
* Sets the minimum number of characters to be shown. If available space is too narrow, font
* size may be decreased to ensure showing at least this many characters. The default is 7 which
* is the number of characters that should always be displayed without truncation when rendering
* the short text or short title fields of a complication.
*/
public void setMinimumCharactersShown(int minCharactersShown) {
if (mMinCharactersShown == minCharactersShown) {
return;
}
mMinCharactersShown = minCharactersShown;
mNeedUpdateLayout = true;
}
/**
* Sets how the text should be ellipsized if it does not fit in the specified number of lines.
* The default is {@link TextUtils.TruncateAt#END}.
*
* <p>Pass in {@code null} to cause text to be truncated but not ellipsized.
*/
public void setEllipsize(@Nullable TextUtils.TruncateAt ellipsize) {
if (mEllipsize == ellipsize) {
return;
}
mEllipsize = ellipsize;
mNeedUpdateLayout = true;
}
/** Sets the alignment of the text. */
public void setAlignment(@Nullable Layout.Alignment alignment) {
if (mAlignment == alignment) {
return;
}
mAlignment = alignment;
mNeedUpdateLayout = true;
}
/** Returns true if text has been set on this renderer. */
public boolean hasText() {
return !TextUtils.isEmpty(mText);
}
/** Returns true if the text will be drawn by this renderer in a left-to-right direction. */
public boolean isLtr() {
return mStaticLayout.getParagraphDirection(0) == Layout.DIR_LEFT_TO_RIGHT;
}
/**
* Sets if the watch face is in ambient mode. Watch faces should call this function while going
* in and out of ambient mode. This allows TextRenderer to remove special characters and styling
* that may not be suitable for displaying in ambient mode.
*/
public void setInAmbientMode(boolean inAmbientMode) {
if (mInAmbientMode == inAmbientMode) {
return;
}
mInAmbientMode = inAmbientMode;
if (!TextUtils.equals(mAmbientModeText, mText)) {
mNeedUpdateLayout = true;
}
}
private void updateLayout(int width, int height) {
if (mPaint == null) {
setPaint(new TextPaint());
}
int availableWidth = (int) (width * (1 - mRelativePaddingStart - mRelativePaddingEnd));
TextPaint paint = new TextPaint(mPaint);
// Reduce text size to prevent vertical overflow
paint.setTextSize(Math.min(height / mMaxLines, paint.getTextSize()));
// Check if current text fits
float textWidth = paint.measureText(mText, 0, mText.length());
if (textWidth > availableWidth) {
// Decrease text size until first mMinCharactersShown characters fit
int charactersShown = mMinCharactersShown;
// Add one character if ellipsizing is enabled and it's not marquee
if (mEllipsize != null && mEllipsize != TextUtils.TruncateAt.MARQUEE) {
charactersShown = charactersShown + 1;
}
// Text may be shorter than character count to be shown
charactersShown = Math.min(charactersShown, mText.length());
CharSequence textToFit = mText.subSequence(0, charactersShown);
textWidth = paint.measureText(textToFit, 0, textToFit.length());
while (textWidth > availableWidth) {
paint.setTextSize(paint.getTextSize() - TEXT_SIZE_STEP_SIZE);
textWidth = paint.measureText(textToFit, 0, textToFit.length());
}
}
CharSequence text = mText;
if (mInAmbientMode) {
mAmbientModeText = EmojiHelper.replaceEmoji(mText, SPACE_CHARACTER);
text = mAmbientModeText;
}
StaticLayout.Builder builder =
StaticLayout.Builder.obtain(text, 0, text.length(), paint, availableWidth);
builder.setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY);
builder.setEllipsize(mEllipsize);
builder.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL);
builder.setMaxLines(mMaxLines);
builder.setAlignment(mAlignment);
mStaticLayout = builder.build();
}
private void calculateBounds() {
int layoutDirection = isLtr() ? View.LAYOUT_DIRECTION_LTR : View.LAYOUT_DIRECTION_RTL;
int leftPadding =
(int) (mBounds.width() * (isLtr() ? mRelativePaddingStart : mRelativePaddingEnd));
int rightPadding =
(int) (mBounds.width() * (isLtr() ? mRelativePaddingEnd : mRelativePaddingStart));
int topPadding = (int) (mBounds.height() * mRelativePaddingTop);
int bottomPadding = (int) (mBounds.height() * mRelativePaddingBottom);
mWorkingRect.set(
mBounds.left + leftPadding,
mBounds.top + topPadding,
mBounds.right - rightPadding,
mBounds.bottom - bottomPadding);
Gravity.apply(
mGravity,
mStaticLayout.getWidth(),
mStaticLayout.getHeight(),
mWorkingRect,
mOutputRect,
layoutDirection);
}
}