1 /*
2  * 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
18 
19 import androidx.compose.ui.geometry.Offset
20 import androidx.compose.ui.geometry.Rect
21 import androidx.compose.ui.geometry.Size
22 import androidx.compose.ui.geometry.isUnspecified
23 import androidx.compose.ui.graphics.BlendMode
24 import androidx.compose.ui.graphics.Brush
25 import androidx.compose.ui.graphics.Canvas
26 import androidx.compose.ui.graphics.Color
27 import androidx.compose.ui.graphics.Shadow
28 import androidx.compose.ui.graphics.drawscope.DrawScope
29 import androidx.compose.ui.graphics.drawscope.DrawStyle
30 import androidx.compose.ui.graphics.drawscope.DrawTransform
31 import androidx.compose.ui.graphics.drawscope.Fill
32 import androidx.compose.ui.graphics.drawscope.withTransform
33 import androidx.compose.ui.graphics.isUnspecified
34 import androidx.compose.ui.graphics.takeOrElse
35 import androidx.compose.ui.text.style.TextDecoration
36 import androidx.compose.ui.text.style.TextForegroundStyle.Unspecified
37 import androidx.compose.ui.text.style.TextOverflow
38 import androidx.compose.ui.text.style.modulate
39 import androidx.compose.ui.unit.Constraints
40 import androidx.compose.ui.util.fastRoundToInt
41 import kotlin.math.ceil
42 
43 object TextPainter {
44 
45     // TODO(b/236964276): Deprecate when TextMeasurer and drawText are no longer Experimental
46     /**
47      * Paints the text onto the given canvas.
48      *
49      * @param canvas a canvas to be drawn
50      * @param textLayoutResult a result of text layout
51      */
paintnull52     fun paint(canvas: Canvas, textLayoutResult: TextLayoutResult) {
53         val needClipping =
54             textLayoutResult.hasVisualOverflow &&
55                 textLayoutResult.layoutInput.overflow != TextOverflow.Visible
56         if (needClipping) {
57             val width = textLayoutResult.size.width.toFloat()
58             val height = textLayoutResult.size.height.toFloat()
59             val bounds = Rect(Offset.Zero, Size(width, height))
60             canvas.save()
61             canvas.clipRect(bounds)
62         }
63 
64         /* inline resolveSpanStyleDefaults to avoid an allocation in draw */
65         val style = textLayoutResult.layoutInput.style.spanStyle
66         val textDecoration = style.textDecoration ?: TextDecoration.None
67         val shadow = style.shadow ?: Shadow.None
68         val drawStyle = style.drawStyle ?: Fill
69         try {
70             val brush = style.brush
71             if (brush != null) {
72                 val alpha =
73                     if (style.textForegroundStyle !== Unspecified) {
74                         style.textForegroundStyle.alpha
75                     } else {
76                         1.0f
77                     }
78                 textLayoutResult.multiParagraph.paint(
79                     canvas = canvas,
80                     brush = brush,
81                     alpha = alpha,
82                     shadow = shadow,
83                     decoration = textDecoration,
84                     drawStyle = drawStyle
85                 )
86             } else {
87                 val color =
88                     if (style.textForegroundStyle !== Unspecified) {
89                         style.textForegroundStyle.color
90                     } else {
91                         Color.Black
92                     }
93                 textLayoutResult.multiParagraph.paint(
94                     canvas = canvas,
95                     color = color,
96                     shadow = shadow,
97                     decoration = textDecoration,
98                     drawStyle = drawStyle
99                 )
100             }
101         } finally {
102             if (needClipping) {
103                 canvas.restore()
104             }
105         }
106     }
107 }
108 
109 /**
110  * Draw styled text using a TextMeasurer.
111  *
112  * This draw function supports multi-styling and async font loading.
113  *
114  * TextMeasurer carries an internal cache to optimize text layout measurement for repeated calls in
115  * draw phase. If layout affecting attributes like font size, font weight, overflow, softWrap, etc.
116  * are changed in consecutive calls to this method, TextMeasurer and its internal cache that holds
117  * layout results may not offer any benefits. Check out [TextMeasurer] and drawText overloads that
118  * take [TextLayoutResult] to learn more about text layout and draw phase optimizations.
119  *
120  * @param textMeasurer Measures and lays out the text
121  * @param text Text to be drawn
122  * @param topLeft Offsets the text from top left point of the current coordinate system.
123  * @param style the [TextStyle] to be applied to the text
124  * @param overflow How visual overflow should be handled.
125  * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
126  *   text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
127  *   [overflow] and TextAlign may have unexpected effects.
128  * @param maxLines An optional maximum number of lines for the text to span, wrapping if necessary.
129  *   If the text exceeds the given number of lines, it will be truncated according to [overflow] and
130  *   [softWrap]. If it is not null, then it must be greater than zero.
131  * @param placeholders a list of [Placeholder]s that specify ranges of text which will be skipped
132  *   during layout and replaced with [Placeholder]. It's required that the range of each
133  *   [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is thrown.
134  * @param size how wide and tall the text should be. If left [Size.Unspecified] as its default
135  *   value, text will be forced to fit inside the total drawing area from where it's placed. If size
136  *   is specified, [Size.width] will define the width of the text. [Size.height] helps defining the
137  *   number of lines that fit if [softWrap] is enabled and [overflow] is [TextOverflow.Ellipsis].
138  *   Otherwise, [Size.height] either defines where the text is clipped ([TextOverflow.Clip]) or
139  *   becomes no-op.
140  * @param blendMode Blending algorithm to be applied to the text
141  * @sample androidx.compose.ui.text.samples.DrawTextAnnotatedStringSample
142  */
DrawScopenull143 fun DrawScope.drawText(
144     textMeasurer: TextMeasurer,
145     text: AnnotatedString,
146     topLeft: Offset = Offset.Zero,
147     style: TextStyle = TextStyle.Default,
148     overflow: TextOverflow = TextOverflow.Clip,
149     softWrap: Boolean = true,
150     maxLines: Int = Int.MAX_VALUE,
151     placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(),
152     size: Size = Size.Unspecified,
153     blendMode: BlendMode = DrawScope.DefaultBlendMode
154 ) {
155     val textLayoutResult =
156         textMeasurer.measure(
157             text = text,
158             style = style,
159             overflow = overflow,
160             softWrap = softWrap,
161             maxLines = maxLines,
162             placeholders = placeholders,
163             constraints = textLayoutConstraints(size, topLeft),
164             layoutDirection = layoutDirection,
165             density = this
166         )
167 
168     withTransform({
169         translate(topLeft.x, topLeft.y)
170         clip(textLayoutResult)
171     }) {
172         textLayoutResult.multiParagraph.paint(canvas = drawContext.canvas, blendMode = blendMode)
173     }
174 }
175 
176 /**
177  * Draw text using a TextMeasurer.
178  *
179  * This draw function supports only one text style, and async font loading.
180  *
181  * TextMeasurer carries an internal cache to optimize text layout measurement for repeated calls in
182  * draw phase. If layout affecting attributes like font size, font weight, overflow, softWrap, etc.
183  * are changed in consecutive calls to this method, TextMeasurer and its internal cache that holds
184  * layout results may not offer any benefits. Check out [TextMeasurer] and drawText overloads that
185  * take [TextLayoutResult] to learn more about text layout and draw phase optimizations.
186  *
187  * @param textMeasurer Measures and lays out the text
188  * @param text Text to be drawn
189  * @param topLeft Offsets the text from top left point of the current coordinate system.
190  * @param style the [TextStyle] to be applied to the text
191  * @param overflow How visual overflow should be handled.
192  * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
193  *   text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
194  *   [overflow] and TextAlign may have unexpected effects.
195  * @param maxLines An optional maximum number of lines for the text to span, wrapping if necessary.
196  *   If the text exceeds the given number of lines, it will be truncated according to [overflow] and
197  *   [softWrap]. If it is not null, then it must be greater than zero.
198  * @param size how wide and tall the text should be. If left [Size.Unspecified] as its default
199  *   value, text will be forced to fit inside the total drawing area from where it's placed. If size
200  *   is specified, [Size.width] will define the width of the text. [Size.height] helps defining the
201  *   number of lines that fit if [softWrap] is enabled and [overflow] is [TextOverflow.Ellipsis].
202  *   Otherwise, [Size.height] either defines where the text is clipped ([TextOverflow.Clip]) or
203  *   becomes no-op.
204  * @param blendMode Blending algorithm to be applied to the text
205  * @sample androidx.compose.ui.text.samples.DrawTextSample
206  * @sample androidx.compose.ui.text.samples.DrawTextStyledSample
207  */
DrawScopenull208 fun DrawScope.drawText(
209     textMeasurer: TextMeasurer,
210     text: String,
211     topLeft: Offset = Offset.Zero,
212     style: TextStyle = TextStyle.Default,
213     overflow: TextOverflow = TextOverflow.Clip,
214     softWrap: Boolean = true,
215     maxLines: Int = Int.MAX_VALUE,
216     size: Size = Size.Unspecified,
217     blendMode: BlendMode = DrawScope.DefaultBlendMode
218 ) {
219     val textLayoutResult =
220         textMeasurer.measure(
221             text = AnnotatedString(text),
222             style = style,
223             overflow = overflow,
224             softWrap = softWrap,
225             maxLines = maxLines,
226             constraints = textLayoutConstraints(size, topLeft),
227             layoutDirection = layoutDirection,
228             density = this
229         )
230 
231     withTransform({
232         translate(topLeft.x, topLeft.y)
233         clip(textLayoutResult)
234     }) {
235         textLayoutResult.multiParagraph.paint(canvas = drawContext.canvas, blendMode = blendMode)
236     }
237 }
238 
239 /**
240  * Draw an existing text layout as produced by [TextMeasurer].
241  *
242  * This draw function cannot relayout when async font loading resolves. If using async fonts or
243  * other dynamic text layout, you are responsible for invalidating layout on changes.
244  *
245  * @param textLayoutResult Text Layout to be drawn
246  * @param color Text color to use
247  * @param topLeft Offsets the text from top left point of the current coordinate system.
248  * @param alpha opacity to be applied to the [color] from 0.0f to 1.0f representing fully
249  *   transparent to fully opaque respectively
250  * @param shadow The shadow effect applied on the text.
251  * @param textDecoration The decorations to paint on the text (e.g., an underline).
252  * @param drawStyle Whether or not the text is stroked or filled in.
253  * @param blendMode Blending algorithm to be applied to the text
254  * @sample androidx.compose.ui.text.samples.DrawTextMeasureInLayoutSample
255  * @sample androidx.compose.ui.text.samples.DrawTextDrawWithCacheSample
256  */
DrawScopenull257 fun DrawScope.drawText(
258     textLayoutResult: TextLayoutResult,
259     color: Color = Color.Unspecified,
260     topLeft: Offset = Offset.Zero,
261     alpha: Float = Float.NaN,
262     shadow: Shadow? = null,
263     textDecoration: TextDecoration? = null,
264     drawStyle: DrawStyle? = null,
265     blendMode: BlendMode = DrawScope.DefaultBlendMode
266 ) {
267     val newShadow = shadow ?: textLayoutResult.layoutInput.style.shadow
268     val newTextDecoration = textDecoration ?: textLayoutResult.layoutInput.style.textDecoration
269     val newDrawStyle = drawStyle ?: textLayoutResult.layoutInput.style.drawStyle
270 
271     withTransform({
272         translate(topLeft.x, topLeft.y)
273         clip(textLayoutResult)
274     }) {
275         // if text layout was created using brush, and [color] is unspecified, we should treat this
276         // like drawText(brush) call
277         val brush = textLayoutResult.layoutInput.style.brush
278         if (brush != null && color.isUnspecified) {
279             textLayoutResult.multiParagraph.paint(
280                 drawContext.canvas,
281                 brush,
282                 if (!alpha.isNaN()) alpha else textLayoutResult.layoutInput.style.alpha,
283                 newShadow,
284                 newTextDecoration,
285                 newDrawStyle,
286                 blendMode
287             )
288         } else {
289             textLayoutResult.multiParagraph.paint(
290                 drawContext.canvas,
291                 color.takeOrElse { textLayoutResult.layoutInput.style.color }.modulate(alpha),
292                 newShadow,
293                 newTextDecoration,
294                 newDrawStyle,
295                 blendMode
296             )
297         }
298     }
299 }
300 
301 /**
302  * Draw an existing text layout as produced by [TextMeasurer].
303  *
304  * This draw function cannot relayout when async font loading resolves. If using async fonts or
305  * other dynamic text layout, you are responsible for invalidating layout on changes.
306  *
307  * @param textLayoutResult Text Layout to be drawn
308  * @param brush The brush to use when drawing the text.
309  * @param topLeft Offsets the text from top left point of the current coordinate system.
310  * @param alpha Opacity to be applied to [brush] from 0.0f to 1.0f representing fully transparent to
311  *   fully opaque respectively.
312  * @param shadow The shadow effect applied on the text.
313  * @param textDecoration The decorations to paint on the text (e.g., an underline).
314  * @param drawStyle Whether or not the text is stroked or filled in.
315  * @param blendMode Blending algorithm to be applied to the text
316  */
DrawScopenull317 fun DrawScope.drawText(
318     textLayoutResult: TextLayoutResult,
319     brush: Brush,
320     topLeft: Offset = Offset.Zero,
321     alpha: Float = Float.NaN,
322     shadow: Shadow? = null,
323     textDecoration: TextDecoration? = null,
324     drawStyle: DrawStyle? = null,
325     blendMode: BlendMode = DrawScope.DefaultBlendMode
326 ) {
327     val newShadow = shadow ?: textLayoutResult.layoutInput.style.shadow
328     val newTextDecoration = textDecoration ?: textLayoutResult.layoutInput.style.textDecoration
329     val newDrawStyle = drawStyle ?: textLayoutResult.layoutInput.style.drawStyle
330 
331     withTransform({
332         translate(topLeft.x, topLeft.y)
333         clip(textLayoutResult)
334     }) {
335         textLayoutResult.multiParagraph.paint(
336             drawContext.canvas,
337             brush,
338             if (!alpha.isNaN()) alpha else textLayoutResult.layoutInput.style.alpha,
339             newShadow,
340             newTextDecoration,
341             newDrawStyle,
342             blendMode
343         )
344     }
345 }
346 
DrawTransformnull347 private fun DrawTransform.clip(textLayoutResult: TextLayoutResult) {
348     if (
349         textLayoutResult.hasVisualOverflow &&
350             textLayoutResult.layoutInput.overflow != TextOverflow.Visible
351     ) {
352         clipRect(
353             left = 0f,
354             top = 0f,
355             right = textLayoutResult.size.width.toFloat(),
356             bottom = textLayoutResult.size.height.toFloat()
357         )
358     }
359 }
360 
361 /** Converts given size and placement preferences to Constraints for measuring text layout. */
DrawScopenull362 private fun DrawScope.textLayoutConstraints(size: Size, topLeft: Offset): Constraints {
363     val minWidth: Int
364     val maxWidth: Int
365     val isWidthNaN = size.isUnspecified || size.width.isNaN()
366     if (isWidthNaN) {
367         minWidth = 0
368         maxWidth = ceil(this.size.width - topLeft.x).fastRoundToInt()
369     } else {
370         val fixedWidth = ceil(size.width).fastRoundToInt()
371         minWidth = fixedWidth
372         maxWidth = fixedWidth
373     }
374 
375     val minHeight: Int
376     val maxHeight: Int
377     val isHeightNaN = size.isUnspecified || size.height.isNaN()
378     if (isHeightNaN) {
379         minHeight = 0
380         maxHeight = ceil(this.size.height - topLeft.y).fastRoundToInt()
381     } else {
382         val fixedHeight = ceil(size.height).fastRoundToInt()
383         minHeight = fixedHeight
384         maxHeight = fixedHeight
385     }
386 
387     return Constraints(minWidth, maxWidth, minHeight, maxHeight)
388 }
389