diff --git a/README.md b/README.md index caa5265..784fab3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ flow the text content around its children. - Justification - Hyphenation - Generate shapes from images +- Path division (extract multiple contours as a list of paths) - Compatible with API 29+ ## Design Systems @@ -115,6 +116,33 @@ the example below, both justification and hyphenation are enabled: ![Justification and hyphenation](art/screenshot_styles_and_justification.png) +You can also specify multiple shapes for any given element by using the `flowShapes` modifiers +instead of `flowShape`. `flowShapes` accepts/returns list of paths instead of a single path. +This feature can be used in combination with the supporting API `Path.divide` which divides a +path's contours into a list of individual paths. For instance: + +```kotlin +val heartsShapes = heartsBitmap.toContour().asComposePath().divide() + +TextFlow( + SampleText, + style = TextStyle(fontSize = 12.sp), + columns = 2 +) { + Image( + bitmap = heartsBitmap.asImageBitmap(), + contentDescription = "", + modifier = Modifier + .align(Alignment.Center) + .flowShapes(FlowType.Outside, 4.dp, heartsShapes) + ) +} +``` + +The division operation create many shapes around which the text can flow: + +![Multiple shapes per element](art/screenshot_shapes.png) + ## Maven ```gradle @@ -137,8 +165,6 @@ dependencies { - Backport to earlier API levels. - Lines containing styles of different line heights can lead to improper flow around certain shapes. - More comprehensive `TextFlowLayoutResult`. -- Paths with multiple contours are treated as a single shape. A future feature will allow such - paths to be treated as multiple shapes. - Add support to ellipsize the last line when the entire text cannot fit in the layout area. - Add support for text-relative placement of flow shapes. - Implement margins support without relying on `Path.op` which can be excessively expensive with diff --git a/art/screenshot_shapes.png b/art/screenshot_shapes.png new file mode 100644 index 0000000..47955e4 Binary files /dev/null and b/art/screenshot_shapes.png differ diff --git a/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt b/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt index 7418495..671e91f 100644 --- a/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt +++ b/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt @@ -65,6 +65,7 @@ import dev.romainguy.text.combobreaker.FlowType import dev.romainguy.text.combobreaker.TextFlowHyphenation import dev.romainguy.text.combobreaker.TextFlowJustification import dev.romainguy.text.combobreaker.demo.ui.theme.ComboBreakerTheme +import dev.romainguy.text.combobreaker.divide import dev.romainguy.text.combobreaker.material3.TextFlow import dev.romainguy.text.combobreaker.toContour @@ -83,10 +84,20 @@ class ComboBreakerActivity : ComponentActivity() { private fun TextFlowDemo() { val colorScheme = MaterialTheme.colorScheme + var columns by remember { mutableStateOf(2) } + var useMultipleShapes by remember { mutableStateOf(false) } + var useRectangleShapes by remember { mutableStateOf(true) } + var isJustified by remember { mutableStateOf(false) } + var isHyphenated by remember { mutableStateOf(true) } + var isDebugOverlayEnabled by remember { mutableStateOf(true) } + //region Sample text - val sampleText = remember { + val sampleText by remember { derivedStateOf { buildAnnotatedString { withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp)) { + if (useMultipleShapes || !useRectangleShapes) { + append("T") + } append("he Hyphen") } append("\n\n") @@ -135,7 +146,7 @@ class ComboBreakerActivity : ComponentActivity() { append("syllabification in justified texts to avoid unsightly spacing (especially ") append("in columns with narrow line lengths, as when used with newspapers).") } - } + } } //endregion val microphone = remember { @@ -155,11 +166,16 @@ class ComboBreakerActivity : ComponentActivity() { val letterT = remember { BitmapFactory.decodeResource(resources, R.drawable.letter_t) } val landscape = remember { BitmapFactory.decodeResource(resources, R.drawable.landscape) } - var columns by remember { mutableStateOf(2) } - var useRectangleShapes by remember { mutableStateOf(true) } - var isJustified by remember { mutableStateOf(false) } - var isHyphenated by remember { mutableStateOf(true) } - var isDebugOverlayEnabled by remember { mutableStateOf(true) } + val hearts = remember { BitmapFactory.decodeResource(resources, R.drawable.hearts) } + val heartsShape by remember { + derivedStateOf { + if (useMultipleShapes) { + hearts.toContour().asComposePath().divide() + } else { + emptyList() + } + } + } val justification by remember { derivedStateOf { @@ -184,45 +200,61 @@ class ComboBreakerActivity : ComponentActivity() { TextFlow( sampleText, modifier = Modifier.weight(1.0f).fillMaxWidth(), - style = LocalTextStyle.current.merge(TextStyle(color = colorScheme.onSurface)), + style = LocalTextStyle.current.merge( + TextStyle( + color = colorScheme.onSurface, + fontSize = if (useMultipleShapes) 12.sp else LocalTextStyle.current.fontSize + ) + ), justification = justification, hyphenation = hyphenation, columns = columns, debugOverlay = isDebugOverlayEnabled ) { - Image( - bitmap = (if (useRectangleShapes) letterT else microphone).asImageBitmap(), - contentDescription = "", - modifier = Modifier - .offset { - if (useRectangleShapes) - IntOffset(0, 0) - else - Offset(-microphone.width / 4.5f, 0.0f).round() - } - .flowShape( - FlowType.OutsideEnd, - if (useRectangleShapes) 0.dp else 8.dp, - if (useRectangleShapes) null else microphoneShape - ) - ) + if (!useMultipleShapes) { + Image( + bitmap = (if (useRectangleShapes) letterT else microphone).asImageBitmap(), + contentDescription = "", + modifier = Modifier + .offset { + if (useRectangleShapes) + IntOffset(0, 0) + else + Offset(-microphone.width / 4.5f, 0.0f).round() + } + .flowShape( + FlowType.OutsideEnd, + if (useRectangleShapes) 0.dp else 8.dp, + if (useRectangleShapes) null else microphoneShape + ) + ) - Image( - bitmap = (if (useRectangleShapes) landscape else badge).asImageBitmap(), - contentDescription = "", - modifier = Modifier - .align(Alignment.Center) - .flowShape( - FlowType.Outside, - if (useRectangleShapes) 8.dp else 10.dp, - if (useRectangleShapes) null else badgeShape - ) - ) + Image( + bitmap = (if (useRectangleShapes) landscape else badge).asImageBitmap(), + contentDescription = "", + modifier = Modifier + .align(Alignment.Center) + .flowShape( + FlowType.Outside, + if (useRectangleShapes) 8.dp else 10.dp, + if (useRectangleShapes) null else badgeShape + ) + ) + } else { + Image( + bitmap = hearts.asImageBitmap(), + contentDescription = "", + modifier = Modifier + .align(Alignment.Center) + .flowShapes(FlowType.Outside, 2.dp, heartsShape) + ) + } } DemoControls( columns, { columns = it }, useRectangleShapes, { useRectangleShapes = it }, + useMultipleShapes, { useMultipleShapes = it }, isJustified, { isJustified = it}, isHyphenated, { isHyphenated = it}, isDebugOverlayEnabled, { isDebugOverlayEnabled = it}, @@ -235,7 +267,9 @@ class ComboBreakerActivity : ComponentActivity() { columns: Int, onColumnsChanged: (Int) -> Unit, useRectangleShapes: Boolean, - onRectangleShapesChanged: (Boolean) -> Unit, + onUseRectangleShapesChanged: (Boolean) -> Unit, + useMultipleShapes: Boolean, + onUseMultipleShapesChanged: (Boolean) -> Unit, justify: Boolean, onJustifyChanged: (Boolean) -> Unit, hyphenation: Boolean, @@ -254,21 +288,20 @@ class ComboBreakerActivity : ComponentActivity() { ) Spacer(modifier = Modifier.width(8.dp)) - Checkbox(checked = useRectangleShapes, onCheckedChange = onRectangleShapesChanged) - Text(text = "Rectangles") + Checkbox(checked = useRectangleShapes, onCheckedChange = onUseRectangleShapesChanged) + Text(text = "Rects") + + Checkbox(checked = useMultipleShapes, onCheckedChange = onUseMultipleShapesChanged) + Text(text = "Multi") } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = justify, onCheckedChange = onJustifyChanged) Text(text = "Justify") - Spacer(modifier = Modifier.width(8.dp)) - Checkbox(checked = hyphenation, onCheckedChange = onHyphenationChanged) Text(text = "Hyphenate") - Spacer(modifier = Modifier.width(8.dp)) - Checkbox(checked = debugOverlay, onCheckedChange = onDebugOverlayChanged) Text(text = "Debug") } diff --git a/combo-breaker-demo/src/main/res/drawable-xxhdpi/hearts.png b/combo-breaker-demo/src/main/res/drawable-xxhdpi/hearts.png new file mode 100644 index 0000000..768f54b Binary files /dev/null and b/combo-breaker-demo/src/main/res/drawable-xxhdpi/hearts.png differ diff --git a/combo-breaker/build.gradle b/combo-breaker/build.gradle index 5acfb20..98c51ac 100644 --- a/combo-breaker/build.gradle +++ b/combo-breaker/build.gradle @@ -41,6 +41,8 @@ dependencies { implementation platform("androidx.compose:compose-bom:$compose_bom") implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.material3:material3' + + implementation 'dev.romainguy:pathway:0.8.0' } apply plugin: 'com.vanniktech.maven.publish' diff --git a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/BasicTextFlow.kt b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/BasicTextFlow.kt index de25c8f..cc359c0 100644 --- a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/BasicTextFlow.kt +++ b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/BasicTextFlow.kt @@ -387,7 +387,7 @@ fun BasicTextFlow( selfSize ) - buildFlowShape( + buildFlowShapes( measurable, position.toOffset(), size, @@ -504,8 +504,7 @@ internal class TextFlowSate( val shapes: ArrayList = ArrayList() } - -private fun buildFlowShape( +private fun buildFlowShapes( measurable: Measurable, elementPosition: Offset, size: IntSize, @@ -530,15 +529,26 @@ private fun buildFlowShape( textFlowData.position } - val path = Path() - val sourcePath = textFlowData.flowShape(size, boxSize) - if (sourcePath == null) { + val flowType = textFlowData.flowType.resolve(layoutDirection) + val margin = with (density) { textFlowData.margin.toPx() } + + val sourcePaths = textFlowData.flowShapes(size, boxSize) + if (sourcePaths.isEmpty()) { + val path = Path() path.addRect(Rect(position, size.toSize())) + expandAndClipPath(path, margin, clip) + flowShapes += FlowShape(path, flowType) } else { - path.addPath(sourcePath, position) + for (sourcePath in sourcePaths) { + val path = Path() + path.addPath(sourcePath, position) + expandAndClipPath(path, margin, clip) + flowShapes += FlowShape(path, flowType) + } } +} - val margin = with (density) { textFlowData.margin.toPx() } +private fun expandAndClipPath(path: Path, margin: Float, clip: Path) { if (margin > 0.0f) { // Note: see comment below val androidPath = path.asAndroidPath() @@ -557,8 +567,6 @@ private fun buildFlowShape( path .asAndroidPath() .op(clip.asAndroidPath(), android.graphics.Path.Op.INTERSECT) - - flowShapes += FlowShape(path, textFlowData.flowType.resolve(layoutDirection)) } private fun Placeable.PlacementScope.placeElement( @@ -636,12 +644,23 @@ private fun ContentDrawScope.drawDebugInfo( /** * Lambda type used by [TextFlowScope.flowShape] to compute a flow shape defined as a [Path]. + * * The two parameters are: * - `size` The size of the element the flowShape modifier is applied to. * - `textFlowSize` The size of the parent [BasicTextFlow] container. */ typealias FlowShapeProvider = (size: IntSize, textFlowSize: IntSize) -> Path? +/** + * Lambda type used by [TextFlowScope.flowShape] to compute a list of flow shapes defined as [Path] + * instances. + * + * The two parameters are: + * - `size` The size of the element the flowShape modifier is applied to. + * - `textFlowSize` The size of the parent [BasicTextFlow] container. + */ +typealias FlowShapeListProvider = (size: IntSize, textFlowSize: IntSize) -> List + /** * A [TextFlowScope] provides a scope for the children of [BasicTextFlow]. */ @@ -682,6 +701,21 @@ interface TextFlowScope { flowShape: Path? = null ): Modifier + /** + * Sets the shapes used to flow text around this element. + * + * @param flowType Defines how text flows around this element, see [FlowType]. + * @param margin The extra margin to add around this element for text flow. + * @param flowShapes A list of [Path] defining the shapes used to flow text around this element. + * If the list is empty, a rectangle of the dimensions of this element will be used by default. + */ + @Stable + fun Modifier.flowShapes( + flowType: FlowType = Outside, + margin: Dp = 0.dp, + flowShapes: List = emptyList() + ): Modifier + /** * Sets the shape used to flow text around this element. This variant of the [flowShape] * modifier accepts a lambda to define the shape used to flow text. That lambda receives @@ -700,6 +734,25 @@ interface TextFlowScope { margin: Dp = 0.dp, flowShape: FlowShapeProvider ): Modifier + + /** + * Sets the shapes used to flow text around this element. This variant of the [flowShapes] + * modifier accepts a lambda to define the shapes used to flow text. That lambda receives + * as parameters the size of this element and the size of the parent [BasicTextFlow] to + * facilitate the computation of an appropriate list of [Path]. + * + * @param flowType Defines how text flows around this element, see [FlowType]. + * @param margin The extra margin to add around this element for text flow. + * @param flowShapes A lambda that returns a list of [Path] defining the shapes used to flow + * text around this element. If the list is empty, a rectangle of the dimensions of this + * element will be used instead. + */ + @Stable + fun Modifier.flowShapes( + flowType: FlowType = Outside, + margin: Dp = 0.dp, + flowShapes: FlowShapeListProvider + ): Modifier } private object TextFlowScopeInstance : TextFlowScope { @@ -721,7 +774,9 @@ private object TextFlowScopeInstance : TextFlowScope { @Stable override fun Modifier.flowShape(flowType: FlowType, margin: Dp, flowShape: Path?) = this.then( - FlowShapeModifier(flowType, margin) { _, _ -> flowShape } + FlowShapeModifier(flowType, margin) { _, _ -> + if (flowShape == null) emptyList() else listOf(flowShape) + } ) @Stable @@ -730,7 +785,28 @@ private object TextFlowScopeInstance : TextFlowScope { margin: Dp, flowShape: FlowShapeProvider ) = this.then( - FlowShapeModifier(flowType, margin, flowShape) + FlowShapeModifier(flowType, margin) { size, containerSize -> + val path = flowShape(size, containerSize) + if (path == null) emptyList() else listOf(path) + } + ) + + @Stable + override fun Modifier.flowShapes( + flowType: FlowType, + margin: Dp, + flowShapes: List + ) = this.then( + FlowShapeModifier(flowType, margin) { _, _ -> flowShapes } + ) + + @Stable + override fun Modifier.flowShapes( + flowType: FlowType, + margin: Dp, + flowShapes: FlowShapeListProvider + ) = this.then( + FlowShapeModifier(flowType, margin, flowShapes) ) } @@ -776,7 +852,7 @@ private class AlignmentAndSizeModifier( private class FlowShapeModifier( val flowType: FlowType, val margin: Dp, - val flowShape: FlowShapeProvider + val flowShape: FlowShapeListProvider ) : ParentDataModifier, OnPlacedModifier { var localParentData: TextFlowParentData? = null @@ -784,7 +860,7 @@ private class FlowShapeModifier( localParentData = ((parentData as? TextFlowParentData) ?: TextFlowParentData()).also { it.margin = margin it.flowType = flowType - it.flowShape = flowShape + it.flowShapes = flowShape } return localParentData!! } @@ -823,7 +899,7 @@ private data class TextFlowParentData( var matchParentSize: Boolean = false, var margin: Dp = 0.dp, var flowType: FlowType = Outside, - var flowShape: FlowShapeProvider = { _, _ -> null }, + var flowShapes: FlowShapeListProvider = { _, _ -> emptyList() }, var position: Offset = Offset.Unspecified ) diff --git a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/FlowSlots.kt b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/FlowSlots.kt index bb0e989..711cc8a 100644 --- a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/FlowSlots.kt +++ b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/FlowSlots.kt @@ -21,6 +21,8 @@ import android.graphics.RectF import kotlin.math.max import kotlin.math.min +private val RectComparator = Comparator { r1: RectF, r2: RectF -> (r1.left - r2.left).toInt() } + /** * Holder for pre-allocated structures that will be used when findFlowSlots() is called * repeatedly. @@ -129,6 +131,8 @@ internal fun findFlowSlots( slots.add(RectF(box)) } + slots.sortWith(RectComparator) + return slots } diff --git a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt index d3457c9..acf3bbc 100644 --- a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt +++ b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt @@ -21,9 +21,73 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asAndroidPath +import dev.romainguy.graphics.path.PathSegment.Type.* +import dev.romainguy.graphics.path.iterator import kotlin.math.max import kotlin.math.min +/** + * Divides this path into a list of paths. Each contour inside this path is returned as a separate + * [Path]. For instance the following code snippet creates two rectangular contours: + * + * ``` + * val p = Path() + * p.addRect(…) + * p.addRect(…) + * val paths = p.divide() + * ``` + * The list returned by calling `p.divide()` will contain two `Path` instances, each representing + * one of the two rectangles. + * + * @param paths An optional mutable list of [Path] that will hold the result of the division. + * + * @return A list of [Path] representing all the contours in this path. The returned list is either + * a newly allocated list if the [paths] parameter was left unspecified, or the [paths] parameter. + */ +fun Path.divide(paths: MutableList = mutableListOf()): List { + var path = Path() + + var first = true + + val iterator = asAndroidPath().iterator() + val points = FloatArray(8) + + while (iterator.hasNext()) { + when (iterator.next(points)) { + Move -> { + if (!first) { + paths.add(path) + path = Path() + } + first = false + path.moveTo(points[0], points[1]) + } + Line -> path.lineTo(points[2], points[3]) + Quadratic -> path.quadraticBezierTo( + points[2], + points[3], + points[4], + points[5] + ) + Conic -> continue // We convert conics to quadratics + Cubic -> path.cubicTo( + points[2], + points[3], + points[4], + points[5], + points[6], + points[7] + ) + Close -> path.close() + Done -> continue // Won't happen inside this loop + } + } + + if (!first) paths.add(path) + + return paths +} + internal class PathSegment(val x0: Float, val y0: Float, val x1: Float, val y1: Float) /** diff --git a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/TextLayout.kt b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/TextLayout.kt index a850772..d8c3552 100644 --- a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/TextLayout.kt +++ b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/TextLayout.kt @@ -423,6 +423,16 @@ private fun justify( return justifyWidth } +// Comparator used to sort the results of an interval query against the styleIntervals +// tree. The tree doesn't preserve ordering and this comparator puts the intervals/ranges +// back in the order provided by AnnotatedString +private val StyleComparator = Comparator { s1: Interval, s2: Interval -> + val start = (s1.start - s2.start).toInt() + if (start < 0.0f) -1 + else if (start == 0) (s2.end - s1.end).toInt() + else 1 + } + private class TextLayoutState( val text: AnnotatedString, val textStyle: TextStyle, @@ -507,17 +517,6 @@ private class TextLayoutState( var textHeight = 0.0f var totalOffset = 0 - // Comparator used to sort the results of an interval query against the styleIntervals - // tree. The tree doesn't preserve ordering and this comparator puts the intervals/ranges - // back in the order provided by AnnotatedString - private val styleComparator = - Comparator { style1: Interval, style2: Interval -> - val start = (style1.start - style2.start).toInt() - if (start < 0.0f) -1 - else if (start == 0) (style2.end - style1.end).toInt() - else 1 - } - // Moves the internal state to the next paragraph in the list fun nextParagraph() { _currentParagraph++ @@ -547,7 +546,7 @@ private class TextLayoutState( ) stylesQuery.clear() - styleIntervals.findOverlaps(searchInternal, stylesQuery).sortedWith(styleComparator) + styleIntervals.findOverlaps(searchInternal, stylesQuery).sortedWith(StyleComparator) mergedStyles.add(Interval(0.0f, paragraph.length.toFloat(), textStyle))