PrecomputedText.java revision fb0abe1feb2cadcee2a3edb2028e1da04bb8a8d5
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
17package android.text;
18
19import android.annotation.FloatRange;
20import android.annotation.IntRange;
21import android.annotation.NonNull;
22import android.annotation.Nullable;
23import android.graphics.Rect;
24import android.text.style.MetricAffectingSpan;
25
26import com.android.internal.util.Preconditions;
27
28import java.util.ArrayList;
29import 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 */
72public 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             */
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             */
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             */
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             */
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             */
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 */
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         */
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         */
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         */
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         */
218        public @Layout.HyphenationFrequency int getHyphenationFrequency() {
219            return mHyphenationFrequency;
220        }
221
222        /** @hide */
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
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
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
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         */
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     */
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 */
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.
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     */
373    public @NonNull CharSequence getText() {
374        return mText;
375    }
376
377    /**
378     * Returns the inclusive start offset of measured region.
379     * @hide
380     */
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     */
389    public @IntRange(from = 0) int getEnd() {
390        return mEnd;
391    }
392
393    /**
394     * Returns the layout parameters used to measure this text.
395     */
396    public @NonNull Params getParams() {
397        return mParams;
398    }
399
400    /**
401     * Returns the count of paragraphs.
402     */
403    public @IntRange(from = 0) int getParagraphCount() {
404        return mParagraphInfo.length;
405    }
406
407    /**
408     * Returns the paragraph start offset of the text.
409     */
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     */
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 */
424    public @NonNull MeasuredParagraph getMeasuredParagraph(@IntRange(from = 0) int paraIndex) {
425        return mParagraphInfo[paraIndex].measured;
426    }
427
428    /** @hide */
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     */
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 */
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     */
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     */
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     */
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
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
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
570    public <T> T[] getSpans(int start, int end, Class<T> type) {
571        return mText.getSpans(start, end, type);
572    }
573
574    @Override
575    public int getSpanStart(Object tag) {
576        return mText.getSpanStart(tag);
577    }
578
579    @Override
580    public int getSpanEnd(Object tag) {
581        return mText.getSpanEnd(tag);
582    }
583
584    @Override
585    public int getSpanFlags(Object tag) {
586        return mText.getSpanFlags(tag);
587    }
588
589    @Override
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
600    public int length() {
601        return mText.length();
602    }
603
604    @Override
605    public char charAt(int index) {
606        return mText.charAt(index);
607    }
608
609    @Override
610    public CharSequence subSequence(int start, int end) {
611        return PrecomputedText.create(mText.subSequence(start, end), mParams);
612    }
613
614    @Override
615    public String toString() {
616        return mText.toString();
617    }
618}
619