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