Home | History | Annotate | Download | only in autoresizetext
      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 
     17 package com.android.incallui.autoresizetext;
     18 
     19 import android.content.Context;
     20 import android.content.res.TypedArray;
     21 import android.graphics.RectF;
     22 import android.os.Build.VERSION;
     23 import android.os.Build.VERSION_CODES;
     24 import android.text.Layout.Alignment;
     25 import android.text.StaticLayout;
     26 import android.text.TextPaint;
     27 import android.util.AttributeSet;
     28 import android.util.DisplayMetrics;
     29 import android.util.SparseIntArray;
     30 import android.util.TypedValue;
     31 import android.widget.TextView;
     32 import javax.annotation.Nullable;
     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 b/26704434. 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  */
     42 public 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     textPaint.set(getPaint());
     86   }
     87 
     88   /** Overridden because getMaxLines is only defined in JB+. */
     89   @Override
     90   public final int getMaxLines() {
     91     if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
     92       return super.getMaxLines();
     93     } else {
     94       return maxLines;
     95     }
     96   }
     97 
     98   /** Overridden because getMaxLines is only defined in JB+. */
     99   @Override
    100   public final void setMaxLines(int maxLines) {
    101     super.setMaxLines(maxLines);
    102     this.maxLines = maxLines;
    103   }
    104 
    105   /** Overridden because getLineSpacingMultiplier is only defined in JB+. */
    106   @Override
    107   public final float getLineSpacingMultiplier() {
    108     if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
    109       return super.getLineSpacingMultiplier();
    110     } else {
    111       return lineSpacingMultiplier;
    112     }
    113   }
    114 
    115   /** Overridden because getLineSpacingExtra is only defined in JB+. */
    116   @Override
    117   public final float getLineSpacingExtra() {
    118     if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
    119       return super.getLineSpacingExtra();
    120     } else {
    121       return lineSpacingExtra;
    122     }
    123   }
    124 
    125   /**
    126    * Overridden because getLineSpacingMultiplier and getLineSpacingExtra are only defined in JB+.
    127    */
    128   @Override
    129   public final void setLineSpacing(float add, float mult) {
    130     super.setLineSpacing(add, mult);
    131     lineSpacingMultiplier = mult;
    132     lineSpacingExtra = add;
    133   }
    134 
    135   /**
    136    * Although this overrides the setTextSize method from the TextView base class, it changes the
    137    * semantics a bit: Calling setTextSize now specifies the maximum text size to be used by this
    138    * view. If the text can't fit with that text size, the text size will be scaled down, up to the
    139    * minimum text size specified in {@link #setMinTextSize}.
    140    *
    141    * <p>Note that the final size unit will be truncated to the nearest integer value of the
    142    * specified unit.
    143    */
    144   @Override
    145   public final void setTextSize(int unit, float size) {
    146     float maxTextSize = TypedValue.applyDimension(unit, size, displayMetrics);
    147     if (this.maxTextSize != maxTextSize) {
    148       this.maxTextSize = maxTextSize;
    149       // TODO: It's not actually necessary to clear the whole cache here. To optimize cache
    150       // deletion we'd have to delete all entries in the cache with a value equal or larger than
    151       // MIN(old_max_size, new_max_size) when changing maxTextSize; and all entries with a value
    152       // equal or smaller than MAX(old_min_size, new_min_size) when changing minTextSize.
    153       textSizesCache.clear();
    154       requestLayout();
    155     }
    156   }
    157 
    158   /**
    159    * Sets the lower text size limit and invalidate the view.
    160    *
    161    * <p>The parameters follow the same behavior as they do in {@link #setTextSize}.
    162    *
    163    * <p>Note that the final size unit will be truncated to the nearest integer value of the
    164    * specified unit.
    165    */
    166   public final void setMinTextSize(int unit, float size) {
    167     float minTextSize = TypedValue.applyDimension(unit, size, displayMetrics);
    168     if (this.minTextSize != minTextSize) {
    169       this.minTextSize = minTextSize;
    170       textSizesCache.clear();
    171       requestLayout();
    172     }
    173   }
    174 
    175   /**
    176    * Sets the unit to use as step units when computing the resized font size. This view's text
    177    * contents will always be rendered as a whole integer value in the unit specified here. For
    178    * example, if the unit is {@link TypedValue#COMPLEX_UNIT_SP}, then the text size may end up
    179    * being 13sp or 14sp, but never 13.5sp.
    180    *
    181    * <p>By default, the AutoResizeTextView uses the unit {@link TypedValue#COMPLEX_UNIT_PX}.
    182    *
    183    * @param unit the unit type to use; must be a known unit type from {@link TypedValue}.
    184    */
    185   public final void setResizeStepUnit(int unit) {
    186     if (resizeStepUnit != unit) {
    187       resizeStepUnit = unit;
    188       requestLayout();
    189     }
    190   }
    191 
    192   private void readAttrs(TypedArray typedArray) {
    193     resizeStepUnit = typedArray.getInt(
    194         R.styleable.AutoResizeTextView_autoResizeText_resizeStepUnit, DEFAULT_RESIZE_STEP_UNIT);
    195     minTextSize = (int) typedArray.getDimension(
    196         R.styleable.AutoResizeTextView_autoResizeText_minTextSize, DEFAULT_MIN_TEXT_SIZE);
    197     maxTextSize = (int) getTextSize();
    198   }
    199 
    200   private void adjustTextSize() {
    201     int maxWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
    202     int maxHeight = getMeasuredHeight() - getPaddingBottom() - getPaddingTop();
    203 
    204     if (maxWidth <= 0 || maxHeight <= 0) {
    205       return;
    206     }
    207 
    208     this.maxWidth = maxWidth;
    209     availableSpaceRect.right = maxWidth;
    210     availableSpaceRect.bottom = maxHeight;
    211     int minSizeInStepSizeUnits = (int) Math.ceil(convertToResizeStepUnits(minTextSize));
    212     int maxSizeInStepSizeUnits = (int) Math.floor(convertToResizeStepUnits(maxTextSize));
    213     float textSize = computeTextSize(
    214         minSizeInStepSizeUnits, maxSizeInStepSizeUnits, availableSpaceRect);
    215     super.setTextSize(resizeStepUnit, textSize);
    216   }
    217 
    218   private boolean suggestedSizeFitsInSpace(float suggestedSizeInPx, RectF availableSpace) {
    219     textPaint.setTextSize(suggestedSizeInPx);
    220     String text = getText().toString();
    221     int maxLines = getMaxLines();
    222     if (maxLines == 1) {
    223       // If single line, check the line's height and width.
    224       return textPaint.getFontSpacing() <= availableSpace.bottom
    225           && textPaint.measureText(text) <= availableSpace.right;
    226     } else {
    227       // If multiline, lay the text out, then check the number of lines, the layout's height,
    228       // and each line's width.
    229       StaticLayout layout = new StaticLayout(text,
    230           textPaint,
    231           maxWidth,
    232           Alignment.ALIGN_NORMAL,
    233           getLineSpacingMultiplier(),
    234           getLineSpacingExtra(),
    235           true);
    236 
    237       // Return false if we need more than maxLines. The text is obviously too big in this case.
    238       if (maxLines != NO_LINE_LIMIT && layout.getLineCount() > maxLines) {
    239         return false;
    240       }
    241       // Return false if the height of the layout is too big.
    242       return layout.getHeight() <= availableSpace.bottom;
    243     }
    244   }
    245 
    246   /**
    247    * Computes the final text size to use for this text view, factoring in any previously
    248    * cached computations.
    249    *
    250    * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit}
    251    * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit}
    252    */
    253   private float computeTextSize(int minSize, int maxSize, RectF availableSpace) {
    254     CharSequence text = getText();
    255     if (text != null && textSizesCache.get(text.hashCode()) != 0) {
    256       return textSizesCache.get(text.hashCode());
    257     }
    258     int size = binarySearchSizes(minSize, maxSize, availableSpace);
    259     textSizesCache.put(text == null ? 0 : text.hashCode(), size);
    260     return size;
    261   }
    262 
    263   /**
    264    * Performs a binary search to find the largest font size that will still fit within the size
    265    * available to this view.
    266    * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit}
    267    * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit}
    268    */
    269   private int binarySearchSizes(int minSize, int maxSize, RectF availableSpace) {
    270     int bestSize = minSize;
    271     int low = minSize + 1;
    272     int high = maxSize;
    273     int sizeToTry;
    274     while (low <= high) {
    275       sizeToTry = (low + high) / 2;
    276       float dimension = TypedValue.applyDimension(resizeStepUnit, sizeToTry, displayMetrics);
    277       if (suggestedSizeFitsInSpace(dimension, availableSpace)) {
    278         bestSize = low;
    279         low = sizeToTry + 1;
    280       } else {
    281         high = sizeToTry - 1;
    282         bestSize = high;
    283       }
    284     }
    285     return bestSize;
    286   }
    287 
    288   private float convertToResizeStepUnits(float dimension) {
    289     // To figure out the multiplier between a raw dimension and the resizeStepUnit, we invert the
    290     // conversion of 1 resizeStepUnit to a raw dimension.
    291     float multiplier = 1 / TypedValue.applyDimension(resizeStepUnit, 1, displayMetrics);
    292     return dimension * multiplier;
    293   }
    294 
    295   @Override
    296   protected final void onTextChanged(
    297       final CharSequence text, final int start, final int before, final int after) {
    298     super.onTextChanged(text, start, before, after);
    299     adjustTextSize();
    300   }
    301 
    302   @Override
    303   protected final void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
    304     super.onSizeChanged(width, height, oldWidth, oldHeight);
    305     if (width != oldWidth || height != oldHeight) {
    306       textSizesCache.clear();
    307       adjustTextSize();
    308     }
    309   }
    310 
    311   @Override
    312   protected final void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    313     adjustTextSize();
    314     super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    315   }
    316 }
    317