1/*
2 * Copyright (C) 2016 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16
17package com.android.incallui.autoresizetext;
18
19import android.content.Context;
20import android.content.res.TypedArray;
21import android.graphics.RectF;
22import android.os.Build.VERSION;
23import android.os.Build.VERSION_CODES;
24import android.support.annotation.Nullable;
25import android.text.Layout.Alignment;
26import android.text.StaticLayout;
27import android.text.TextPaint;
28import android.util.AttributeSet;
29import android.util.DisplayMetrics;
30import android.util.SparseIntArray;
31import android.util.TypedValue;
32import android.widget.TextView;
33
34/**
35 * A TextView that automatically scales its text to completely fill its allotted width.
36 *
37 * <p>Note: In some edge cases, the binary search algorithm to find the best fit may slightly
38 * overshoot / undershoot its constraints. See a bug. No minimal repro case has been
39 * found yet. A known workaround is the solution provided on StackOverflow:
40 * http://stackoverflow.com/a/5535672
41 */
42public class AutoResizeTextView extends TextView {
43  private static final int NO_LINE_LIMIT = -1;
44  private static final float DEFAULT_MIN_TEXT_SIZE = 16.0f;
45  private static final int DEFAULT_RESIZE_STEP_UNIT = TypedValue.COMPLEX_UNIT_PX;
46
47  private final DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
48  private final RectF availableSpaceRect = new RectF();
49  private final SparseIntArray textSizesCache = new SparseIntArray();
50  private final TextPaint textPaint = new TextPaint();
51  private int resizeStepUnit = DEFAULT_RESIZE_STEP_UNIT;
52  private float minTextSize = DEFAULT_MIN_TEXT_SIZE;
53  private float maxTextSize;
54  private int maxWidth;
55  private int maxLines;
56  private float lineSpacingMultiplier = 1.0f;
57  private float lineSpacingExtra = 0.0f;
58
59  public AutoResizeTextView(Context context) {
60    super(context, null, 0);
61    initialize(context, null, 0, 0);
62  }
63
64  public AutoResizeTextView(Context context, AttributeSet attrs) {
65    super(context, attrs, 0);
66    initialize(context, attrs, 0, 0);
67  }
68
69  public AutoResizeTextView(Context context, AttributeSet attrs, int defStyleAttr) {
70    super(context, attrs, defStyleAttr);
71    initialize(context, attrs, defStyleAttr, 0);
72  }
73
74  public AutoResizeTextView(
75      Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
76    super(context, attrs, defStyleAttr, defStyleRes);
77    initialize(context, attrs, defStyleAttr, defStyleRes);
78  }
79
80  private void initialize(
81      Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
82    TypedArray typedArray = context.getTheme().obtainStyledAttributes(
83        attrs, R.styleable.AutoResizeTextView, defStyleAttr, defStyleRes);
84    readAttrs(typedArray);
85    typedArray.recycle();
86    textPaint.set(getPaint());
87  }
88
89  /** Overridden because getMaxLines is only defined in JB+. */
90  @Override
91  public final int getMaxLines() {
92    if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
93      return super.getMaxLines();
94    } else {
95      return maxLines;
96    }
97  }
98
99  /** Overridden because getMaxLines is only defined in JB+. */
100  @Override
101  public final void setMaxLines(int maxLines) {
102    super.setMaxLines(maxLines);
103    this.maxLines = maxLines;
104  }
105
106  /** Overridden because getLineSpacingMultiplier is only defined in JB+. */
107  @Override
108  public final float getLineSpacingMultiplier() {
109    if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
110      return super.getLineSpacingMultiplier();
111    } else {
112      return lineSpacingMultiplier;
113    }
114  }
115
116  /** Overridden because getLineSpacingExtra is only defined in JB+. */
117  @Override
118  public final float getLineSpacingExtra() {
119    if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
120      return super.getLineSpacingExtra();
121    } else {
122      return lineSpacingExtra;
123    }
124  }
125
126  /**
127   * Overridden because getLineSpacingMultiplier and getLineSpacingExtra are only defined in JB+.
128   */
129  @Override
130  public final void setLineSpacing(float add, float mult) {
131    super.setLineSpacing(add, mult);
132    lineSpacingMultiplier = mult;
133    lineSpacingExtra = add;
134  }
135
136  /**
137   * Although this overrides the setTextSize method from the TextView base class, it changes the
138   * semantics a bit: Calling setTextSize now specifies the maximum text size to be used by this
139   * view. If the text can't fit with that text size, the text size will be scaled down, up to the
140   * minimum text size specified in {@link #setMinTextSize}.
141   *
142   * <p>Note that the final size unit will be truncated to the nearest integer value of the
143   * specified unit.
144   */
145  @Override
146  public final void setTextSize(int unit, float size) {
147    float maxTextSize = TypedValue.applyDimension(unit, size, displayMetrics);
148    if (this.maxTextSize != maxTextSize) {
149      this.maxTextSize = maxTextSize;
150      // TODO(tobyj): It's not actually necessary to clear the whole cache here. To optimize cache
151      // deletion we'd have to delete all entries in the cache with a value equal or larger than
152      // MIN(old_max_size, new_max_size) when changing maxTextSize; and all entries with a value
153      // equal or smaller than MAX(old_min_size, new_min_size) when changing minTextSize.
154      textSizesCache.clear();
155      requestLayout();
156    }
157  }
158
159  /**
160   * Sets the lower text size limit and invalidate the view.
161   *
162   * <p>The parameters follow the same behavior as they do in {@link #setTextSize}.
163   *
164   * <p>Note that the final size unit will be truncated to the nearest integer value of the
165   * specified unit.
166   */
167  public final void setMinTextSize(int unit, float size) {
168    float minTextSize = TypedValue.applyDimension(unit, size, displayMetrics);
169    if (this.minTextSize != minTextSize) {
170      this.minTextSize = minTextSize;
171      textSizesCache.clear();
172      requestLayout();
173    }
174  }
175
176  /**
177   * Sets the unit to use as step units when computing the resized font size. This view's text
178   * contents will always be rendered as a whole integer value in the unit specified here. For
179   * example, if the unit is {@link TypedValue#COMPLEX_UNIT_SP}, then the text size may end up
180   * being 13sp or 14sp, but never 13.5sp.
181   *
182   * <p>By default, the AutoResizeTextView uses the unit {@link TypedValue#COMPLEX_UNIT_PX}.
183   *
184   * @param unit the unit type to use; must be a known unit type from {@link TypedValue}.
185   */
186  public final void setResizeStepUnit(int unit) {
187    if (resizeStepUnit != unit) {
188      resizeStepUnit = unit;
189      requestLayout();
190    }
191  }
192
193  private void readAttrs(TypedArray typedArray) {
194    resizeStepUnit = typedArray.getInt(
195        R.styleable.AutoResizeTextView_autoResizeText_resizeStepUnit, DEFAULT_RESIZE_STEP_UNIT);
196    minTextSize = (int) typedArray.getDimension(
197        R.styleable.AutoResizeTextView_autoResizeText_minTextSize, DEFAULT_MIN_TEXT_SIZE);
198    maxTextSize = (int) getTextSize();
199  }
200
201  private void adjustTextSize() {
202    int maxWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
203    int maxHeight = getMeasuredHeight() - getPaddingBottom() - getPaddingTop();
204
205    if (maxWidth <= 0 || maxHeight <= 0) {
206      return;
207    }
208
209    this.maxWidth = maxWidth;
210    availableSpaceRect.right = maxWidth;
211    availableSpaceRect.bottom = maxHeight;
212    int minSizeInStepSizeUnits = (int) Math.ceil(convertToResizeStepUnits(minTextSize));
213    int maxSizeInStepSizeUnits = (int) Math.floor(convertToResizeStepUnits(maxTextSize));
214    float textSize = computeTextSize(
215        minSizeInStepSizeUnits, maxSizeInStepSizeUnits, availableSpaceRect);
216    super.setTextSize(resizeStepUnit, textSize);
217  }
218
219  private boolean suggestedSizeFitsInSpace(float suggestedSizeInPx, RectF availableSpace) {
220    textPaint.setTextSize(suggestedSizeInPx);
221    String text = getText().toString();
222    int maxLines = getMaxLines();
223    if (maxLines == 1) {
224      // If single line, check the line's height and width.
225      return textPaint.getFontSpacing() <= availableSpace.bottom
226          && textPaint.measureText(text) <= availableSpace.right;
227    } else {
228      // If multiline, lay the text out, then check the number of lines, the layout's height,
229      // and each line's width.
230      StaticLayout layout = new StaticLayout(text,
231          textPaint,
232          maxWidth,
233          Alignment.ALIGN_NORMAL,
234          getLineSpacingMultiplier(),
235          getLineSpacingExtra(),
236          true);
237
238      // Return false if we need more than maxLines. The text is obviously too big in this case.
239      if (maxLines != NO_LINE_LIMIT && layout.getLineCount() > maxLines) {
240        return false;
241      }
242      // Return false if the height of the layout is too big.
243      return layout.getHeight() <= availableSpace.bottom;
244    }
245  }
246
247  /**
248   * Computes the final text size to use for this text view, factoring in any previously
249   * cached computations.
250   *
251   * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit}
252   * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit}
253   */
254  private float computeTextSize(int minSize, int maxSize, RectF availableSpace) {
255    CharSequence text = getText();
256    if (text != null && textSizesCache.get(text.hashCode()) != 0) {
257      return textSizesCache.get(text.hashCode());
258    }
259    int size = binarySearchSizes(minSize, maxSize, availableSpace);
260    textSizesCache.put(text == null ? 0 : text.hashCode(), size);
261    return size;
262  }
263
264  /**
265   * Performs a binary search to find the largest font size that will still fit within the size
266   * available to this view.
267   * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit}
268   * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit}
269   */
270  private int binarySearchSizes(int minSize, int maxSize, RectF availableSpace) {
271    int bestSize = minSize;
272    int low = minSize + 1;
273    int high = maxSize;
274    int sizeToTry;
275    while (low <= high) {
276      sizeToTry = (low + high) / 2;
277      float dimension = TypedValue.applyDimension(resizeStepUnit, sizeToTry, displayMetrics);
278      if (suggestedSizeFitsInSpace(dimension, availableSpace)) {
279        bestSize = low;
280        low = sizeToTry + 1;
281      } else {
282        high = sizeToTry - 1;
283        bestSize = high;
284      }
285    }
286    return bestSize;
287  }
288
289  private float convertToResizeStepUnits(float dimension) {
290    // To figure out the multiplier between a raw dimension and the resizeStepUnit, we invert the
291    // conversion of 1 resizeStepUnit to a raw dimension.
292    float multiplier = 1 / TypedValue.applyDimension(resizeStepUnit, 1, displayMetrics);
293    return dimension * multiplier;
294  }
295
296  @Override
297  protected final void onTextChanged(
298      final CharSequence text, final int start, final int before, final int after) {
299    super.onTextChanged(text, start, before, after);
300    adjustTextSize();
301  }
302
303  @Override
304  protected final void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
305    super.onSizeChanged(width, height, oldWidth, oldHeight);
306    if (width != oldWidth || height != oldHeight) {
307      textSizesCache.clear();
308      adjustTextSize();
309    }
310  }
311
312  @Override
313  protected final void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
314    adjustTextSize();
315    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
316  }
317}
318