1 /* 2 * Copyright 2023 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.foundation.text.modifiers 18 19 import androidx.compose.foundation.text.DefaultMinLines 20 import androidx.compose.foundation.text.ceilToIntPx 21 import androidx.compose.ui.text.AnnotatedString 22 import androidx.compose.ui.text.MultiParagraph 23 import androidx.compose.ui.text.MultiParagraphIntrinsics 24 import androidx.compose.ui.text.Paragraph 25 import androidx.compose.ui.text.ParagraphIntrinsics 26 import androidx.compose.ui.text.TextLayoutInput 27 import androidx.compose.ui.text.TextLayoutResult 28 import androidx.compose.ui.text.TextStyle 29 import androidx.compose.ui.text.font.FontFamily 30 import androidx.compose.ui.text.resolveDefaults 31 import androidx.compose.ui.text.style.TextOverflow 32 import androidx.compose.ui.unit.Constraints 33 import androidx.compose.ui.unit.Density 34 import androidx.compose.ui.unit.IntSize 35 import androidx.compose.ui.unit.LayoutDirection 36 import androidx.compose.ui.unit.constrain 37 import kotlin.math.min 38 39 /** 40 * Performs text layout using [Paragraph]. 41 * 42 * Results are cached whenever possible, for example when only constraints change in a way that 43 * cannot reflow text. 44 * 45 * All measurements are cached. 46 */ 47 internal class ParagraphLayoutCache( 48 private var text: String, 49 private var style: TextStyle, 50 private var fontFamilyResolver: FontFamily.Resolver, 51 private var overflow: TextOverflow = TextOverflow.Clip, 52 private var softWrap: Boolean = true, 53 private var maxLines: Int = Int.MAX_VALUE, 54 private var minLines: Int = DefaultMinLines 55 ) { 56 57 /** 58 * Density is an interface which makes it behave like a provider, rather than a final class. 59 * Whenever Density changes, the object itself may remain the same, making the below density 60 * variable mutate internally. This value holds the last seen density whenever Compose sends us 61 * a Density may have changed notification via layout or draw phase. 62 */ 63 private var lastDensity: InlineDensity = InlineDensity.Unspecified 64 65 /** Density that text layout is performed in */ 66 internal var density: Density? = null 67 set(value) { 68 val localField = field <lambda>null69 val newDensity = value?.let { InlineDensity(it) } ?: InlineDensity.Unspecified 70 if (localField == null) { 71 field = value 72 lastDensity = newDensity 73 return 74 } 75 76 if (value == null || lastDensity != newDensity) { 77 field = value 78 lastDensity = newDensity 79 markDirty() 80 } 81 } 82 83 /** Read to set up a snapshot observer observe changes to fonts. */ 84 internal val observeFontChanges: Unit 85 get() { 86 paragraphIntrinsics?.hasStaleResolvedFonts 87 } 88 89 /** The last computed paragraph */ 90 internal var paragraph: Paragraph? = null 91 92 /** The text did overflow */ 93 internal var didOverflow: Boolean = false 94 95 /** The last computed layout size (as would have been reported in TextLayoutResult) */ 96 internal var layoutSize: IntSize = IntSize(0, 0) 97 98 /** Convert min max lines into actual constraints */ 99 private var mMinLinesConstrainer: MinLinesConstrainer? = null 100 101 /** [ParagraphIntrinsics] will be initialized lazily */ 102 private var paragraphIntrinsics: ParagraphIntrinsics? = null 103 104 /** [LayoutDirection] used to compute [ParagraphIntrinsics] */ 105 private var intrinsicsLayoutDirection: LayoutDirection? = null 106 107 /** Constraints passed to last layout. */ 108 private var prevConstraints: Constraints = Constraints.fixed(0, 0) 109 110 /** Input width for the last call to [intrinsicHeight] */ 111 private var cachedIntrinsicHeightInputWidth: Int = -1 112 113 /** Output height for last call to [intrinsicHeight] at [cachedIntrinsicHeightInputWidth] */ 114 private var cachedIntrinsicHeight: Int = -1 115 116 /** 117 * Update layout constraints for this text 118 * 119 * @return true if constraints caused a text layout invalidation 120 */ layoutWithConstraintsnull121 fun layoutWithConstraints(constraints: Constraints, layoutDirection: LayoutDirection): Boolean { 122 val finalConstraints = 123 if (minLines > 1) { 124 useMinLinesConstrainer(constraints, layoutDirection) 125 } else { 126 constraints 127 } 128 129 if (!newLayoutWillBeDifferent(finalConstraints, layoutDirection)) { 130 if (finalConstraints != prevConstraints) { 131 // ensure size and overflow is still accurate 132 val localParagraph = paragraph!! 133 val layoutWidth = min(localParagraph.maxIntrinsicWidth, localParagraph.width) 134 val localSize = 135 finalConstraints.constrain( 136 IntSize(layoutWidth.ceilToIntPx(), localParagraph.height.ceilToIntPx()) 137 ) 138 layoutSize = localSize 139 didOverflow = 140 overflow != TextOverflow.Visible && 141 (localSize.width < localParagraph.width || 142 localSize.height < localParagraph.height) 143 prevConstraints = finalConstraints 144 } 145 return false 146 } 147 148 paragraph = 149 layoutText(finalConstraints, layoutDirection).also { 150 prevConstraints = finalConstraints 151 val localSize = 152 finalConstraints.constrain( 153 IntSize(it.width.ceilToIntPx(), it.height.ceilToIntPx()) 154 ) 155 layoutSize = localSize 156 didOverflow = 157 overflow != TextOverflow.Visible && 158 (localSize.width < it.width || localSize.height < it.height) 159 } 160 return true 161 } 162 useMinLinesConstrainernull163 private fun useMinLinesConstrainer( 164 constraints: Constraints, 165 layoutDirection: LayoutDirection, 166 style: TextStyle = this.style 167 ): Constraints { 168 val localMin = 169 MinLinesConstrainer.from( 170 mMinLinesConstrainer, 171 layoutDirection, 172 style, 173 density!!, 174 fontFamilyResolver 175 ) 176 .also { mMinLinesConstrainer = it } 177 return localMin.coerceMinLines(inConstraints = constraints, minLines = minLines) 178 } 179 180 /** The natural height of text at [width] in [layoutDirection] */ intrinsicHeightnull181 fun intrinsicHeight(width: Int, layoutDirection: LayoutDirection): Int { 182 val localWidth = cachedIntrinsicHeightInputWidth 183 val localHeght = cachedIntrinsicHeight 184 if (width == localWidth && localWidth != -1) return localHeght 185 val constraints = Constraints(0, width, 0, Constraints.Infinity) 186 val finalConstraints = 187 if (minLines > 1) { 188 useMinLinesConstrainer(constraints, layoutDirection) 189 } else { 190 constraints 191 } 192 val result = 193 layoutText(finalConstraints, layoutDirection) 194 .height 195 .ceilToIntPx() 196 .coerceAtLeast(finalConstraints.minHeight) 197 198 cachedIntrinsicHeightInputWidth = width 199 cachedIntrinsicHeight = result 200 return result 201 } 202 203 /** Call when any parameters change, invalidation is a result of calling this method. */ updatenull204 fun update( 205 text: String, 206 style: TextStyle, 207 fontFamilyResolver: FontFamily.Resolver, 208 overflow: TextOverflow, 209 softWrap: Boolean, 210 maxLines: Int, 211 minLines: Int 212 ) { 213 this.text = text 214 this.style = style 215 this.fontFamilyResolver = fontFamilyResolver 216 this.overflow = overflow 217 this.softWrap = softWrap 218 this.maxLines = maxLines 219 this.minLines = minLines 220 markDirty() 221 } 222 223 /** 224 * Minimum information required to compute [MultiParagraphIntrinsics]. 225 * 226 * After calling paragraphIntrinsics is cached. 227 */ setLayoutDirectionnull228 private fun setLayoutDirection(layoutDirection: LayoutDirection): ParagraphIntrinsics { 229 val localIntrinsics = paragraphIntrinsics 230 val intrinsics = 231 if ( 232 localIntrinsics == null || 233 layoutDirection != intrinsicsLayoutDirection || 234 localIntrinsics.hasStaleResolvedFonts 235 ) { 236 intrinsicsLayoutDirection = layoutDirection 237 ParagraphIntrinsics( 238 text = text, 239 style = resolveDefaults(style, layoutDirection), 240 annotations = listOf(), 241 density = density!!, 242 fontFamilyResolver = fontFamilyResolver, 243 placeholders = listOf() 244 ) 245 } else { 246 localIntrinsics 247 } 248 paragraphIntrinsics = intrinsics 249 return intrinsics 250 } 251 252 /** 253 * Computes the visual position of the glyphs for painting the text. 254 * 255 * The text will layout with a width that's as close to its max intrinsic width as possible 256 * while still being greater than or equal to `minWidth` and less than or equal to `maxWidth`. 257 */ layoutTextnull258 internal fun layoutText(constraints: Constraints, layoutDirection: LayoutDirection): Paragraph { 259 val localParagraphIntrinsics = setLayoutDirection(layoutDirection) 260 261 return Paragraph( 262 paragraphIntrinsics = localParagraphIntrinsics, 263 constraints = 264 finalConstraints( 265 constraints, 266 softWrap, 267 overflow, 268 localParagraphIntrinsics.maxIntrinsicWidth 269 ), 270 maxLines = finalMaxLines(softWrap, overflow, maxLines), 271 overflow = overflow 272 ) 273 } 274 275 /** 276 * Attempt to compute if the new layout will be the same for the given constraints and 277 * layoutDirection. 278 */ newLayoutWillBeDifferentnull279 private fun newLayoutWillBeDifferent( 280 constraints: Constraints, 281 layoutDirection: LayoutDirection 282 ): Boolean { 283 // paragraph and paragraphIntrinsics are from previous run 284 val localParagraph = paragraph ?: return true 285 val localParagraphIntrinsics = paragraphIntrinsics ?: return true 286 // no layout yet 287 288 // async typeface changes 289 if (localParagraphIntrinsics.hasStaleResolvedFonts) return true 290 291 // layout direction changed 292 if (layoutDirection != intrinsicsLayoutDirection) return true 293 294 // if we were passed identical constraints just skip more work 295 if (constraints == prevConstraints) return false 296 297 if (constraints.maxWidth != prevConstraints.maxWidth) return true 298 if (constraints.minWidth != prevConstraints.minWidth) return true 299 300 // if we get here width won't change, height may be clipped 301 if (constraints.maxHeight < localParagraph.height || localParagraph.didExceedMaxLines) { 302 // vertical clip changes 303 return true 304 } 305 306 // breaks can't change, height can't change 307 return false 308 } 309 markDirtynull310 private fun markDirty() { 311 paragraph = null 312 paragraphIntrinsics = null 313 intrinsicsLayoutDirection = null 314 cachedIntrinsicHeightInputWidth = -1 315 cachedIntrinsicHeight = -1 316 prevConstraints = Constraints.fixed(0, 0) 317 layoutSize = IntSize(0, 0) 318 didOverflow = false 319 } 320 321 /** 322 * Compute a [TextLayoutResult] for the current Layout values. 323 * 324 * This does an entire Text layout to produce the result, it is slow. 325 * 326 * Exposed for semantics GetTextLayoutResult 327 */ slowCreateTextLayoutResultOrNullnull328 fun slowCreateTextLayoutResultOrNull(style: TextStyle): TextLayoutResult? { 329 // make sure we're in a valid place 330 val localLayoutDirection = intrinsicsLayoutDirection ?: return null 331 val localDensity = density ?: return null 332 val annotatedString = AnnotatedString(text) 333 paragraph ?: return null 334 paragraphIntrinsics ?: return null 335 val finalConstraints = prevConstraints.copyMaxDimensions() 336 337 // and redo layout with MultiParagraph 338 return TextLayoutResult( 339 TextLayoutInput( 340 annotatedString, 341 style, 342 emptyList(), 343 maxLines, 344 softWrap, 345 overflow, 346 localDensity, 347 localLayoutDirection, 348 fontFamilyResolver, 349 finalConstraints 350 ), 351 MultiParagraph( 352 MultiParagraphIntrinsics( 353 annotatedString = annotatedString, 354 style = style, 355 placeholders = emptyList(), 356 density = localDensity, 357 fontFamilyResolver = fontFamilyResolver 358 ), 359 finalConstraints, 360 maxLines, 361 overflow 362 ), 363 layoutSize 364 ) 365 } 366 367 /** The width for text if all soft wrap opportunities were taken. */ minIntrinsicWidthnull368 fun minIntrinsicWidth(layoutDirection: LayoutDirection): Int { 369 return setLayoutDirection(layoutDirection).minIntrinsicWidth.ceilToIntPx() 370 } 371 372 /** The width at which increasing the width of the text no lonfger decreases the height. */ maxIntrinsicWidthnull373 fun maxIntrinsicWidth(layoutDirection: LayoutDirection): Int { 374 return setLayoutDirection(layoutDirection).maxIntrinsicWidth.ceilToIntPx() 375 } 376 toStringnull377 override fun toString(): String = 378 "ParagraphLayoutCache(paragraph=${if (paragraph != null) "<paragraph>" else "null"}, " + 379 "lastDensity=$lastDensity)" 380 } 381