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