/*
* Copyright 2016 Google Inc.
*
* 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 io.plaidapp.ui.transitions;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.content.Context;
import android.content.Intent;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.ColorInt;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Layout;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.transition.Transition;
import android.transition.TransitionValues;
import android.util.AttributeSet;
import android.util.Property;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.LinearInterpolator;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import io.plaidapp.R;
import io.plaidapp.util.FontUtil;
/**
* A transition for repositioning text. This will animate changes in text size and position,
* re-flowing line breaks as necessary.
* <p>
* Strongly recommended to use a curved {@code pathMotion} for a more natural transition.
*/
public class ReflowText extends Transition {
private static final String EXTRA_REFLOW_DATA = "EXTRA_REFLOW_DATA";
private static final String PROPNAME_DATA = "plaid:reflowtext:data";
private static final String PROPNAME_TEXT_SIZE = "plaid:reflowtext:textsize";
private static final String PROPNAME_BOUNDS = "plaid:reflowtext:bounds";
private static final String[] PROPERTIES = { PROPNAME_TEXT_SIZE, PROPNAME_BOUNDS };
private static final int TRANSPARENT = 0;
private static final int OPAQUE = 255;
private static final int OPACITY_MID_TRANSITION = (int) (0.8f * OPAQUE);
private static final float STAGGER_DECAY = 0.8f;
private int velocity = 700; // pixels per second
private long minDuration = 200; // ms
private long maxDuration = 400; // ms
private long staggerDelay = 40; // ms
private long duration;
// this is hack for preventing view from drawing briefly at the end of the transition :(
private final boolean freezeFrame;
public ReflowText(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ReflowText);
velocity = a.getDimensionPixelSize(R.styleable.ReflowText_velocity, velocity);
minDuration = a.getInt(R.styleable.ReflowText_minDuration, (int) minDuration);
maxDuration = a.getInt(R.styleable.ReflowText_maxDuration, (int) maxDuration);
staggerDelay = a.getInt(R.styleable.ReflowText_staggerDelay, (int) staggerDelay);
freezeFrame = a.getBoolean(R.styleable.ReflowText_freezeFrame, false);
a.recycle();
}
/**
* Store data about the view which will participate in a reflow transition in {@code intent}.
*/
public static void addExtras(@NonNull Intent intent, @NonNull Reflowable reflowableView) {
intent.putExtra(EXTRA_REFLOW_DATA, new ReflowData(reflowableView));
}
/**
* Retrieve data about the reflow from {@code intent} and store it for later use.
*/
public static void setupReflow(@NonNull Intent intent, @NonNull View view) {
view.setTag(R.id.tag_reflow_data, intent.getParcelableExtra(EXTRA_REFLOW_DATA));
}
/**
* Create data about the reflow from {@code reflowableView} and store it for later use.
*/
public static void setupReflow(@NonNull Reflowable reflowableView) {
reflowableView.getView().setTag(R.id.tag_reflow_data, new ReflowData(reflowableView));
}
@Override
public void captureStartValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
@Override
public void captureEndValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
@Override
public String[] getTransitionProperties() {
return PROPERTIES;
}
@Override
public Animator createAnimator(
ViewGroup sceneRoot,
TransitionValues startValues,
TransitionValues endValues) {
if (startValues == null || endValues == null) return null;
final View view = endValues.view;
AnimatorSet transition = new AnimatorSet();
ReflowData startData = (ReflowData) startValues.values.get(PROPNAME_DATA);
ReflowData endData = (ReflowData) endValues.values.get(PROPNAME_DATA);
duration = calculateDuration(startData.bounds, endData.bounds);
// create layouts & capture a bitmaps of the text in both states
// (with max lines variants where needed)
Layout startLayout = createLayout(startData, sceneRoot.getContext(), false);
Layout endLayout = createLayout(endData, sceneRoot.getContext(), false);
Layout startLayoutMaxLines = null;
Layout endLayoutMaxLines = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // StaticLayout maxLines support
if (startData.maxLines != -1) {
startLayoutMaxLines = createLayout(startData, sceneRoot.getContext(), true);
}
if (endData.maxLines != -1) {
endLayoutMaxLines = createLayout(endData, sceneRoot.getContext(), true);
}
}
final Bitmap startText = createBitmap(startData,
startLayoutMaxLines != null ? startLayoutMaxLines : startLayout);
final Bitmap endText = createBitmap(endData,
endLayoutMaxLines != null ? endLayoutMaxLines : endLayout);
// temporarily turn off clipping so we can draw outside of our bounds don't draw
view.setWillNotDraw(true);
((ViewGroup) view.getParent()).setClipChildren(false);
// calculate the runs of text to move together
List<Run> runs = getRuns(startData, startLayout, startLayoutMaxLines,
endData, endLayout, endLayoutMaxLines);
// create animators for moving, scaling and fading each run of text
transition.playTogether(
createRunAnimators(view, startData, endData, startText, endText, runs));
if (!freezeFrame) {
transition.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// clean up
view.setWillNotDraw(false);
view.getOverlay().clear();
((ViewGroup) view.getParent()).setClipChildren(true);
startText.recycle();
endText.recycle();
}
});
}
return transition;
}
@Override
public Transition setDuration(long duration) {
/* don't call super as we want to handle duration ourselves */
return this;
}
private void captureValues(TransitionValues transitionValues) {
ReflowData reflowData = getReflowData(transitionValues.view);
transitionValues.values.put(PROPNAME_DATA, reflowData);
if (reflowData != null) {
// add these props to the map separately (even though they are captured in the reflow
// data) to use only them to determine whether to create an animation i.e. only
// animate if text size or bounds have changed (see #getTransitionProperties())
transitionValues.values.put(PROPNAME_TEXT_SIZE, reflowData.textSize);
transitionValues.values.put(PROPNAME_BOUNDS, reflowData.bounds);
}
}
private ReflowData getReflowData(@NonNull View view) {
ReflowData reflowData = (ReflowData) view.getTag(R.id.tag_reflow_data);
if (reflowData != null) {
view.setTag(R.id.tag_reflow_data, null);
return reflowData;
}
return null;
}
/**
* Calculate the {@linkplain Run}s i.e. diff the start and end states, see where text changes
* line and track the bounds of sections of text that can move together.
* <p>
* If a text block has a max number of lines, consider both with and without this limit applied.
* This allows simulating the correct line breaking as well as calculating the position that
* overflowing text would have been laid out, so that it can animate from/to that position.
*/
private List<Run> getRuns(@NonNull ReflowData startData, @NonNull Layout startLayout,
@Nullable Layout startLayoutMaxLines, @NonNull ReflowData endData,
@NonNull Layout endLayout, @Nullable Layout endLayoutMaxLines) {
int textLength = endLayout.getText().length();
int currentStartLine = 0;
int currentStartRunLeft = 0;
int currentStartRunTop = 0;
int currentEndLine = 0;
int currentEndRunLeft = 0;
int currentEndRunTop = 0;
List<Run> runs = new ArrayList<>(endLayout.getLineCount());
for (int i = 0; i < textLength; i++) {
// work out which line this letter is on in the start state
int startLine = -1;
boolean startMax = false;
boolean startMaxEllipsis = false;
if (startLayoutMaxLines != null) {
char letter = startLayoutMaxLines.getText().charAt(i);
startMaxEllipsis = letter == '…';
if (letter != '\uFEFF' // beyond max lines
&& !startMaxEllipsis) { // ellipsize inserted into layout
startLine = startLayoutMaxLines.getLineForOffset(i);
startMax = true;
}
}
if (!startMax) {
startLine = startLayout.getLineForOffset(i);
}
// work out which line this letter is on in the end state
int endLine = -1;
boolean endMax = false;
boolean endMaxEllipsis = false;
if (endLayoutMaxLines != null) {
char letter = endLayoutMaxLines.getText().charAt(i);
endMaxEllipsis = letter == '…';
if (letter != '\uFEFF' // beyond max lines
&& !endMaxEllipsis) { // ellipsize inserted into layout
endLine = endLayoutMaxLines.getLineForOffset(i);
endMax = true;
}
}
if (!endMax) {
endLine = endLayout.getLineForOffset(i);
}
boolean lastChar = i == textLength - 1;
if (startLine != currentStartLine
|| endLine != currentEndLine
|| lastChar) {
// at a run boundary, store bounds in both states
int startRunRight = getRunRight(startLayout, startLayoutMaxLines,
currentStartLine, i, startLine, startMax, startMaxEllipsis, lastChar);
int startRunBottom = startLayout.getLineBottom(currentStartLine);
int endRunRight = getRunRight(endLayout, endLayoutMaxLines, currentEndLine, i,
endLine, endMax, endMaxEllipsis, lastChar);
int endRunBottom = endLayout.getLineBottom(currentEndLine);
Rect startBound = new Rect(
currentStartRunLeft, currentStartRunTop, startRunRight, startRunBottom);
startBound.offset(startData.textPosition.x, startData.textPosition.y);
Rect endBound = new Rect(
currentEndRunLeft, currentEndRunTop, endRunRight, endRunBottom);
endBound.offset(endData.textPosition.x, endData.textPosition.y);
runs.add(new Run(
startBound,
startMax || startRunBottom <= startData.textHeight,
endBound,
endMax || endRunBottom <= endData.textHeight));
currentStartLine = startLine;
currentStartRunLeft = (int) (startMax ? startLayoutMaxLines
.getPrimaryHorizontal(i) : startLayout.getPrimaryHorizontal(i));
currentStartRunTop = startLayout.getLineTop(startLine);
currentEndLine = endLine;
currentEndRunLeft = (int) (endMax ? endLayoutMaxLines
.getPrimaryHorizontal(i) : endLayout.getPrimaryHorizontal(i));
currentEndRunTop = endLayout.getLineTop(endLine);
}
}
return runs;
}
/**
* Calculate the right boundary for this run (harder than it sounds). As we're a letter ahead,
* need to grab either current letter start or the end of the previous line. Also need to
* consider maxLines case, which inserts ellipses at the overflow point – don't include these.
*/
private int getRunRight(
Layout unrestrictedLayout, Layout maxLinesLayout, int currentLine, int index,
int line, boolean withinMax, boolean isMaxEllipsis, boolean isLastChar) {
int runRight;
if (line != currentLine || isLastChar) {
if (isMaxEllipsis) {
runRight = (int) maxLinesLayout.getPrimaryHorizontal(index);
} else {
runRight = (int) unrestrictedLayout.getLineMax(currentLine);
}
} else {
if (withinMax) {
runRight = (int) maxLinesLayout.getPrimaryHorizontal(index);
} else {
runRight = (int) unrestrictedLayout.getPrimaryHorizontal(index);
}
}
return runRight;
}
/**
* Create Animators to transition each run of text from start to end position and size.
*/
@NonNull
private List<Animator> createRunAnimators(
View view,
ReflowData startData,
ReflowData endData,
Bitmap startText,
Bitmap endText,
List<Run> runs) {
List<Animator> animators = new ArrayList<>(runs.size());
int dx = startData.bounds.left - endData.bounds.left;
int dy = startData.bounds.top - endData.bounds.top;
long startDelay = 0L;
// move text closest to the destination first i.e. loop forward or backward over the runs
boolean upward = startData.bounds.centerY() > endData.bounds.centerY();
boolean first = true;
boolean lastRightward = true;
LinearInterpolator linearInterpolator = new LinearInterpolator();
for (int i = upward ? 0 : runs.size() - 1;
((upward && i < runs.size()) || (!upward && i >= 0));
i += (upward ? 1 : -1)) {
Run run = runs.get(i);
// skip text runs which aren't visible in either state
if (!run.startVisible && !run.endVisible) continue;
// create & position the drawable which displays the run; add it to the overlay.
SwitchDrawable drawable = new SwitchDrawable(
startText, run.start, startData.textSize,
endText, run.end, endData.textSize);
drawable.setBounds(
run.start.left + dx,
run.start.top + dy,
run.start.right + dx,
run.start.bottom + dy);
view.getOverlay().add(drawable);
PropertyValuesHolder topLeft =
PropertyValuesHolder.ofObject(SwitchDrawable.TOP_LEFT, null,
getPathMotion().getPath(
run.start.left + dx,
run.start.top + dy,
run.end.left,
run.end.top));
PropertyValuesHolder width = PropertyValuesHolder.ofInt(
SwitchDrawable.WIDTH, run.start.width(), run.end.width());
PropertyValuesHolder height = PropertyValuesHolder.ofInt(
SwitchDrawable.HEIGHT, run.start.height(), run.end.height());
// the progress property drives the switching behaviour
PropertyValuesHolder progress = PropertyValuesHolder.ofFloat(
SwitchDrawable.PROGRESS, 0f, 1f);
Animator runAnim = ObjectAnimator.ofPropertyValuesHolder(
drawable, topLeft, width, height, progress);
boolean rightward = run.start.centerX() + dx < run.end.centerX();
if ((run.startVisible && run.endVisible)
&& !first && rightward != lastRightward) {
// increase the start delay (by a decreasing amount) for the next run
// (if it's visible throughout) to stagger the movement and try to minimize overlaps
startDelay += staggerDelay;
staggerDelay *= STAGGER_DECAY;
}
lastRightward = rightward;
first = false;
runAnim.setStartDelay(startDelay);
long animDuration = Math.max(minDuration, duration - (startDelay / 2));
runAnim.setDuration(animDuration);
animators.add(runAnim);
if (run.startVisible != run.endVisible) {
// if run is appearing/disappearing then fade it in/out
ObjectAnimator fade = ObjectAnimator.ofInt(
drawable,
SwitchDrawable.ALPHA,
run.startVisible ? OPAQUE : TRANSPARENT,
run.endVisible ? OPAQUE : TRANSPARENT);
fade.setDuration((duration + startDelay) / 2);
if (!run.startVisible) {
drawable.setAlpha(TRANSPARENT);
fade.setStartDelay((duration + startDelay) / 2);
} else {
fade.setStartDelay(startDelay);
}
animators.add(fade);
} else {
// slightly fade during transition to minimize movement
ObjectAnimator fade = ObjectAnimator.ofInt(
drawable,
SwitchDrawable.ALPHA,
OPAQUE, OPACITY_MID_TRANSITION, OPAQUE);
fade.setStartDelay(startDelay);
fade.setDuration(duration + startDelay);
fade.setInterpolator(linearInterpolator);
animators.add(fade);
}
}
return animators;
}
private Layout createLayout(ReflowData data, Context context, boolean enforceMaxLines) {
TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
paint.setTextSize(data.textSize);
paint.setColor(data.textColor);
paint.setLetterSpacing(data.letterSpacing);
if (data.fontName != null) {
paint.setTypeface(FontUtil.get(context, data.fontName));
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder builder = StaticLayout.Builder.obtain(
data.text, 0, data.text.length(), paint, data.textWidth)
.setLineSpacing(data.lineSpacingAdd, data.lineSpacingMult)
.setBreakStrategy(data.breakStrategy);
if (enforceMaxLines && data.maxLines != -1) {
builder.setMaxLines(data.maxLines);
builder.setEllipsize(TextUtils.TruncateAt.END);
}
return builder.build();
} else {
return new StaticLayout(
data.text,
paint,
data.textWidth,
Layout.Alignment.ALIGN_NORMAL,
data.lineSpacingMult,
data.lineSpacingAdd,
true);
}
}
private Bitmap createBitmap(@NonNull ReflowData data, @NonNull Layout layout) {
Bitmap bitmap = Bitmap.createBitmap(
data.bounds.width(), data.bounds.height(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.translate(data.textPosition.x, data.textPosition.y);
layout.draw(canvas);
return bitmap;
}
/**
* Calculate the duration for the transition depending upon how far the text has to move.
*/
private long calculateDuration(@NonNull Rect startPosition, @NonNull Rect endPosition) {
float distance = (float) Math.hypot(
startPosition.exactCenterX() - endPosition.exactCenterX(),
startPosition.exactCenterY() - endPosition.exactCenterY());
long duration = (long) (1000 * (distance / velocity));
return Math.max(minDuration, Math.min(maxDuration, duration));
}
/**
* Holds all data needed to describe a block of text i.e. to be able to re-create the
* {@link Layout}.
*/
private static class ReflowData implements Parcelable {
final String text;
final float textSize;
final @ColorInt int textColor;
final Rect bounds;
final @Nullable String fontName;
final float lineSpacingAdd;
final float lineSpacingMult;
final Point textPosition;
final int textHeight;
final int textWidth;
final int breakStrategy;
final float letterSpacing;
final int maxLines;
ReflowData(@NonNull Reflowable reflowable) {
text = reflowable.getText();
textSize = reflowable.getTextSize();
textColor = reflowable.getTextColor();
fontName = reflowable.getFontName();
final View view = reflowable.getView();
int[] loc = new int[2];
view.getLocationInWindow(loc);
bounds = new Rect(loc[0], loc[1], loc[0] + view.getWidth(), loc[1] + view.getHeight());
textPosition = reflowable.getTextPosition();
textHeight = reflowable.getTextHeight();
lineSpacingAdd = reflowable.getLineSpacingAdd();
lineSpacingMult = reflowable.getLineSpacingMult();
textWidth = reflowable.getTextWidth();
breakStrategy = reflowable.getBreakStrategy();
letterSpacing = reflowable.getLetterSpacing();
maxLines = reflowable.getMaxLines();
}
ReflowData(Parcel in) {
text = in.readString();
textSize = in.readFloat();
textColor = in.readInt();
bounds = (Rect) in.readValue(Rect.class.getClassLoader());
fontName = in.readString();
lineSpacingAdd = in.readFloat();
lineSpacingMult = in.readFloat();
textPosition = (Point) in.readValue(Point.class.getClassLoader());
textHeight = in.readInt();
textWidth = in.readInt();
breakStrategy = in.readInt();
letterSpacing = in.readFloat();
maxLines = in.readInt();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(text);
dest.writeFloat(textSize);
dest.writeInt(textColor);
dest.writeValue(bounds);
dest.writeString(fontName);
dest.writeFloat(lineSpacingAdd);
dest.writeFloat(lineSpacingMult);
dest.writeValue(textPosition);
dest.writeInt(textHeight);
dest.writeInt(textWidth);
dest.writeInt(breakStrategy);
dest.writeFloat(letterSpacing);
dest.writeInt(maxLines);
}
@SuppressWarnings("unused")
public static final Parcelable.Creator<ReflowData> CREATOR
= new Parcelable.Creator<ReflowData>() {
@Override
public ReflowData createFromParcel(Parcel in) {
return new ReflowData(in);
}
@Override
public ReflowData[] newArray(int size) {
return new ReflowData[size];
}
};
}
/**
* Models the location of a run of text in both start and end states.
*/
private static class Run {
final Rect start;
final boolean startVisible;
final Rect end;
final boolean endVisible;
Run(Rect start, boolean startVisible, Rect end, boolean endVisible) {
this.start = start;
this.startVisible = startVisible;
this.end = end;
this.endVisible = endVisible;
}
}
/**
* A drawable which shows (a portion of) one of two given bitmaps, switching between them once
* a progress property passes a threshold.
* <p>
* This is helpful when animating text size change as small text scaled up is blurry but larger
* text scaled down has different kerning. Instead we use images of both states and switch
* during the transition. We use images as animating text size thrashes the font cache.
*/
private static class SwitchDrawable extends Drawable {
static final Property<SwitchDrawable, PointF> TOP_LEFT =
new Property<SwitchDrawable, PointF>(PointF.class, "topLeft") {
@Override
public void set(SwitchDrawable drawable, PointF topLeft) {
drawable.setTopLeft(topLeft);
}
@Override
public PointF get(SwitchDrawable drawable) {
return drawable.getTopLeft();
}
};
static final Property<SwitchDrawable, Integer> WIDTH =
new Property<SwitchDrawable, Integer>(Integer.class, "width") {
@Override
public void set(SwitchDrawable drawable, Integer width) {
drawable.setWidth(width);
}
@Override
public Integer get(SwitchDrawable drawable) {
return drawable.getWidth();
}
};
static final Property<SwitchDrawable, Integer> HEIGHT =
new Property<SwitchDrawable, Integer>(Integer.class, "height") {
@Override
public void set(SwitchDrawable drawable, Integer height) {
drawable.setHeight(height);
}
@Override
public Integer get(SwitchDrawable drawable) {
return drawable.getHeight();
}
};
static final Property<SwitchDrawable, Integer> ALPHA =
new Property<SwitchDrawable, Integer>(Integer.class, "alpha") {
@Override
public void set(SwitchDrawable drawable, Integer alpha) {
drawable.setAlpha(alpha);
}
@Override
public Integer get(SwitchDrawable drawable) {
return drawable.getAlpha();
}
};
static final Property<SwitchDrawable, Float> PROGRESS =
new Property<SwitchDrawable, Float>(Float.class, "progress") {
@Override
public void set(SwitchDrawable drawable, Float progress) {
drawable.setProgress(progress);
}
@Override
public Float get(SwitchDrawable drawable) {
return 0f;
}
};
private final Paint paint;
private final float switchThreshold;
private Bitmap currentBitmap;
private final Bitmap endBitmap;
private Rect currentBitmapSrcBounds;
private final Rect endBitmapSrcBounds;
private boolean hasSwitched = false;
private PointF topLeft;
private int width, height;
SwitchDrawable(
@NonNull Bitmap startBitmap,
@NonNull Rect startBitmapSrcBounds,
float startFontSize,
@NonNull Bitmap endBitmap,
@NonNull Rect endBitmapSrcBounds,
float endFontSize) {
currentBitmap = startBitmap;
currentBitmapSrcBounds = startBitmapSrcBounds;
this.endBitmap = endBitmap;
this.endBitmapSrcBounds = endBitmapSrcBounds;
switchThreshold = startFontSize / (startFontSize + endFontSize);
paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG);
}
@Override
public void draw(@NonNull Canvas canvas) {
canvas.drawBitmap(currentBitmap, currentBitmapSrcBounds, getBounds(), paint);
}
@Override
public int getAlpha() {
return paint.getAlpha();
}
@Override
public void setAlpha(int alpha) {
paint.setAlpha(alpha);
}
@Override
public ColorFilter getColorFilter() {
return paint.getColorFilter();
}
@Override
public void setColorFilter(ColorFilter colorFilter) {
paint.setColorFilter(colorFilter);
}
@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}
void setProgress(float progress) {
if (!hasSwitched && progress >= switchThreshold) {
currentBitmap = endBitmap;
currentBitmapSrcBounds = endBitmapSrcBounds;
hasSwitched = true;
}
}
PointF getTopLeft() {
return topLeft;
}
void setTopLeft(PointF topLeft) {
this.topLeft = topLeft;
updateBounds();
}
int getWidth() {
return width;
}
void setWidth(int width) {
this.width = width;
updateBounds();
}
int getHeight() {
return height;
}
void setHeight(int height) {
this.height = height;
updateBounds();
}
private void updateBounds() {
int left = Math.round(topLeft.x);
int top = Math.round(topLeft.y);
setBounds(left, top, left + width, top + height);
}
}
/**
* Interface describing a view which supports re-flowing i.e. it exposes enough information to
* construct a {@link ReflowData} object;
*/
public interface Reflowable<T extends View> {
T getView();
String getText();
Point getTextPosition();
int getTextWidth();
int getTextHeight();
float getTextSize();
@ColorInt int getTextColor();
float getLineSpacingAdd();
float getLineSpacingMult();
int getBreakStrategy();
float getLetterSpacing();
@Nullable String getFontName();
int getMaxLines();
}
/**
* Wraps a {@link TextView} and implements {@link Reflowable}.
*/
public static class ReflowableTextView implements Reflowable<TextView> {
private final TextView textView;
public ReflowableTextView(TextView textView) {
this.textView = textView;
}
@Override
public TextView getView() {
return textView;
}
@Override
public String getText() {
return textView.getText().toString();
}
@Override
public Point getTextPosition() {
return new Point(textView.getCompoundPaddingLeft(), textView.getCompoundPaddingTop());
}
@Override
public int getTextWidth() {
return textView.getWidth()
- textView.getCompoundPaddingLeft() - textView.getCompoundPaddingRight();
}
@Override
public int getTextHeight() {
if (textView.getMaxLines() != -1) {
return (textView.getMaxLines() * textView.getLineHeight()) + 1;
} else {
return textView.getHeight() - textView.getCompoundPaddingTop()
- textView.getCompoundPaddingBottom();
}
}
@Override
public float getLineSpacingAdd() {
return textView.getLineSpacingExtra();
}
@Override
public float getLineSpacingMult() {
return textView.getLineSpacingMultiplier();
}
@Override
public int getBreakStrategy() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return textView.getBreakStrategy();
}
return -1;
}
@Override
public float getLetterSpacing() {
return textView.getLetterSpacing();
}
@Override
public float getTextSize() {
return textView.getTextSize();
}
@Override
public int getTextColor() {
return textView.getCurrentTextColor();
}
@Nullable
@Override
public String getFontName() {
return FontUtil.getName(textView.getTypeface());
}
@Override
public int getMaxLines() {
return textView.getMaxLines();
}
}
}