1 /* 2 * Copyright 2024 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 18 19 import androidx.compose.foundation.text.modifiers.TextAutoSizeLayoutScope 20 import androidx.compose.ui.text.AnnotatedString 21 import androidx.compose.ui.text.TextLayoutResult 22 import androidx.compose.ui.text.style.TextOverflow 23 import androidx.compose.ui.unit.Constraints 24 import androidx.compose.ui.unit.TextUnit 25 import androidx.compose.ui.unit.TextUnitType 26 import androidx.compose.ui.unit.sp 27 import kotlin.math.floor 28 29 /** 30 * Interface used by Text composables to override text size to automatically grow or shrink text to 31 * fill the layout bounds. 32 * 33 * @sample androidx.compose.foundation.samples.TextAutoSizeBasicTextSample 34 */ 35 interface TextAutoSize { 36 /** 37 * Calculates font size and provides access to [TextAutoSizeLayoutScope], which offers 38 * [TextAutoSizeLayoutScope.performLayout] to lay out the text and use the measured size. 39 * 40 * @sample androidx.compose.foundation.samples.CustomTextAutoSizeSample 41 * @return The derived optimal font size 42 * @see [TextAutoSizeLayoutScope.performLayout] 43 */ getFontSizenull44 fun TextAutoSizeLayoutScope.getFontSize( 45 constraints: Constraints, 46 text: AnnotatedString 47 ): TextUnit 48 49 /** 50 * This type is used in performance-sensitive paths and requires providing equality guarantees. 51 * Using a data class is sufficient. Singletons may implement this function with referential 52 * equality (`this === other`). Instances with no properties may implement this function by 53 * checking the type of the other object. 54 * 55 * @return true if both AutoSize instances are identical. 56 */ 57 override fun equals(other: Any?): Boolean 58 59 /** 60 * This type is used in performance-sensitive paths and requires providing identity guarantees. 61 * 62 * @return a unique hashcode for this AutoSize instance. 63 */ 64 override fun hashCode(): Int 65 66 companion object { 67 /** 68 * Automatically size the text with the biggest font size that fits the available space. 69 * 70 * When text auto size is performed with [TextOverflow.Ellipsis], 71 * [TextOverflow.StartEllipsis] or [TextOverflow.MiddleEllipsis] (e.g. by specifying 72 * textOverflow on BasicText), this implementation will consider the text to be fitting the 73 * available space if it is *not* ellipsized. 74 * 75 * @param minFontSize The smallest potential font size of the text. Default = 12.sp. This 76 * must be smaller than [maxFontSize]; an [IllegalArgumentException] will be thrown 77 * otherwise. 78 * @param maxFontSize The largest potential font size of the text. Default = 112.sp. This 79 * must be larger than [minFontSize]; an [IllegalArgumentException] will be thrown 80 * otherwise. 81 * @param stepSize The smallest difference between potential font sizes. Specifically, every 82 * font size, when subtracted by [minFontSize], is divisible by [stepSize]. Default = 83 * 0.25.sp. This must not be less than `0.0001f.sp`; an [IllegalArgumentException] will be 84 * thrown otherwise. If [stepSize] is greater than the difference between [minFontSize] 85 * and [maxFontSize], [minFontSize] will be used for the layout. 86 * @return AutoSize instance with the step-based configuration. Using this in a compatible 87 * composable will cause its text to be sized as above. 88 */ 89 fun StepBased( 90 minFontSize: TextUnit = TextAutoSizeDefaults.MinFontSize, 91 maxFontSize: TextUnit = TextAutoSizeDefaults.MaxFontSize, 92 stepSize: TextUnit = 0.25.sp 93 ): TextAutoSize = 94 AutoSizeStepBased( 95 minFontSize = minFontSize, 96 maxFontSize = maxFontSize, 97 stepSize = stepSize 98 ) 99 } 100 } 101 102 /** Contains defaults for [TextAutoSize] APIs. */ 103 object TextAutoSizeDefaults { 104 /** The default minimum font size for [TextAutoSize]. */ 105 val MinFontSize = 12.sp 106 107 /** The default maximum font size for [TextAutoSize]. */ 108 val MaxFontSize = 112.sp 109 } 110 111 private class AutoSizeStepBased( 112 private var minFontSize: TextUnit, 113 private val maxFontSize: TextUnit, 114 private val stepSize: TextUnit 115 ) : TextAutoSize { 116 init { 117 // Checks for validity of AutoSize instance 118 // Unspecified check 119 if (minFontSize == TextUnit.Unspecified) { 120 throw IllegalArgumentException( 121 "AutoSize.StepBased: TextUnit.Unspecified is not a valid value for minFontSize. " + 122 "Try using other values e.g. 10.sp" 123 ) 124 } 125 if (maxFontSize == TextUnit.Unspecified) { 126 throw IllegalArgumentException( 127 "AutoSize.StepBased: TextUnit.Unspecified is not a valid value for maxFontSize. " + 128 "Try using other values e.g. 100.sp" 129 ) 130 } 131 if (stepSize == TextUnit.Unspecified) { 132 throw IllegalArgumentException( 133 "AutoSize.StepBased: TextUnit.Unspecified is not a valid value for stepSize. " + 134 "Try using other values e.g. 0.25.sp" 135 ) 136 } 137 138 // minFontSize maxFontSize comparison check 139 if (minFontSize.type == maxFontSize.type && minFontSize > maxFontSize) { 140 minFontSize = maxFontSize 141 } 142 143 // check if stepSize is too small 144 if (stepSize.type == TextUnitType.Sp && stepSize < 0.0001f.sp) { 145 throw IllegalArgumentException( 146 "AutoSize.StepBased: stepSize must be greater than or equal to 0.0001f.sp" 147 ) 148 } 149 150 // check if minFontSize or maxFontSize are negative 151 if (minFontSize.value < 0) { 152 throw IllegalArgumentException("AutoSize.StepBased: minFontSize must not be negative") 153 } 154 if (maxFontSize.value < 0) { 155 throw IllegalArgumentException("AutoSize.StepBased: maxFontSize must not be negative") 156 } 157 } 158 getFontSizenull159 override fun TextAutoSizeLayoutScope.getFontSize( 160 constraints: Constraints, 161 text: AnnotatedString 162 ): TextUnit { 163 val stepSize = stepSize.toPx() 164 val smallest = minFontSize.toPx() 165 val largest = maxFontSize.toPx() 166 var min = smallest 167 var max = largest 168 169 var current = (min + max) / 2 170 171 while ((max - min) >= stepSize) { 172 val layoutResult = performLayout(constraints, text, current.toSp()) 173 if (layoutResult.didOverflow()) { 174 max = current 175 } else { 176 min = current 177 } 178 current = (min + max) / 2 179 } 180 // used size minus minFontSize must be divisible by stepSize 181 current = (floor((min - smallest) / stepSize) * stepSize + smallest) 182 183 // We have found a size that fits, but we can still try one step up 184 if ((current + stepSize) <= largest) { 185 val layoutResult = performLayout(constraints, text, (current + stepSize).toSp()) 186 if (!layoutResult.didOverflow()) { 187 current += stepSize 188 } 189 } 190 191 return current.toSp() 192 } 193 TextLayoutResultnull194 private fun TextLayoutResult.didOverflow() = 195 when (layoutInput.overflow) { 196 TextOverflow.Clip, 197 TextOverflow.Visible -> didOverflowBounds() 198 TextOverflow.StartEllipsis, 199 TextOverflow.MiddleEllipsis, 200 TextOverflow.Ellipsis -> didOverflowByEllipsize() 201 else -> 202 throw IllegalArgumentException( 203 "TextOverflow type ${layoutInput.overflow} is not supported." 204 ) 205 } 206 didOverflowBoundsnull207 private fun TextLayoutResult.didOverflowBounds() = didOverflowWidth || didOverflowHeight 208 209 /** 210 * Whether a [TextLayoutResult] with any ellipsize [TextOverflow] did overflow, that is: 211 * - [TextOverflow.StartEllipsis] and [TextOverflow.MiddleEllipsis]: if the text is single line, 212 * ellipsize was performed. If it is multiline, the text overflowed (see [TextOverflow]) after 213 * falling back to clip. 214 * - [TextOverflow.Ellipsis]: If ellipsize was performed on the last line of text 215 */ 216 private fun TextLayoutResult.didOverflowByEllipsize(): Boolean = 217 when (lineCount) { 218 0 -> false 219 // Text only gets start- or middle-ellipsized if it is single line, so we can check if 220 // the first line is ellipsized to cover all single-line ellipsis overflow 221 1 -> isLineEllipsized(0) 222 else -> 223 when (layoutInput.overflow) { 224 // If the text is not single line but start or middle ellipsis has been set, 225 // fall 226 // back to the behavior for TextOverflow.Clip 227 TextOverflow.StartEllipsis, 228 TextOverflow.MiddleEllipsis -> didOverflowBounds() 229 // TextOverflow.Ellipsis is supported for multiline text and happens at the end 230 // of 231 // the text, so we only need to check the last line 232 TextOverflow.Ellipsis -> isLineEllipsized(lineCount - 1) 233 else -> false 234 } 235 } 236 equalsnull237 override fun equals(other: Any?): Boolean { 238 if (other === this) return true 239 if (other == null) return false 240 if (other !is AutoSizeStepBased) return false 241 242 if (other.minFontSize != minFontSize) return false 243 if (other.maxFontSize != maxFontSize) return false 244 if (other.stepSize != stepSize) return false 245 246 return true 247 } 248 hashCodenull249 override fun hashCode(): Int { 250 var result = minFontSize.hashCode() 251 result = 31 * result + maxFontSize.hashCode() 252 result = 31 * result + stepSize.hashCode() 253 return result 254 } 255 } 256