diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/MainScreen.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/MainScreen.kt index 24906e6ab86b4..0a7e710478d90 100644 --- a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/MainScreen.kt +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/MainScreen.kt @@ -19,6 +19,7 @@ package androidx.compose.mpp.demo import androidx.compose.mpp.demo.bug.BugReproducers import androidx.compose.mpp.demo.components.Components import androidx.compose.mpp.demo.textfield.android.AndroidTextFieldSamples +import androidx.compose.mpp.demo.textfield.android.TextBrushDemo val MainScreen = Screen.Selection( "Demo", @@ -35,4 +36,5 @@ val MainScreen = Screen.Selection( Screen.Example("FontRasterization") { FontRasterization() }, Screen.Example("InteropOrder") { InteropOrder() }, AndroidTextFieldSamples, + Screen.Example("Android TextBrushDemo") { TextBrushDemo() }, ) diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/android/BrushDemo.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/android/BrushDemo.kt index 2d63c1aadf0f6..e8f64c4f063d5 100644 --- a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/android/BrushDemo.kt +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/android/BrushDemo.kt @@ -16,18 +16,236 @@ package androidx.compose.mpp.demo.textfield.android +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Text import androidx.compose.material.TextField -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.TileMode -import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.sp -@OptIn(ExperimentalTextApi::class) +@Composable +fun TextBrushDemo() { + LazyColumn { + item { + TagLine(tag = "Sample") + TextStyleBrushSample() + } + item { + TagLine(tag = "Brush") + BrushDemo() + } + item { + TagLine(tag = "Brush Emojis") + BrushGraphicalEmoji() + } + item { + TagLine(tag = "SingleLine Span Brush") + SingleLineSpanBrush() + } + item { + TagLine(tag = "MultiLine Span Brush") + MultiLineSpanBrush() + } + item { + TagLine(tag = "MultiParagraph Brush") + MultiParagraphBrush() + } + item { + TagLine(tag = "Animated Brush") + AnimatedBrush() + } + item { + TagLine(tag = "Shadow and Brush") + ShadowAndBrush() + } + item { + TagLine(tag = "TextField") + TextFieldBrush() + } + } +} + +@Composable +fun BrushDemo() { + Text( + "Brush is awesome\nBrush is awesome\nBrush is awesome", + style = TextStyle( + brush = Brush.linearGradient( + colors = RainbowColors, + tileMode = TileMode.Mirror + ), + fontSize = 30.sp + ) + ) +} + +@Composable +fun BrushGraphicalEmoji() { + Text( + "\uD83D\uDEF3\uD83D\uDD2E\uD83E\uDDED\uD83E\uDD5D\uD83E\uDD8C\uD83D\uDE0D", + style = TextStyle( + brush = Brush.linearGradient( + colors = RainbowColors, + tileMode = TileMode.Mirror + ) + ), + fontSize = 30.sp + ) +} + +@Composable +fun SingleLineSpanBrush() { + val infiniteTransition = rememberInfiniteTransition() + val start by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 4000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ) + ) + Text( + buildAnnotatedString { + append("Brush is awesome\n") + withStyle( + SpanStyle( + brush = Brush.linearGradient( + colors = RainbowColors, + start = Offset(start, 0f), + tileMode = TileMode.Mirror + ) + ) + ) { + append("Brush is awesome") + } + append("\nBrush is awesome") + }, + fontSize = 30.sp, + ) +} + +@Composable +fun MultiLineSpanBrush() { + Text( + buildAnnotatedString { + append("Brush is aweso") + withStyle( + SpanStyle( + brush = Brush.linearGradient( + colors = RainbowColors, + tileMode = TileMode.Mirror + ) + ) + ) { + append("me\nBrush is awesome\nCo") + } + append("mpose is awesome") + }, + fontSize = 30.sp, + ) +} + +@Composable +fun MultiParagraphBrush() { + Text( + buildAnnotatedString { + withStyle(ParagraphStyle(textAlign = TextAlign.Right)) { + append(loremIpsum(wordCount = 29)) + } + + withStyle(ParagraphStyle(textAlign = TextAlign.Left)) { + append(loremIpsum(wordCount = 29)) + } + }, + style = TextStyle( + brush = Brush.radialGradient( + *RainbowStops.zip(RainbowColors).toTypedArray(), + radius = 600f, + tileMode = TileMode.Mirror + ) + ), + fontSize = 30.sp + ) +} + +@Composable +fun AnimatedBrush() { + val infiniteTransition = rememberInfiniteTransition() + val radius by infiniteTransition.animateFloat( + initialValue = 100f, + targetValue = 300f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ) + ) + val brush = remember { + // postpone the state read to shader creation time which happens during draw. + ShaderBrush { size -> + RadialGradientShader( + center = size.center, + radius = radius, + colors = RainbowColors, + colorStops = RainbowStops, + tileMode = TileMode.Mirror + ) + } + } + Text( + text = loremIpsum(wordCount = 29), + style = TextStyle( + brush = brush, + fontSize = 30.sp + ) + ) +} + +@Composable +fun ShadowAndBrush() { + Text( + "Brush is awesome", + style = TextStyle( + shadow = Shadow( + offset = Offset(8f, 8f), + blurRadius = 4f, + color = Color.Black + ), + brush = Brush.linearGradient( + colors = RainbowColors, + tileMode = TileMode.Mirror + ) + ), + fontSize = 42.sp + ) +} + @Composable fun TextFieldBrush() { var text by remember { mutableStateOf("Brush is awesome") } @@ -45,7 +263,8 @@ fun TextFieldBrush() { ) } -private val RainbowColors = listOf( +@Suppress("PrimitiveInCollection") +internal val RainbowColors = listOf( Color(0xff9c4f96), Color(0xffff6355), Color(0xfffba949), @@ -53,3 +272,12 @@ private val RainbowColors = listOf( Color(0xff8bd448), Color(0xff2aa8f2) ) + +@Suppress("PrimitiveInCollection") +internal val RainbowStops = listOf(0f, 0.2f, 0.4f, 0.6f, 0.8f, 1f) + +private fun ShaderBrush(block: (Size) -> Shader): ShaderBrush { + return object : ShaderBrush() { + override fun createShader(size: Size): Shader = block(size) + } +} diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/android/TextStyleSamples.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/android/TextStyleSamples.kt new file mode 100644 index 0000000000000..c5a7774bf9e3d --- /dev/null +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/android/TextStyleSamples.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.mpp.demo.textfield.android + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +@Composable +fun TextStyleSample() { + Text( + text = "Demo Text", + style = TextStyle( + color = Color.Red, + fontSize = 16.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.W800, + fontStyle = FontStyle.Italic, + letterSpacing = 0.5.em, + background = Color.LightGray, + textDecoration = TextDecoration.Underline + ) + ) +} + +@Composable +fun TextStyleBrushSample() { + Text( + text = "Demo Text", + style = TextStyle( + brush = Brush.linearGradient(listOf(Color.Red, Color.Blue, Color.Green)), + alpha = 0.8f, + fontSize = 16.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.W800, + fontStyle = FontStyle.Italic, + letterSpacing = 0.5.em, + textDecoration = TextDecoration.Underline + ) + ) +} diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/SkiaParagraph.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/SkiaParagraph.skiko.kt index 88fc5bb8d0abd..61b867eb0bca3 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/SkiaParagraph.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/SkiaParagraph.skiko.kt @@ -20,9 +20,16 @@ import org.jetbrains.skia.Rect as SkRect import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.asSkiaPath import androidx.compose.ui.graphics.drawscope.DrawStyle +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toComposeRect import androidx.compose.ui.text.platform.SkiaParagraphIntrinsics import androidx.compose.ui.text.platform.cursorHorizontalPosition import androidx.compose.ui.text.style.LineHeightStyle @@ -34,10 +41,14 @@ import kotlin.math.floor import kotlin.math.roundToInt import org.jetbrains.skia.FontMetrics import org.jetbrains.skia.IRange -import org.jetbrains.skia.paragraph.* +import org.jetbrains.skia.paragraph.Direction +import org.jetbrains.skia.paragraph.LineMetrics +import org.jetbrains.skia.paragraph.RectHeightMode +import org.jetbrains.skia.paragraph.RectWidthMode +import org.jetbrains.skia.paragraph.TextBox internal class SkiaParagraph( - intrinsics: ParagraphIntrinsics, + private val paragraphIntrinsics: SkiaParagraphIntrinsics, val maxLines: Int, val ellipsis: Boolean, val constraints: Constraints @@ -45,8 +56,6 @@ internal class SkiaParagraph( private val ellipsisChar = if (ellipsis) "\u2026" else "" - private val paragraphIntrinsics = intrinsics as SkiaParagraphIntrinsics - private val layouter = paragraphIntrinsics.layouter().apply { setParagraphStyle( maxLines = maxLines, @@ -482,8 +491,8 @@ internal class SkiaParagraph( textDecoration: TextDecoration? ) { paragraph = with(layouter) { + setColor(color) setTextStyle( - color = color, shadow = shadow, textDecoration = textDecoration ) @@ -504,8 +513,8 @@ internal class SkiaParagraph( blendMode: BlendMode ) { paragraph = with(layouter) { + setColor(color) setTextStyle( - color = color, shadow = shadow, textDecoration = textDecoration ) @@ -529,12 +538,14 @@ internal class SkiaParagraph( blendMode: BlendMode ) { paragraph = with(layouter) { - setTextStyle( + setBrush( brush = brush, brushSize = Size(width, height), alpha = alpha, + ) + setTextStyle( shadow = shadow, - textDecoration = textDecoration + textDecoration = textDecoration, ) setDrawStyle(drawStyle) setBlendMode(blendMode) diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.skiko.kt new file mode 100644 index 0000000000000..9220e7df57083 --- /dev/null +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.skiko.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("SkiaParagraph_skikoKt") +@file:JvmMultifileClass + +package androidx.compose.ui.text.platform + +import androidx.compose.ui.text.AnnotatedString.Range +import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.ParagraphIntrinsics +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.SkiaParagraph +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.ceilToInt +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import kotlin.jvm.JvmMultifileClass +import kotlin.jvm.JvmName + +@Suppress("DEPRECATION") +@Deprecated( + "Font.ResourceLoader is deprecated, instead pass FontFamily.Resolver", + replaceWith = ReplaceWith("ActualParagraph(text, style, spanStyles, placeholders, " + + "maxLines, ellipsis, width, density, fontFamilyResolver)"), +) +internal actual fun ActualParagraph( + text: String, + style: TextStyle, + spanStyles: List>, + placeholders: List>, + maxLines: Int, + ellipsis: Boolean, + width: Float, + density: Density, + @Suppress("DEPRECATION") resourceLoader: Font.ResourceLoader +): Paragraph = SkiaParagraph( + SkiaParagraphIntrinsics( + text, + style, + spanStyles, + placeholders, + density, + createFontFamilyResolver(resourceLoader) + ), + maxLines, + ellipsis, + Constraints(maxWidth = width.ceilToInt()) +) + +internal actual fun ActualParagraph( + text: String, + style: TextStyle, + spanStyles: List>, + placeholders: List>, + maxLines: Int, + ellipsis: Boolean, + constraints: Constraints, + density: Density, + fontFamilyResolver: FontFamily.Resolver +): Paragraph = SkiaParagraph( + SkiaParagraphIntrinsics( + text, + style, + spanStyles, + placeholders, + density, + fontFamilyResolver + ), + maxLines, + ellipsis, + constraints +) + +internal actual fun ActualParagraph( + paragraphIntrinsics: ParagraphIntrinsics, + maxLines: Int, + ellipsis: Boolean, + constraints: Constraints +): Paragraph = SkiaParagraph( + paragraphIntrinsics as SkiaParagraphIntrinsics, + maxLines, + ellipsis, + constraints +) diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaParagraph.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt similarity index 81% rename from compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaParagraph.skiko.kt rename to compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt index d6d657587fa8c..31faf9b1ecbc1 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaParagraph.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt @@ -13,6 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +@file:JvmName("SkiaParagraph_skikoKt") +@file:JvmMultifileClass + package androidx.compose.ui.text.platform import org.jetbrains.skia.Font as SkFont @@ -21,165 +25,138 @@ import org.jetbrains.skia.paragraph.Alignment as SkAlignment import org.jetbrains.skia.paragraph.DecorationLineStyle as SkDecorationLineStyle import org.jetbrains.skia.paragraph.DecorationStyle as SkDecorationStyle import org.jetbrains.skia.paragraph.Direction as SkDirection +import org.jetbrains.skia.paragraph.FontRastrSettings as SkFontRastrSettings import org.jetbrains.skia.paragraph.Paragraph as SkParagraph import org.jetbrains.skia.paragraph.ParagraphBuilder as SkParagraphBuilder import org.jetbrains.skia.paragraph.Shadow as SkShadow import org.jetbrains.skia.paragraph.TextIndent as SkTextIndent import org.jetbrains.skia.paragraph.TextStyle as SkTextStyle -import org.jetbrains.skia.paragraph.FontRastrSettings as SkFontRastrSettings import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawStyle -import androidx.compose.ui.text.* import androidx.compose.ui.text.AnnotatedString.Range -import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.FontRasterizationSettings +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextDecorationLineStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.* -import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.WeakKeysCache +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontFamilyResolverImpl import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontSynthesis +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.SkiaFontLoader import androidx.compose.ui.text.intl.LocaleList -import androidx.compose.ui.text.style.* -import androidx.compose.ui.unit.* +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextForegroundStyle +import androidx.compose.ui.text.style.TextGeometricTransform +import androidx.compose.ui.text.toSkFontRastrSettings +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.isUnspecified +import androidx.compose.ui.unit.sp +import kotlin.jvm.JvmMultifileClass +import kotlin.jvm.JvmName import org.jetbrains.skia.FontFeature import org.jetbrains.skia.Paint -import org.jetbrains.skia.paragraph.* +import org.jetbrains.skia.PaintMode +import org.jetbrains.skia.paragraph.BaselineMode +import org.jetbrains.skia.paragraph.HeightMode +import org.jetbrains.skia.paragraph.LineMetrics import org.jetbrains.skia.paragraph.ParagraphStyle +import org.jetbrains.skia.paragraph.PlaceholderAlignment +import org.jetbrains.skia.paragraph.PlaceholderStyle +import org.jetbrains.skia.paragraph.TextBox private val DefaultFontSize = 16.sp -@Suppress("DEPRECATION") -@Deprecated( - "Font.ResourceLoader is deprecated, instead pass FontFamily.Resolver", - replaceWith = ReplaceWith("ActualParagraph(text, style, spanStyles, placeholders, " + - "maxLines, ellipsis, width, density, fontFamilyResolver)"), -) -internal actual fun ActualParagraph( - text: String, - style: TextStyle, - spanStyles: List>, - placeholders: List>, - maxLines: Int, - ellipsis: Boolean, - width: Float, - density: Density, - @Suppress("DEPRECATION") resourceLoader: Font.ResourceLoader -): Paragraph = SkiaParagraph( - SkiaParagraphIntrinsics( - text, - style, - spanStyles, - placeholders, - density, - createFontFamilyResolver(resourceLoader) - ), - maxLines, - ellipsis, - Constraints(maxWidth = width.ceilToInt()) -) - -internal actual fun ActualParagraph( - text: String, - style: TextStyle, - spanStyles: List>, - placeholders: List>, - maxLines: Int, - ellipsis: Boolean, - constraints: Constraints, - density: Density, - fontFamilyResolver: FontFamily.Resolver -): Paragraph = SkiaParagraph( - SkiaParagraphIntrinsics( - text, - style, - spanStyles, - placeholders, - density, - fontFamilyResolver - ), - maxLines, - ellipsis, - constraints -) - -internal actual fun ActualParagraph( - paragraphIntrinsics: ParagraphIntrinsics, - maxLines: Int, - ellipsis: Boolean, - constraints: Constraints -): Paragraph = SkiaParagraph( - paragraphIntrinsics as SkiaParagraphIntrinsics, - maxLines, - ellipsis, - constraints -) - // Computed ComputedStyles always have font/letter size in pixels for particular `density`. // It's important because density could be changed in runtime, and it should force // SkTextStyle to be recalculated. Or we can have different densities in different windows. @OptIn(ExperimentalTextApi::class) -internal data class ComputedStyle( - var textForegroundStyle: TextForegroundStyle, - var brushSize: Size, - var fontSize: Float, - var fontWeight: FontWeight?, - var fontStyle: FontStyle?, - var fontSynthesis: FontSynthesis?, - var fontFamily: FontFamily?, - var fontFeatureSettings: String?, - var letterSpacing: Float?, - var baselineShift: BaselineShift?, - var textGeometricTransform: TextGeometricTransform?, - var localeList: LocaleList?, +private data class ComputedStyle( + var textForegroundStyle: TextForegroundStyle = TextForegroundStyle.Unspecified, + var brushSize: Size = Size.Unspecified, + var fontSize: Float = Float.NaN, + var fontWeight: FontWeight? = null, + var fontStyle: FontStyle? = null, + var fontSynthesis: FontSynthesis? = null, + var fontFamily: FontFamily? = null, + var fontFeatureSettings: String? = null, + var letterSpacing: Float? = null, + var baselineShift: BaselineShift? = null, + var textGeometricTransform: TextGeometricTransform? = null, + var localeList: LocaleList? = null, var background: Color = Color.Unspecified, - var textDecoration: TextDecoration?, - var textDecorationLineStyle: TextDecorationLineStyle?, - var shadow: Shadow?, - var drawStyle: DrawStyle?, - var blendMode: BlendMode, - var lineHeight: Float?, + var textDecoration: TextDecoration? = null, + var textDecorationLineStyle: TextDecorationLineStyle? = null, + var shadow: Shadow? = null, + var drawStyle: DrawStyle? = null, + var blendMode: BlendMode = DrawScope.DefaultBlendMode, + var lineHeight: Float? = null, ) { - constructor( density: Density, spanStyle: SpanStyle, brushSize: Size = Size.Unspecified, blendMode: BlendMode = DrawScope.DefaultBlendMode, lineHeight: TextUnit, - ) : this( - textForegroundStyle = spanStyle.textForegroundStyle, - brushSize = brushSize, - fontSize = with(density) { spanStyle.fontSize.toPx() }, - fontWeight = spanStyle.fontWeight, - fontStyle = spanStyle.fontStyle, - fontSynthesis = spanStyle.fontSynthesis, - fontFamily = spanStyle.fontFamily, - fontFeatureSettings = spanStyle.fontFeatureSettings, - letterSpacing = if (spanStyle.letterSpacing.isSpecified) { + ) : this() { + set(density, spanStyle, brushSize, blendMode, lineHeight) + } + + fun set( + density: Density, + spanStyle: SpanStyle, + brushSize: Size = Size.Unspecified, + blendMode: BlendMode = DrawScope.DefaultBlendMode, + lineHeight: TextUnit, + ) { + this.textForegroundStyle = spanStyle.textForegroundStyle + this.brushSize = brushSize + this.fontSize = with(density) { spanStyle.fontSize.toPx() } + this.fontWeight = spanStyle.fontWeight + this.fontStyle = spanStyle.fontStyle + this.fontSynthesis = spanStyle.fontSynthesis + this.fontFamily = spanStyle.fontFamily + this.fontFeatureSettings = spanStyle.fontFeatureSettings + this.letterSpacing = if (spanStyle.letterSpacing.isSpecified) { with(density) { spanStyle.letterSpacing.toPx() } - } else null, - baselineShift = spanStyle.baselineShift, - textGeometricTransform = spanStyle.textGeometricTransform, - localeList = spanStyle.localeList, - background = spanStyle.background, - textDecoration = spanStyle.textDecoration, - textDecorationLineStyle = spanStyle.platformStyle?.textDecorationLineStyle, - shadow = spanStyle.shadow, - drawStyle = spanStyle.drawStyle, - blendMode = blendMode, - lineHeight = if (lineHeight.isSpecified) { + } else null + this.baselineShift = spanStyle.baselineShift + this.textGeometricTransform = spanStyle.textGeometricTransform + this.localeList = spanStyle.localeList + this.background = spanStyle.background + this.textDecoration = spanStyle.textDecoration + this.textDecorationLineStyle = spanStyle.platformStyle?.textDecorationLineStyle + this.shadow = spanStyle.shadow + this.drawStyle = spanStyle.drawStyle + this.blendMode = blendMode + this.lineHeight = if (lineHeight.isSpecified) { lineHeight.toPx(density, spanStyle.fontSize) - } else null, - ) + } else null + } - private fun toTextPaint(): Paint? = Paint().let { - with(it.asComposePaint()) { - color = textForegroundStyle.color - applyBrush(textForegroundStyle.brush, brushSize, textForegroundStyle.alpha) - applyDrawStyle(drawStyle) - blendMode = this@ComputedStyle.blendMode - return@let it.takeIf { shader != null || style != PaintingStyle.Fill || !it.isSrcOver } - } + private val _foregroundPaint = SkiaTextPaint() + fun getForegroundPaint(): Paint { + // `asFrameworkPaint` doesn't create a copy, + // so all the changes will be applied to skia paint. + val paint = _foregroundPaint.asFrameworkPaint() + paint.reset() + _foregroundPaint.color = textForegroundStyle.color + _foregroundPaint.setBrush(textForegroundStyle.brush, brushSize, textForegroundStyle.alpha) + _foregroundPaint.setDrawStyle(drawStyle) + _foregroundPaint.blendMode = blendMode + return paint } fun toSkTextStyle(fontFamilyResolver: FontFamily.Resolver): SkTextStyle { @@ -187,8 +164,10 @@ internal data class ComputedStyle( if (textForegroundStyle.color.isSpecified) { res.color = textForegroundStyle.color.toArgb() } - val foreground = toTextPaint() - if (foreground != null) { + val foreground = getForegroundPaint() + if (foreground.shader != null || + foreground.mode != PaintMode.FILL || + !foreground.isSrcOver) { res.foreground = foreground } fontStyle?.let { @@ -287,10 +266,24 @@ internal class ParagraphBuilder( var drawStyle: DrawStyle? = null, var blendMode: BlendMode = DrawScope.DefaultBlendMode ) { + private val defaultStyle = ComputedStyle() private lateinit var initialStyle: SpanStyle - private lateinit var defaultStyle: ComputedStyle private lateinit var ops: List + private fun prepareDefaultStyle() { + initialStyle = textStyle.toSpanStyle().copyWithDefaultFontSize( + drawStyle = drawStyle + ) + defaultStyle.set(density, initialStyle, brushSize, blendMode, textStyle.lineHeight) + } + + fun updateForegroundPaint(paragraph: SkParagraph?) { + if (paragraph == null) return + prepareDefaultStyle() + val foregroundPaint = defaultStyle.getForegroundPaint() + paragraph.updateForegroundPaint(0, text.length, foregroundPaint) + } + /** * SkParagraph styles model doesn't match Compose's one. * SkParagraph has only a stack-based push/pop styles interface that works great with Span @@ -302,10 +295,7 @@ internal class ParagraphBuilder( * of active styles is being compiled into single SkParagraph's style for every chunk of text */ fun build(): SkParagraph { - initialStyle = textStyle.toSpanStyle().copyWithDefaultFontSize( - drawStyle = drawStyle - ) - defaultStyle = ComputedStyle(density, initialStyle, brushSize, blendMode, textStyle.lineHeight) + prepareDefaultStyle() ops = makeOps( spanStyles, placeholders diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt index 2ea355118e621..25df889446124 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt @@ -76,11 +76,22 @@ internal class ParagraphLayouter( textDirection = textDirection ) private var paragraphCache: Paragraph? = null + private var updateForeground = false private var width: Float = Float.NaN val defaultFont get() = builder.defaultFont val textStyle get() = builder.textStyle + private fun invalidateParagraph(onlyForeground: Boolean = false) { + // skia's updateForegroundPaint applies the same style to every span, + // so if we have any, we need to rebuild the entire paragraph :'( + if (onlyForeground && builder.spanStyles.isEmpty()) { + updateForeground = true + } else { + paragraphCache = null + } + } + internal fun emptyLineMetrics(paragraph: Paragraph): Array = builder.emptyLineMetrics(paragraph) @@ -93,100 +104,115 @@ internal class ParagraphLayouter( ) { builder.maxLines = maxLines builder.ellipsis = ellipsis - paragraphCache = null - } - } - - fun setBrushSize( - brushSize: Size, - ) { - if (builder.brushSize != brushSize) { - builder.brushSize = brushSize - - // [brushSize] requires only for shader recreation and does not require re-layout, - // but we have to invalidate it because it's backed into skia's paragraph. - // Since it affects only [ShaderBrush] we can keep the cache if it's not used. - if (builder.textStyle.brush is ShaderBrush || - builder.spanStyles.any { it.item.brush is ShaderBrush }) { - paragraphCache = null - } + invalidateParagraph() } } - fun setTextStyle( + fun setColor( color: Color, - shadow: Shadow?, - textDecoration: TextDecoration? ) { val actualColor = color.takeOrElse { builder.textStyle.color } - if (builder.textStyle.color != actualColor || - builder.textStyle.shadow != shadow || - builder.textStyle.textDecoration != textDecoration - ) { + if (builder.textStyle.color != actualColor) { builder.textStyle = builder.textStyle.copy( color = actualColor, - shadow = shadow, - textDecoration = textDecoration ) - paragraphCache = null + invalidateParagraph(onlyForeground = true) } } - @ExperimentalTextApi - fun setTextStyle( + fun setBrush( brush: Brush?, brushSize: Size, alpha: Float, - shadow: Shadow?, - textDecoration: TextDecoration? ) { val actualSize = builder.brushSize if (builder.textStyle.brush != brush || actualSize.isUnspecified || !actualSize.width.sameValueAs(brushSize.width) || !actualSize.height.sameValueAs(brushSize.height) || - !builder.textStyle.alpha.sameValueAs(alpha) || - builder.textStyle.shadow != shadow || - builder.textStyle.textDecoration != textDecoration + !builder.textStyle.alpha.sameValueAs(alpha) ) { builder.textStyle = builder.textStyle.copy( brush = brush, alpha = alpha, - shadow = shadow, - textDecoration = textDecoration ) builder.brushSize = brushSize - paragraphCache = null + invalidateParagraph(onlyForeground = true) + } + } + + fun setBrushSize( + brushSize: Size, + ) { + if (builder.brushSize != brushSize) { + builder.brushSize = brushSize + + // [brushSize] requires only for shader recreation and does not require re-layout, + // but we have to invalidate it because it's backed into skia's paragraph. + // Since it affects only [ShaderBrush] we can keep the cache if it's not used. + if (builder.textStyle.brush is ShaderBrush || + builder.spanStyles.any { it.item.brush is ShaderBrush }) { + invalidateParagraph(onlyForeground = true) + } + } + } + + fun setTextStyle( + shadow: Shadow?, + textDecoration: TextDecoration?, + ) { + if (builder.textStyle.shadow != shadow || + builder.textStyle.textDecoration != textDecoration + ) { + builder.textStyle = builder.textStyle.copy( + shadow = shadow, + textDecoration = textDecoration, + ) + invalidateParagraph() } } fun setDrawStyle(drawStyle: DrawStyle?) { if (builder.drawStyle != drawStyle) { builder.drawStyle = drawStyle - paragraphCache = null + invalidateParagraph(onlyForeground = true) } } fun setBlendMode(blendMode: BlendMode) { if (builder.blendMode != blendMode) { builder.blendMode = blendMode - paragraphCache = null + invalidateParagraph() } } fun layoutParagraph(width: Float): Paragraph { - val paragraph = paragraphCache + var paragraph = paragraphCache return if (paragraph != null) { + var layoutRequired = false + if (updateForeground) { + builder.updateForegroundPaint(paragraph) + updateForeground = false + + // Skia caches everything internally, so to actually apply it + // markDirty + layout is required. + paragraph.markDirty() + layoutRequired = true + } if (!this.width.sameValueAs(width)) { this.width = width + layoutRequired = true + } + if (layoutRequired) { paragraph.layout(width) } paragraph } else { - builder.build().apply { - paragraphCache = this - layout(width) - } + paragraph = builder.build() + paragraph.layout(width) + paragraphCache = paragraph + updateForeground = false + return paragraph } } } diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaTextPaint.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaTextPaint.skiko.kt index bc25d40fd56e8..dda048c9a9d3a 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaTextPaint.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaTextPaint.skiko.kt @@ -16,47 +16,98 @@ package androidx.compose.ui.text.platform +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isSpecified -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.DrawStyle import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.style.modulate // Copied from AndroidTextPaint. -internal fun Paint.applyBrush(brush: Brush?, size: Size, alpha: Float = Float.NaN) { - // if size is unspecified and brush is not null, nothing should be done. - // it basically means brush is given but size is not yet calculated at this time. - if ((brush is SolidColor && brush.value.isSpecified) || - (brush is ShaderBrush && size.isSpecified)) { - // alpha is always applied even if Float.NaN is passed to applyTo function. - // if it's actually Float.NaN, we simply send the current value - brush.applyTo( - size, - this, - if (alpha.isNaN()) 1f else alpha.coerceIn(0f, 1f) - ) - } else if (brush == null) { - shader = null - } -} +internal class SkiaTextPaint : Paint by Paint() { + @VisibleForTesting + internal var brush: Brush? = null + + internal var shaderState: State? = null -internal fun Paint.applyDrawStyle(drawStyle: DrawStyle?) { - when (drawStyle) { - Fill, null -> { - // Stroke properties such as strokeWidth, strokeMiter are not re-set because - // Fill style should make those properties no-op. Next time the style is set - // as Stroke, stroke properties get re-set as well. - style = PaintingStyle.Fill + @VisibleForTesting + internal var brushSize: Size? = null + + fun setBrush(brush: Brush?, size: Size, alpha: Float = Float.NaN) { + when (brush) { + // null brush should just clear the shader and leave `color` as the final decider + // while painting + null -> { + clearShader() + } + // SolidColor brush can be treated just like setting a color. + is SolidColor -> { + if (color.isSpecified) { + this.color = brush.value.modulate(alpha) + clearShader() + } + } + // This is the brush type that we mostly refer to when we talk about brush support. + // Below code is almost equivalent to; + // val this.shaderState = remember(brush, brushSize) { + // derivedStateOf { + // brush.createShader(size) + // } + // } + is ShaderBrush -> { + if (this.brush != brush || this.brushSize != size) { + if (size.isSpecified) { + this.brush = brush + this.brushSize = size + this.shaderState = derivedStateOf { + brush.createShader(size) + } + } + } + this.shader = this.shaderState?.value + this.alpha = if (alpha.isNaN()) 1f else alpha.coerceIn(0f, 1f) + } } - is Stroke -> { - style = PaintingStyle.Stroke - strokeWidth = drawStyle.width - strokeMiterLimit = drawStyle.miter - strokeJoin = drawStyle.join - strokeCap = drawStyle.cap - pathEffect = drawStyle.pathEffect + } + + fun setDrawStyle(drawStyle: DrawStyle?) { + when (drawStyle) { + Fill, null -> { + // Stroke properties such as strokeWidth, strokeMiter are not re-set because + // Fill style should make those properties no-op. Next time the style is set + // as Stroke, stroke properties get re-set as well. + style = PaintingStyle.Fill + } + + is Stroke -> { + style = PaintingStyle.Stroke + strokeWidth = drawStyle.width + strokeMiterLimit = drawStyle.miter + strokeJoin = drawStyle.join + strokeCap = drawStyle.cap + pathEffect = drawStyle.pathEffect + } } } -} \ No newline at end of file + + /** + * Clears all shader related cache parameters and native shader property. + */ + private fun clearShader() { + this.shaderState = null + this.brush = null + this.brushSize = null + this.shader = null + } +}