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