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