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