1 /* 2 * Copyright (C) 2017 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 android.text; 18 19 import android.annotation.FloatRange; 20 import android.annotation.IntRange; 21 import android.annotation.NonNull; 22 import android.annotation.Nullable; 23 import android.graphics.Rect; 24 import android.text.style.MetricAffectingSpan; 25 26 import com.android.internal.util.Preconditions; 27 28 import java.util.ArrayList; 29 import java.util.Objects; 30 31 /** 32 * A text which has the character metrics data. 33 * 34 * A text object that contains the character metrics data and can be used to improve the performance 35 * of text layout operations. When a PrecomputedText is created with a given {@link CharSequence}, 36 * it will measure the text metrics during the creation. This PrecomputedText instance can be set on 37 * {@link android.widget.TextView} or {@link StaticLayout}. Since the text layout information will 38 * be included in this instance, {@link android.widget.TextView} or {@link StaticLayout} will not 39 * have to recalculate this information. 40 * 41 * Note that the {@link PrecomputedText} created from different parameters of the target {@link 42 * android.widget.TextView} will be rejected internally and compute the text layout again with the 43 * current {@link android.widget.TextView} parameters. 44 * 45 * <pre> 46 * An example usage is: 47 * <code> 48 * static void asyncSetText(TextView textView, final String longString, Executor bgExecutor) { 49 * // construct precompute related parameters using the TextView that we will set the text on. 50 * final PrecomputedText.Params params = textView.getTextMetricsParams(); 51 * final Reference textViewRef = new WeakReference<>(textView); 52 * bgExecutor.submit(() -> { 53 * TextView textView = textViewRef.get(); 54 * if (textView == null) return; 55 * final PrecomputedText precomputedText = PrecomputedText.create(longString, params); 56 * textView.post(() -> { 57 * TextView textView = textViewRef.get(); 58 * if (textView == null) return; 59 * textView.setText(precomputedText); 60 * }); 61 * }); 62 * } 63 * </code> 64 * </pre> 65 * 66 * Note that the {@link PrecomputedText} created from different parameters of the target 67 * {@link android.widget.TextView} will be rejected. 68 * 69 * Note that any {@link android.text.NoCopySpan} attached to the original text won't be passed to 70 * PrecomputedText. 71 */ 72 public class PrecomputedText implements Spannable { 73 private static final char LINE_FEED = '\n'; 74 75 /** 76 * The information required for building {@link PrecomputedText}. 77 * 78 * Contains information required for precomputing text measurement metadata, so it can be done 79 * in isolation of a {@link android.widget.TextView} or {@link StaticLayout}, when final layout 80 * constraints are not known. 81 */ 82 public static final class Params { 83 // The TextPaint used for measurement. 84 private final @NonNull TextPaint mPaint; 85 86 // The requested text direction. 87 private final @NonNull TextDirectionHeuristic mTextDir; 88 89 // The break strategy for this measured text. 90 private final @Layout.BreakStrategy int mBreakStrategy; 91 92 // The hyphenation frequency for this measured text. 93 private final @Layout.HyphenationFrequency int mHyphenationFrequency; 94 95 /** 96 * A builder for creating {@link Params}. 97 */ 98 public static class Builder { 99 // The TextPaint used for measurement. 100 private final @NonNull TextPaint mPaint; 101 102 // The requested text direction. 103 private TextDirectionHeuristic mTextDir = TextDirectionHeuristics.FIRSTSTRONG_LTR; 104 105 // The break strategy for this measured text. 106 private @Layout.BreakStrategy int mBreakStrategy = Layout.BREAK_STRATEGY_HIGH_QUALITY; 107 108 // The hyphenation frequency for this measured text. 109 private @Layout.HyphenationFrequency int mHyphenationFrequency = 110 Layout.HYPHENATION_FREQUENCY_NORMAL; 111 112 /** 113 * Builder constructor. 114 * 115 * @param paint the paint to be used for drawing 116 */ Builder(@onNull TextPaint paint)117 public Builder(@NonNull TextPaint paint) { 118 mPaint = paint; 119 } 120 121 /** 122 * Set the line break strategy. 123 * 124 * The default value is {@link Layout#BREAK_STRATEGY_HIGH_QUALITY}. 125 * 126 * @param strategy the break strategy 127 * @return this builder, useful for chaining 128 * @see StaticLayout.Builder#setBreakStrategy 129 * @see android.widget.TextView#setBreakStrategy 130 */ setBreakStrategy(@ayout.BreakStrategy int strategy)131 public Builder setBreakStrategy(@Layout.BreakStrategy int strategy) { 132 mBreakStrategy = strategy; 133 return this; 134 } 135 136 /** 137 * Set the hyphenation frequency. 138 * 139 * The default value is {@link Layout#HYPHENATION_FREQUENCY_NORMAL}. 140 * 141 * @param frequency the hyphenation frequency 142 * @return this builder, useful for chaining 143 * @see StaticLayout.Builder#setHyphenationFrequency 144 * @see android.widget.TextView#setHyphenationFrequency 145 */ setHyphenationFrequency(@ayout.HyphenationFrequency int frequency)146 public Builder setHyphenationFrequency(@Layout.HyphenationFrequency int frequency) { 147 mHyphenationFrequency = frequency; 148 return this; 149 } 150 151 /** 152 * Set the text direction heuristic. 153 * 154 * The default value is {@link TextDirectionHeuristics#FIRSTSTRONG_LTR}. 155 * 156 * @param textDir the text direction heuristic for resolving bidi behavior 157 * @return this builder, useful for chaining 158 * @see StaticLayout.Builder#setTextDirection 159 */ setTextDirection(@onNull TextDirectionHeuristic textDir)160 public Builder setTextDirection(@NonNull TextDirectionHeuristic textDir) { 161 mTextDir = textDir; 162 return this; 163 } 164 165 /** 166 * Build the {@link Params}. 167 * 168 * @return the layout parameter 169 */ build()170 public @NonNull Params build() { 171 return new Params(mPaint, mTextDir, mBreakStrategy, mHyphenationFrequency); 172 } 173 } 174 175 // This is public hidden for internal use. 176 // For the external developers, use Builder instead. 177 /** @hide */ Params(@onNull TextPaint paint, @NonNull TextDirectionHeuristic textDir, @Layout.BreakStrategy int strategy, @Layout.HyphenationFrequency int frequency)178 public Params(@NonNull TextPaint paint, @NonNull TextDirectionHeuristic textDir, 179 @Layout.BreakStrategy int strategy, @Layout.HyphenationFrequency int frequency) { 180 mPaint = paint; 181 mTextDir = textDir; 182 mBreakStrategy = strategy; 183 mHyphenationFrequency = frequency; 184 } 185 186 /** 187 * Returns the {@link TextPaint} for this text. 188 * 189 * @return A {@link TextPaint} 190 */ getTextPaint()191 public @NonNull TextPaint getTextPaint() { 192 return mPaint; 193 } 194 195 /** 196 * Returns the {@link TextDirectionHeuristic} for this text. 197 * 198 * @return A {@link TextDirectionHeuristic} 199 */ getTextDirection()200 public @NonNull TextDirectionHeuristic getTextDirection() { 201 return mTextDir; 202 } 203 204 /** 205 * Returns the break strategy for this text. 206 * 207 * @return A line break strategy 208 */ getBreakStrategy()209 public @Layout.BreakStrategy int getBreakStrategy() { 210 return mBreakStrategy; 211 } 212 213 /** 214 * Returns the hyphenation frequency for this text. 215 * 216 * @return A hyphenation frequency 217 */ getHyphenationFrequency()218 public @Layout.HyphenationFrequency int getHyphenationFrequency() { 219 return mHyphenationFrequency; 220 } 221 222 /** @hide */ isSameTextMetricsInternal(@onNull TextPaint paint, @NonNull TextDirectionHeuristic textDir, @Layout.BreakStrategy int strategy, @Layout.HyphenationFrequency int frequency)223 public boolean isSameTextMetricsInternal(@NonNull TextPaint paint, 224 @NonNull TextDirectionHeuristic textDir, @Layout.BreakStrategy int strategy, 225 @Layout.HyphenationFrequency int frequency) { 226 return mTextDir == textDir 227 && mBreakStrategy == strategy 228 && mHyphenationFrequency == frequency 229 && mPaint.equalsForTextMeasurement(paint); 230 } 231 232 /** 233 * Check if the same text layout. 234 * 235 * @return true if this and the given param result in the same text layout 236 */ 237 @Override equals(@ullable Object o)238 public boolean equals(@Nullable Object o) { 239 if (o == this) { 240 return true; 241 } 242 if (o == null || !(o instanceof Params)) { 243 return false; 244 } 245 Params param = (Params) o; 246 return isSameTextMetricsInternal(param.mPaint, param.mTextDir, param.mBreakStrategy, 247 param.mHyphenationFrequency); 248 } 249 250 @Override hashCode()251 public int hashCode() { 252 // TODO: implement MinikinPaint::hashCode and use it to keep consistency with equals. 253 return Objects.hash(mPaint.getTextSize(), mPaint.getTextScaleX(), mPaint.getTextSkewX(), 254 mPaint.getLetterSpacing(), mPaint.getWordSpacing(), mPaint.getFlags(), 255 mPaint.getTextLocales(), mPaint.getTypeface(), 256 mPaint.getFontVariationSettings(), mPaint.isElegantTextHeight(), mTextDir, 257 mBreakStrategy, mHyphenationFrequency); 258 } 259 260 @Override toString()261 public String toString() { 262 return "{" 263 + "textSize=" + mPaint.getTextSize() 264 + ", textScaleX=" + mPaint.getTextScaleX() 265 + ", textSkewX=" + mPaint.getTextSkewX() 266 + ", letterSpacing=" + mPaint.getLetterSpacing() 267 + ", textLocale=" + mPaint.getTextLocales() 268 + ", typeface=" + mPaint.getTypeface() 269 + ", variationSettings=" + mPaint.getFontVariationSettings() 270 + ", elegantTextHeight=" + mPaint.isElegantTextHeight() 271 + ", textDir=" + mTextDir 272 + ", breakStrategy=" + mBreakStrategy 273 + ", hyphenationFrequency=" + mHyphenationFrequency 274 + "}"; 275 } 276 }; 277 278 /** @hide */ 279 public static class ParagraphInfo { 280 public final @IntRange(from = 0) int paragraphEnd; 281 public final @NonNull MeasuredParagraph measured; 282 283 /** 284 * @param paraEnd the end offset of this paragraph 285 * @param measured a measured paragraph 286 */ ParagraphInfo(@ntRangefrom = 0) int paraEnd, @NonNull MeasuredParagraph measured)287 public ParagraphInfo(@IntRange(from = 0) int paraEnd, @NonNull MeasuredParagraph measured) { 288 this.paragraphEnd = paraEnd; 289 this.measured = measured; 290 } 291 }; 292 293 294 // The original text. 295 private final @NonNull SpannableString mText; 296 297 // The inclusive start offset of the measuring target. 298 private final @IntRange(from = 0) int mStart; 299 300 // The exclusive end offset of the measuring target. 301 private final @IntRange(from = 0) int mEnd; 302 303 private final @NonNull Params mParams; 304 305 // The list of measured paragraph info. 306 private final @NonNull ParagraphInfo[] mParagraphInfo; 307 308 /** 309 * Create a new {@link PrecomputedText} which will pre-compute text measurement and glyph 310 * positioning information. 311 * <p> 312 * This can be expensive, so computing this on a background thread before your text will be 313 * presented can save work on the UI thread. 314 * </p> 315 * 316 * Note that any {@link android.text.NoCopySpan} attached to the text won't be passed to the 317 * created PrecomputedText. 318 * 319 * @param text the text to be measured 320 * @param params parameters that define how text will be precomputed 321 * @return A {@link PrecomputedText} 322 */ create(@onNull CharSequence text, @NonNull Params params)323 public static PrecomputedText create(@NonNull CharSequence text, @NonNull Params params) { 324 ParagraphInfo[] paraInfo = createMeasuredParagraphs( 325 text, params, 0, text.length(), true /* computeLayout */); 326 return new PrecomputedText(text, 0, text.length(), params, paraInfo); 327 } 328 329 /** @hide */ createMeasuredParagraphs( @onNull CharSequence text, @NonNull Params params, @IntRange(from = 0) int start, @IntRange(from = 0) int end, boolean computeLayout)330 public static ParagraphInfo[] createMeasuredParagraphs( 331 @NonNull CharSequence text, @NonNull Params params, 332 @IntRange(from = 0) int start, @IntRange(from = 0) int end, boolean computeLayout) { 333 ArrayList<ParagraphInfo> result = new ArrayList<>(); 334 335 Preconditions.checkNotNull(text); 336 Preconditions.checkNotNull(params); 337 final boolean needHyphenation = params.getBreakStrategy() != Layout.BREAK_STRATEGY_SIMPLE 338 && params.getHyphenationFrequency() != Layout.HYPHENATION_FREQUENCY_NONE; 339 340 int paraEnd = 0; 341 for (int paraStart = start; paraStart < end; paraStart = paraEnd) { 342 paraEnd = TextUtils.indexOf(text, LINE_FEED, paraStart, end); 343 if (paraEnd < 0) { 344 // No LINE_FEED(U+000A) character found. Use end of the text as the paragraph 345 // end. 346 paraEnd = end; 347 } else { 348 paraEnd++; // Includes LINE_FEED(U+000A) to the prev paragraph. 349 } 350 351 result.add(new ParagraphInfo(paraEnd, MeasuredParagraph.buildForStaticLayout( 352 params.getTextPaint(), text, paraStart, paraEnd, params.getTextDirection(), 353 needHyphenation, computeLayout, null /* no recycle */))); 354 } 355 return result.toArray(new ParagraphInfo[result.size()]); 356 } 357 358 // Use PrecomputedText.create instead. PrecomputedText(@onNull CharSequence text, @IntRange(from = 0) int start, @IntRange(from = 0) int end, @NonNull Params params, @NonNull ParagraphInfo[] paraInfo)359 private PrecomputedText(@NonNull CharSequence text, @IntRange(from = 0) int start, 360 @IntRange(from = 0) int end, @NonNull Params params, 361 @NonNull ParagraphInfo[] paraInfo) { 362 mText = new SpannableString(text, true /* ignoreNoCopySpan */); 363 mStart = start; 364 mEnd = end; 365 mParams = params; 366 mParagraphInfo = paraInfo; 367 } 368 369 /** 370 * Return the underlying text. 371 * @hide 372 */ getText()373 public @NonNull CharSequence getText() { 374 return mText; 375 } 376 377 /** 378 * Returns the inclusive start offset of measured region. 379 * @hide 380 */ getStart()381 public @IntRange(from = 0) int getStart() { 382 return mStart; 383 } 384 385 /** 386 * Returns the exclusive end offset of measured region. 387 * @hide 388 */ getEnd()389 public @IntRange(from = 0) int getEnd() { 390 return mEnd; 391 } 392 393 /** 394 * Returns the layout parameters used to measure this text. 395 */ getParams()396 public @NonNull Params getParams() { 397 return mParams; 398 } 399 400 /** 401 * Returns the count of paragraphs. 402 */ getParagraphCount()403 public @IntRange(from = 0) int getParagraphCount() { 404 return mParagraphInfo.length; 405 } 406 407 /** 408 * Returns the paragraph start offset of the text. 409 */ getParagraphStart(@ntRangefrom = 0) int paraIndex)410 public @IntRange(from = 0) int getParagraphStart(@IntRange(from = 0) int paraIndex) { 411 Preconditions.checkArgumentInRange(paraIndex, 0, getParagraphCount(), "paraIndex"); 412 return paraIndex == 0 ? mStart : getParagraphEnd(paraIndex - 1); 413 } 414 415 /** 416 * Returns the paragraph end offset of the text. 417 */ getParagraphEnd(@ntRangefrom = 0) int paraIndex)418 public @IntRange(from = 0) int getParagraphEnd(@IntRange(from = 0) int paraIndex) { 419 Preconditions.checkArgumentInRange(paraIndex, 0, getParagraphCount(), "paraIndex"); 420 return mParagraphInfo[paraIndex].paragraphEnd; 421 } 422 423 /** @hide */ getMeasuredParagraph(@ntRangefrom = 0) int paraIndex)424 public @NonNull MeasuredParagraph getMeasuredParagraph(@IntRange(from = 0) int paraIndex) { 425 return mParagraphInfo[paraIndex].measured; 426 } 427 428 /** @hide */ getParagraphInfo()429 public @NonNull ParagraphInfo[] getParagraphInfo() { 430 return mParagraphInfo; 431 } 432 433 /** 434 * Returns true if the given TextPaint gives the same result of text layout for this text. 435 * @hide 436 */ canUseMeasuredResult(@ntRangefrom = 0) int start, @IntRange(from = 0) int end, @NonNull TextDirectionHeuristic textDir, @NonNull TextPaint paint, @Layout.BreakStrategy int strategy, @Layout.HyphenationFrequency int frequency)437 public boolean canUseMeasuredResult(@IntRange(from = 0) int start, @IntRange(from = 0) int end, 438 @NonNull TextDirectionHeuristic textDir, @NonNull TextPaint paint, 439 @Layout.BreakStrategy int strategy, @Layout.HyphenationFrequency int frequency) { 440 final TextPaint mtPaint = mParams.getTextPaint(); 441 return mStart == start 442 && mEnd == end 443 && mParams.isSameTextMetricsInternal(paint, textDir, strategy, frequency); 444 } 445 446 /** @hide */ findParaIndex(@ntRangefrom = 0) int pos)447 public int findParaIndex(@IntRange(from = 0) int pos) { 448 // TODO: Maybe good to remove paragraph concept from PrecomputedText and add substring 449 // layout support to StaticLayout. 450 for (int i = 0; i < mParagraphInfo.length; ++i) { 451 if (pos < mParagraphInfo[i].paragraphEnd) { 452 return i; 453 } 454 } 455 throw new IndexOutOfBoundsException( 456 "pos must be less than " + mParagraphInfo[mParagraphInfo.length - 1].paragraphEnd 457 + ", gave " + pos); 458 } 459 460 /** 461 * Returns text width for the given range. 462 * Both {@code start} and {@code end} offset need to be in the same paragraph, otherwise 463 * IllegalArgumentException will be thrown. 464 * 465 * @param start the inclusive start offset in the text 466 * @param end the exclusive end offset in the text 467 * @return the text width 468 * @throws IllegalArgumentException if start and end offset are in the different paragraph. 469 */ getWidth(@ntRangefrom = 0) int start, @IntRange(from = 0) int end)470 public @FloatRange(from = 0) float getWidth(@IntRange(from = 0) int start, 471 @IntRange(from = 0) int end) { 472 Preconditions.checkArgument(0 <= start && start <= mText.length(), "invalid start offset"); 473 Preconditions.checkArgument(0 <= end && end <= mText.length(), "invalid end offset"); 474 Preconditions.checkArgument(start <= end, "start offset can not be larger than end offset"); 475 476 if (start == end) { 477 return 0; 478 } 479 final int paraIndex = findParaIndex(start); 480 final int paraStart = getParagraphStart(paraIndex); 481 final int paraEnd = getParagraphEnd(paraIndex); 482 if (start < paraStart || paraEnd < end) { 483 throw new IllegalArgumentException("Cannot measured across the paragraph:" 484 + "para: (" + paraStart + ", " + paraEnd + "), " 485 + "request: (" + start + ", " + end + ")"); 486 } 487 return getMeasuredParagraph(paraIndex).getWidth(start - paraStart, end - paraStart); 488 } 489 490 /** 491 * Retrieves the text bounding box for the given range. 492 * Both {@code start} and {@code end} offset need to be in the same paragraph, otherwise 493 * IllegalArgumentException will be thrown. 494 * 495 * @param start the inclusive start offset in the text 496 * @param end the exclusive end offset in the text 497 * @param bounds the output rectangle 498 * @throws IllegalArgumentException if start and end offset are in the different paragraph. 499 */ getBounds(@ntRangefrom = 0) int start, @IntRange(from = 0) int end, @NonNull Rect bounds)500 public void getBounds(@IntRange(from = 0) int start, @IntRange(from = 0) int end, 501 @NonNull Rect bounds) { 502 Preconditions.checkArgument(0 <= start && start <= mText.length(), "invalid start offset"); 503 Preconditions.checkArgument(0 <= end && end <= mText.length(), "invalid end offset"); 504 Preconditions.checkArgument(start <= end, "start offset can not be larger than end offset"); 505 Preconditions.checkNotNull(bounds); 506 if (start == end) { 507 bounds.set(0, 0, 0, 0); 508 return; 509 } 510 final int paraIndex = findParaIndex(start); 511 final int paraStart = getParagraphStart(paraIndex); 512 final int paraEnd = getParagraphEnd(paraIndex); 513 if (start < paraStart || paraEnd < end) { 514 throw new IllegalArgumentException("Cannot measured across the paragraph:" 515 + "para: (" + paraStart + ", " + paraEnd + "), " 516 + "request: (" + start + ", " + end + ")"); 517 } 518 getMeasuredParagraph(paraIndex).getBounds(start - paraStart, end - paraStart, bounds); 519 } 520 521 /** 522 * Returns the size of native PrecomputedText memory usage. 523 * 524 * Note that this is not guaranteed to be accurate. Must be used only for testing purposes. 525 * @hide 526 */ getMemoryUsage()527 public int getMemoryUsage() { 528 int r = 0; 529 for (int i = 0; i < getParagraphCount(); ++i) { 530 r += getMeasuredParagraph(i).getMemoryUsage(); 531 } 532 return r; 533 } 534 535 /////////////////////////////////////////////////////////////////////////////////////////////// 536 // Spannable overrides 537 // 538 // Do not allow to modify MetricAffectingSpan 539 540 /** 541 * @throws IllegalArgumentException if {@link MetricAffectingSpan} is specified. 542 */ 543 @Override setSpan(Object what, int start, int end, int flags)544 public void setSpan(Object what, int start, int end, int flags) { 545 if (what instanceof MetricAffectingSpan) { 546 throw new IllegalArgumentException( 547 "MetricAffectingSpan can not be set to PrecomputedText."); 548 } 549 mText.setSpan(what, start, end, flags); 550 } 551 552 /** 553 * @throws IllegalArgumentException if {@link MetricAffectingSpan} is specified. 554 */ 555 @Override removeSpan(Object what)556 public void removeSpan(Object what) { 557 if (what instanceof MetricAffectingSpan) { 558 throw new IllegalArgumentException( 559 "MetricAffectingSpan can not be removed from PrecomputedText."); 560 } 561 mText.removeSpan(what); 562 } 563 564 /////////////////////////////////////////////////////////////////////////////////////////////// 565 // Spanned overrides 566 // 567 // Just proxy for underlying mText if appropriate. 568 569 @Override getSpans(int start, int end, Class<T> type)570 public <T> T[] getSpans(int start, int end, Class<T> type) { 571 return mText.getSpans(start, end, type); 572 } 573 574 @Override getSpanStart(Object tag)575 public int getSpanStart(Object tag) { 576 return mText.getSpanStart(tag); 577 } 578 579 @Override getSpanEnd(Object tag)580 public int getSpanEnd(Object tag) { 581 return mText.getSpanEnd(tag); 582 } 583 584 @Override getSpanFlags(Object tag)585 public int getSpanFlags(Object tag) { 586 return mText.getSpanFlags(tag); 587 } 588 589 @Override nextSpanTransition(int start, int limit, Class type)590 public int nextSpanTransition(int start, int limit, Class type) { 591 return mText.nextSpanTransition(start, limit, type); 592 } 593 594 /////////////////////////////////////////////////////////////////////////////////////////////// 595 // CharSequence overrides. 596 // 597 // Just proxy for underlying mText. 598 599 @Override length()600 public int length() { 601 return mText.length(); 602 } 603 604 @Override charAt(int index)605 public char charAt(int index) { 606 return mText.charAt(index); 607 } 608 609 @Override subSequence(int start, int end)610 public CharSequence subSequence(int start, int end) { 611 return PrecomputedText.create(mText.subSequence(start, end), mParams); 612 } 613 614 @Override toString()615 public String toString() { 616 return mText.toString(); 617 } 618 } 619