1 /*
<lambda>null2 * Copyright 2019 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 androidx.compose.ui.text.android
18
19 import android.text.BoringLayout
20 import android.text.Layout
21 import android.text.SpannableString
22 import android.text.Spanned
23 import android.text.TextPaint
24 import android.text.style.CharacterStyle
25 import android.text.style.MetricAffectingSpan
26 import androidx.compose.ui.text.android.style.LetterSpacingSpanEm
27 import androidx.compose.ui.text.android.style.LetterSpacingSpanPx
28 import java.text.BreakIterator
29 import java.util.PriorityQueue
30 import kotlin.math.ceil
31
32 /**
33 * Flag for applying the fix for [b/346918500](https://issuetracker.google.com/346918500).
34 *
35 * If true, this will allocate a new [SpannableString] if there are spans that must be removed
36 * before measuring any intrinsic width.
37 */
38 @Suppress("MayBeConstant") // Don't inline so folks can R8 assumevalues it
39 private val stripNonMetricAffectingCharSpans: Boolean = true
40
41 /** Computes and caches the text layout intrinsic values such as min/max width. */
42 internal class LayoutIntrinsics(
43 private val charSequence: CharSequence,
44 private val textPaint: TextPaint,
45 @LayoutCompat.TextDirection private val textDirectionHeuristic: Int
46 ) {
47
48 private var _maxIntrinsicWidth: Float = Float.NaN
49 private var _minIntrinsicWidth: Float = Float.NaN
50 private var _boringMetrics: BoringLayout.Metrics? = null
51 private var boringMetricsIsInit: Boolean = false
52
53 private var _charSequenceForIntrinsicWidth: CharSequence? = null
54 private val charSequenceForIntrinsicWidth: CharSequence
55 get() =
56 if (_charSequenceForIntrinsicWidth == null) {
57 if (stripNonMetricAffectingCharSpans) {
58 stripNonMetricAffectingCharacterStyleSpans(charSequence).also {
59 _charSequenceForIntrinsicWidth = it
60 }
61 } else {
62 charSequence
63 }
64 } else {
65 _charSequenceForIntrinsicWidth!!
66 }
67
68 /**
69 * Compute Android platform BoringLayout metrics. A null value means the provided CharSequence
70 * cannot be laid out using a BoringLayout.
71 */
72 val boringMetrics: BoringLayout.Metrics?
73 get() {
74 if (!boringMetricsIsInit) {
75 val frameworkTextDir = getTextDirectionHeuristic(textDirectionHeuristic)
76 _boringMetrics =
77 BoringLayoutFactory.measure(charSequence, textPaint, frameworkTextDir)
78 boringMetricsIsInit = true
79 }
80 return _boringMetrics
81 }
82
83 /**
84 * Calculate minimum intrinsic width of the CharSequence.
85 *
86 * @see androidx.compose.ui.text.android.minIntrinsicWidth
87 */
88 val minIntrinsicWidth: Float
89 get() =
90 if (!_minIntrinsicWidth.isNaN()) {
91 _minIntrinsicWidth
92 } else {
93 _minIntrinsicWidth = computeMinIntrinsicWidth()
94 _minIntrinsicWidth
95 }
96
97 /**
98 * Returns the word with the longest length. To calculate it in a performant way, it applies a
99 * heuristics where
100 * - it first finds a set of words with the longest length
101 * - finds the word with maximum width in that set
102 */
103 private fun computeMinIntrinsicWidth(): Float {
104 val iterator = BreakIterator.getLineInstance(textPaint.textLocale)
105 iterator.text = CharSequenceCharacterIterator(charSequence, 0, charSequence.length)
106
107 // 10 is just a random number that limits the size of the candidate list
108 val heapSize = 10
109 // min heap that will hold [heapSize] many words with max length
110 val longestWordCandidates =
111 PriorityQueue(
112 heapSize,
113 Comparator<Pair<Int, Int>> { left, right ->
114 (left.second - left.first) - (right.second - right.first)
115 }
116 )
117
118 var start = 0
119 var end = iterator.next()
120 while (end != BreakIterator.DONE) {
121 if (longestWordCandidates.size < heapSize) {
122 longestWordCandidates.add(Pair(start, end))
123 } else {
124 longestWordCandidates.peek()?.let { minPair ->
125 if ((minPair.second - minPair.first) < (end - start)) {
126 longestWordCandidates.poll()
127 longestWordCandidates.add(Pair(start, end))
128 }
129 }
130 }
131
132 start = end
133 end = iterator.next()
134 }
135
136 return if (longestWordCandidates.isEmpty()) 0f
137 else longestWordCandidates.maxOf { (start, end) -> getDesiredWidth(start, end) }
138 }
139
140 /**
141 * Calculate maximum intrinsic width for the CharSequence. Maximum intrinsic width is the width
142 * of text where no soft line breaks are applied.
143 */
144 val maxIntrinsicWidth: Float
145 get() =
146 if (!_maxIntrinsicWidth.isNaN()) {
147 _maxIntrinsicWidth
148 } else {
149 _maxIntrinsicWidth = computeMaxIntrinsicWidth()
150 _maxIntrinsicWidth
151 }
152
153 private fun computeMaxIntrinsicWidth(): Float {
154 var desiredWidth = (boringMetrics?.width ?: -1).toFloat()
155
156 // boring metrics doesn't cover RTL text so we fallback to different calculation
157 // when boring metrics can't be calculated
158 if (desiredWidth < 0) {
159 // b/233856978, apply `ceil` function here to be consistent with the boring
160 // metrics width calculation that does it under the hood, too
161 desiredWidth = ceil(getDesiredWidth())
162 }
163
164 if (shouldIncreaseMaxIntrinsic(desiredWidth, charSequence, textPaint)) {
165 // b/173574230, increase maxIntrinsicWidth, so that StaticLayout won't form 2
166 // lines for the given maxIntrinsicWidth
167 desiredWidth += 0.5f
168 }
169 return desiredWidth
170 }
171
172 private fun getDesiredWidth(
173 start: Int = 0,
174 end: Int = charSequenceForIntrinsicWidth.length
175 ): Float = Layout.getDesiredWidth(charSequenceForIntrinsicWidth, start, end, textPaint)
176 }
177
178 /**
179 * See [b/346918500#comment7](https://issuetracker.google.com/346918500#comment7).
180 *
181 * Remove all character styling spans for measuring intrinsic width. [CharacterStyle] spans may
182 * affect the intrinsic width, even though they aren't supposed to, resulting in a width that
183 * doesn't actually fit the text. This can cause the line to unexpectedly wrap, even if `maxLines`
184 * was set to `1` or `softWrap` was `false`.
185 *
186 * [MetricAffectingSpan] extends [CharacterStyle], but [MetricAffectingSpan]s are allowed to affect
187 * the width, so only remove spans that **do** extend [CharacterStyle] but **don't** extend
188 * [MetricAffectingSpan].
189 */
stripNonMetricAffectingCharacterStyleSpansnull190 private fun stripNonMetricAffectingCharacterStyleSpans(charSequence: CharSequence): CharSequence {
191 if (charSequence !is Spanned || !charSequence.hasSpan(CharacterStyle::class.java)) {
192 return charSequence
193 }
194
195 val spans = charSequence.getSpans(0, charSequence.length, CharacterStyle::class.java)
196 if (spans.isNullOrEmpty()) return charSequence
197
198 // Don't allocate a new SpannableString unless we are certain we will be modifying it.
199 var spannableString: SpannableString? = null
200 for (span in spans) {
201 if (span is MetricAffectingSpan) continue
202 if (spannableString == null) spannableString = SpannableString(charSequence)
203 spannableString.removeSpan(span)
204 }
205 return spannableString ?: charSequence
206 }
207
208 /**
209 * b/173574230 on Android 11 and above, creating a StaticLayout when
210 * - desiredWidth is an Integer,
211 * - letterSpacing is set
212 * - lineHeight is set StaticLayout forms 2 lines for the given desiredWidth.
213 *
214 * This function checks if those conditions are met.
215 */
shouldIncreaseMaxIntrinsicnull216 private fun shouldIncreaseMaxIntrinsic(
217 desiredWidth: Float,
218 charSequence: CharSequence,
219 textPaint: TextPaint
220 ): Boolean {
221 return desiredWidth != 0f &&
222 (charSequence is Spanned &&
223 (charSequence.hasSpan(LetterSpacingSpanPx::class.java) ||
224 charSequence.hasSpan(LetterSpacingSpanEm::class.java)) ||
225 textPaint.letterSpacing != 0f)
226 }
227