Marquee Text Effect in Jetpack Compose - android

In the past, a kind of animation could be included in the text, in which if the text exceeded the limits, it would automatically scroll horizontally. This was done by including: android:ellipsize="marquee", and the result was something similar to the one shown here:
The problem is that in Jetpack Compose I don't see a way to include that option inside the Composable Text, there is the TextOverflow that includes the Clip, Ellipsis or Visible options, but I don't know if there is a way to include or use the "Marquee" option in Jetpack Compose. Is there any way to do it?

Modifier.basicMarquee was introduced in 1.4.0-alpha04. It's gonna animate position of your content if it doesn't fit the container width. Here's usage example:
Text(
LoremIpsum().values.first().take(10),
maxLines = 1,
modifier = Modifier
.width(50.dp)
.basicMarquee()
)
It's not gonna add gradient edges, as XML attribute does. The tricky part is that this modifier doesn't has any state for you to know, if content fits the bounds or not, so you can't optionally draw the gradient. Maybe they'll add it later.
Below solution would work prior Compose 1.4 and also would only add gradient in case content doesn't fit the container.
You will need TargetBasedAnimation, which will update the text offset, and SubcomposeLayout, which lies under most collections. Inside you can define the size of the text, and also place the second similar Text, which will appear from the right edge.
#Composable
fun MarqueeText(
text: String,
modifier: Modifier = Modifier,
textModifier: Modifier = Modifier,
gradientEdgeColor: Color = Color.White,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
val createText = #Composable { localModifier: Modifier ->
Text(
text,
textAlign = textAlign,
modifier = localModifier,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = 1,
onTextLayout = onTextLayout,
style = style,
)
}
var offset by remember { mutableStateOf(0) }
val textLayoutInfoState = remember { mutableStateOf<TextLayoutInfo?>(null) }
LaunchedEffect(textLayoutInfoState.value) {
val textLayoutInfo = textLayoutInfoState.value ?: return#LaunchedEffect
if (textLayoutInfo.textWidth <= textLayoutInfo.containerWidth) return#LaunchedEffect
val duration = 7500 * textLayoutInfo.textWidth / textLayoutInfo.containerWidth
val delay = 1000L
do {
val animation = TargetBasedAnimation(
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = duration,
delayMillis = 1000,
easing = LinearEasing,
),
repeatMode = RepeatMode.Restart
),
typeConverter = Int.VectorConverter,
initialValue = 0,
targetValue = -textLayoutInfo.textWidth
)
val startTime = withFrameNanos { it }
do {
val playTime = withFrameNanos { it } - startTime
offset = (animation.getValueFromNanos(playTime))
} while (!animation.isFinishedFromNanos(playTime))
delay(delay)
} while (true)
}
SubcomposeLayout(
modifier = modifier.clipToBounds()
) { constraints ->
val infiniteWidthConstraints = constraints.copy(maxWidth = Int.MAX_VALUE)
var mainText = subcompose(MarqueeLayers.MainText) {
createText(textModifier)
}.first().measure(infiniteWidthConstraints)
var gradient: Placeable? = null
var secondPlaceableWithOffset: Pair<Placeable, Int>? = null
if (mainText.width <= constraints.maxWidth) {
mainText = subcompose(MarqueeLayers.SecondaryText) {
createText(textModifier.fillMaxWidth())
}.first().measure(constraints)
textLayoutInfoState.value = null
} else {
val spacing = constraints.maxWidth * 2 / 3
textLayoutInfoState.value = TextLayoutInfo(
textWidth = mainText.width + spacing,
containerWidth = constraints.maxWidth
)
val secondTextOffset = mainText.width + offset + spacing
val secondTextSpace = constraints.maxWidth - secondTextOffset
if (secondTextSpace > 0) {
secondPlaceableWithOffset = subcompose(MarqueeLayers.SecondaryText) {
createText(textModifier)
}.first().measure(infiniteWidthConstraints) to secondTextOffset
}
gradient = subcompose(MarqueeLayers.EdgesGradient) {
Row {
GradientEdge(gradientEdgeColor, Color.Transparent)
Spacer(Modifier.weight(1f))
GradientEdge(Color.Transparent, gradientEdgeColor)
}
}.first().measure(constraints.copy(maxHeight = mainText.height))
}
layout(
width = constraints.maxWidth,
height = mainText.height
) {
mainText.place(offset, 0)
secondPlaceableWithOffset?.let {
it.first.place(it.second, 0)
}
gradient?.place(0, 0)
}
}
}
#Composable
private fun GradientEdge(
startColor: Color, endColor: Color,
) {
Box(
modifier = Modifier
.width(10.dp)
.fillMaxHeight()
.background(
brush = Brush.horizontalGradient(
0f to startColor, 1f to endColor,
)
)
)
}
private enum class MarqueeLayers { MainText, SecondaryText, EdgesGradient }
private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int)
Usage:
MarqueeText(LoremIpsum().values.first().take(90))
Result:

Simple solution yet not perfect
val scrollState = rememberScrollState()
var shouldAnimate by remember {
mutableStateOf(true)
}
LaunchedEffect(key1 = shouldAnimated){
scrollState.animateScrollTo(
scrollState.maxValue,
animationSpec = tween(10000, 200, easing = CubicBezierEasing(0f,0f,0f,0f))
)
scrollState.scrollTo(0)
shouldAnimated = !shouldAnimated
}
Text(
text = value,
color = Color.White,
fontSize = 10.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.horizontalScroll(scrollState, false)
)

Starting from 1.4.0-alpha04 you can use the basicMarquee() modifier:
// Marquee only animates when the content doesn't fit in the max width.
Column(Modifier.width(30.dp)) {
Text("hello world hello world hello",
Modifier.basicMarquee())
}
If you want to add a fade effect at the edges you can use:
Text(
"the quick brown fox jumped over the lazy dogs",
Modifier
.widthIn(max = edgeWidth * 4)
// Rendering to an offscreen buffer is required to get the faded edges' alpha to be
// applied only to the text, and not whatever is drawn below this composable (e.g. the
// window).
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawFadedEdge(leftEdge = true)
drawFadedEdge(leftEdge = false)
}
.basicMarquee(
// Animate forever.
iterations = Int.MAX_VALUE,
spacing = MarqueeSpacing(0.dp)
)
.padding(start = edgeWidth)
)
with:
val edgeWidth = 32.dp
fun ContentDrawScope.drawFadedEdge(leftEdge: Boolean) {
val edgeWidthPx = edgeWidth.toPx()
drawRect(
topLeft = Offset(if (leftEdge) 0f else size.width - edgeWidthPx, 0f),
size = Size(edgeWidthPx, size.height),
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = if (leftEdge) 0f else size.width,
endX = if (leftEdge) edgeWidthPx else size.width - edgeWidthPx
),
blendMode = BlendMode.DstIn
)
}

You can refer to https://issuetracker.google.com/issues/139321650 for this. The code is available at : https://android-review.googlesource.com/c/platform/frameworks/support/+/2334291/20/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
It adds an extension function for Modifier.basicMarquee(parameters)
The code works perfectly fine for Text composable.
EDIT: You can refer here as well->
const val DefaultMarqueeIterations: Int = 3
const val DefaultMarqueeDelayMillis: Int = 1_200
val DefaultMarqueeSpacing: MarqueeSpacing = MarqueeSpacing.fractionOfContainer(1f / 3f)
val DefaultMarqueeVelocity: Dp = 30.dp
/**
* Applies an animated marquee effect to the modified content if it's too wide to fit in the
* available space. This modifier has no effect if the content fits in the max constraints. The
* content will be measured with unbounded width.
*
* When the animation is running, it will restart from the initial state any time:
* - any of the parameters to this modifier change, or
* - the content or container size change.
*
* The animation only affects the drawing of the content, not its position. The offset returned by
* the [LayoutCoordinates] of anything inside the marquee is undefined relative to anything outside
* the marquee, and may not match its drawn position on screen. This modifier also does not
* currently support content that accepts position-based input such as pointer events.
*
*
* #param iterations The number of times to repeat the animation. `Int.MAX_VALUE` will repeat
* forever, and 0 will disable animation.
* #param animationMode Whether the marquee should start animating [Immediately] or only
* [WhileFocused].
* #param delayMillis The duration to wait before starting each subsequent iteration, in millis.
* #param initialDelayMillis The duration to wait before starting the first iteration of the
* animation, in millis. By default, there will be no initial delay if [animationMode] is
* [Immediately], otherwise the initial delay will be [delayMillis].
* #param spacing A [MarqueeSpacing] that specifies how much space to leave at the end of the
* content before showing the beginning again.
* #param velocity The speed of the animation in dps / second.
*/
fun Modifier.basicMarquee(
iterations: Int = DefaultMarqueeIterations,
animationMode: MarqueeAnimationMode = Immediately,
delayMillis: Int = DefaultMarqueeDelayMillis,
initialDelayMillis: Int = if (animationMode == Immediately) delayMillis else 0,
spacing: MarqueeSpacing = DefaultMarqueeSpacing,
velocity: Dp = DefaultMarqueeVelocity
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "basicMarquee"
properties["iterations"] = iterations
properties["animationMode"] = animationMode
properties["delayMillis"] = delayMillis
properties["initialDelayMillis"] = initialDelayMillis
properties["spacing"] = spacing
properties["velocity"] = velocity
}
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val modifier = remember(
iterations,
delayMillis,
initialDelayMillis,
velocity,
spacing,
animationMode,
density,
layoutDirection,
) {
MarqueeModifier(
iterations = iterations,
delayMillis = delayMillis,
initialDelayMillis = initialDelayMillis,
velocity = velocity * if (layoutDirection == Ltr) 1f else -1f,
spacing = spacing,
animationMode = animationMode,
density = density
)
}
LaunchedEffect(modifier) {
modifier.runAnimation()
}
return#composed modifier
}
private class MarqueeModifier(
private val iterations: Int,
private val delayMillis: Int,
private val initialDelayMillis: Int,
private val velocity: Dp,
private val spacing: MarqueeSpacing,
private val animationMode: MarqueeAnimationMode,
private val density: Density,
) : Modifier.Element, LayoutModifier, DrawModifier, FocusEventModifier {
private var contentWidth by mutableStateOf(0)
private var containerWidth by mutableStateOf(0)
private var hasFocus by mutableStateOf(false)
private val offset = Animatable(0f)
private val direction = sign(velocity.value)
private val spacingPx by derivedStateOf {
with(spacing) {
density.calculateSpacing(contentWidth, containerWidth)
}
}
private val firstCopyVisible by derivedStateOf {
when (direction) {
1f -> offset.value < contentWidth
else -> offset.value < containerWidth
}
}
private val secondCopyVisible by derivedStateOf {
when (direction) {
1f -> offset.value > (contentWidth + spacingPx) - containerWidth
else -> offset.value > spacingPx
}
}
private val secondCopyOffset: Float by derivedStateOf {
when (direction) {
1f -> contentWidth + spacingPx
else -> -contentWidth - spacingPx
}.toFloat()
}
private val contentWidthPlusSpacing: Float?
get() {
// Don't animate if content fits. (Because coroutines, the int will get boxed anyway.)
if (contentWidth <= containerWidth) return null
if (animationMode == WhileFocused && !hasFocus) return null
return (contentWidth + spacingPx).toFloat()
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val childConstraints = constraints.copy(maxWidth = Constraints.Infinity)
val placeable = measurable.measure(childConstraints)
containerWidth = constraints.constrainWidth(placeable.width)
contentWidth = placeable.width
return layout(containerWidth, placeable.height) {
// Placing the marquee content in a layer means we don't invalidate the parent draw
// scope on every animation frame.
placeable.placeWithLayer(x = (-offset.value * direction).roundToInt(), y = 0)
}
}
override fun ContentDrawScope.draw() {
val clipOffset = offset.value * direction
clipRect(left = clipOffset, right = clipOffset + containerWidth) {
if (firstCopyVisible) {
this#draw.drawContent()
}
if (secondCopyVisible) {
translate(left = secondCopyOffset) {
this#draw.drawContent()
}
}
}
}
override fun onFocusEvent(focusState: FocusState) {
hasFocus = focusState.hasFocus
}
suspend fun runAnimation() {
if (iterations <= 0) {
// No animation.
return
}
snapshotFlow { contentWidthPlusSpacing }.collectLatest { contentWithSpacingWidth ->
// Don't animate when the content fits.
if (contentWithSpacingWidth == null) return#collectLatest
val spec = createMarqueeAnimationSpec(
iterations,
contentWithSpacingWidth,
initialDelayMillis,
delayMillis,
velocity,
density
)
offset.snapTo(0f)
try {
offset.animateTo(contentWithSpacingWidth, spec)
} finally {
offset.snapTo(0f)
}
}
}
}
private fun createMarqueeAnimationSpec(
iterations: Int,
targetValue: Float,
initialDelayMillis: Int,
delayMillis: Int,
velocity: Dp,
density: Density
): AnimationSpec<Float> {
val pxPerSec = with(density) { velocity.toPx() }
val singleSpec = velocityBasedTween(
velocity = pxPerSec.absoluteValue,
targetValue = targetValue,
delayMillis = delayMillis
)
// Need to cancel out the non-initial delay.
val startOffset = StartOffset(-delayMillis + initialDelayMillis)
return if (iterations == Int.MAX_VALUE) {
infiniteRepeatable(singleSpec, initialStartOffset = startOffset)
} else {
repeatable(iterations, singleSpec, initialStartOffset = startOffset)
}
}
/**
* Calculates a float [TweenSpec] that moves at a constant [velocity] for an animation from 0 to
* [targetValue].
*
* #param velocity Speed of animation in px / sec.
*/
private fun velocityBasedTween(
velocity: Float,
targetValue: Float,
delayMillis: Int
): TweenSpec<Float> {
val pxPerMilli = velocity / 1000f
return tween(
durationMillis = ceil(targetValue / pxPerMilli).toInt(),
easing = LinearEasing,
delayMillis = delayMillis
)
}
/** Specifies when the [basicMarquee] animation runs. */
#JvmInline
value class MarqueeAnimationMode private constructor(private val value: Int) {
override fun toString(): String = when (this) {
Immediately -> "Immediately"
WhileFocused -> "WhileFocused"
else -> error("invalid value: $value")
}
companion object {
/** Starts animating immediately, irrespective of focus state. */
#Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
val Immediately = MarqueeAnimationMode(0)
/**
* Only animates while the marquee has focus. This includes when a focusable child in the
* marquee's content is focused.
*/
#Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
val WhileFocused = MarqueeAnimationMode(1)
}
}
/**
* A [MarqueeSpacing] with a fixed size.
*/
fun MarqueeSpacing(spacing: Dp): MarqueeSpacing = MarqueeSpacing { _, _ -> spacing.roundToPx() }
/**
* Defines a [calculateSpacing] method that determines the space after the end of [basicMarquee]
* content before drawing the content again.
*/
fun interface MarqueeSpacing {
/**
* Calculates the space after the end of [basicMarquee] content before drawing the content
* again.
*
* This is a restartable method: any state used to calculate the result will cause the spacing
* to be re-calculated when it changes.
*
* #param contentWidth The width of the content inside the marquee, in pixels. Will always be
* larger than [containerWidth].
* #param containerWidth The width of the marquee itself, in pixels. Will always be smaller than
* [contentWidth].
* #return The space in pixels between the end of the content and the beginning of the content
* when wrapping.
*/
fun Density.calculateSpacing(
contentWidth: Int,
containerWidth: Int
): Int
companion object {
/**
* A [MarqueeSpacing] that is a fraction of the container's width.
*/
fun fractionOfContainer(fraction: Float): MarqueeSpacing = MarqueeSpacing { _, width ->
(fraction * width).roundToInt()
}
}
}
and you can use it like this:
Text(text = "marquee text",modifier = Modifier.basicMarquee())

Related

How to add drag to custom layout and change the color of the dragged surface in jetpack compose

I am using this tutorial
https://danielrampelt.com/blog/jetpack-compose-custom-schedule-layout-part-1/ and part 2
to draw a custom schedule. How can I want to use draggable inside of it so I can change the color of the dragged surface in jetpack compose. My aim is to drag an time slot vertically. This is my composable function. I have put the draggable function but I don't know how to implement it. Now my screen freezes, I can't scroll vertically.
#Composable
fun DynamicSchedule(
viewModel: CalenderViewModel,
modifier: Modifier = Modifier,
appointmentContent: #Composable (appointment: Appointment) -> Unit = {
ScheduleCard(
appointment = it
)
},
minDate: LocalDate,
maxDate: LocalDate,
dayWidth: Dp,
hourHeight: Dp
) {
val numDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt() + 1
val dividerColor = if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray
var offsetY by remember { mutableStateOf(0f) }
Layout(
content = {
viewModel.state.value.appointmentList.sortedBy { it.startDate }
.forEach { appointment ->
Box(modifier = Modifier.appointmentData(appointment)) {
appointmentContent(appointment)
}
}
},
modifier = modifier
.drawBehind {
repeat(23) {
drawLine(
dividerColor,
start = Offset(0f, (it + 1) * hourHeight.toPx()),
end = Offset(size.width, (it + 1) * hourHeight.toPx()),
strokeWidth = 1.dp.toPx()
)
}
repeat(numDays - 1) {
drawLine(
dividerColor,
start = Offset((it + 1) * dayWidth.toPx(), 0f),
end = Offset((it + 1) * dayWidth.toPx(), size.height),
strokeWidth = 1.dp.toPx()
)
}
}
.pointerInput(Unit) {
detectTapGestures {
val x = it.x.toDp()
val y = it.y.toDp()
val time = y.value.toInt() / hourHeight.value
val date = (x.value.toInt() / dayWidth.value)
println("X: $x, Y: $y")
println("Day: $date, Time: $time")
}
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
offsetY += delta
println("Delta: $offsetY")
}
),
) { measurables, constraints ->
println("i got recomposed ======== ")
val height = hourHeight.roundToPx() * 24
val width = dayWidth.roundToPx() * numDays
val placeablesWithAppointment = measurables.map { measurable ->
val appointment = measurable.parentData as Appointment
val appointmentDurationInMinutes =
ChronoUnit.MINUTES.between(
appointment.startDate.time.toJavaLocalTime(),
appointment.endDate.time.toJavaLocalTime()
)
val appointmentHeight =
((appointmentDurationInMinutes / 60f) * hourHeight.toPx()).roundToInt()
val placeable = measurable.measure(
constraints.copy(
minWidth = dayWidth.roundToPx(),
maxWidth = dayWidth.roundToPx(),
minHeight = appointmentHeight,
maxHeight = appointmentHeight
)
)
Pair(placeable, appointment)
}
layout(width, height) {
placeablesWithAppointment.forEach { (placeable, appointment) ->
//appointment time - midnight
val appointmentOffsetMinutes =
ChronoUnit.MINUTES.between(
LocalTime.MIN,
appointment.startDate.time.toJavaLocalTime()
)
val appointmentY =
((appointmentOffsetMinutes / 60f) * hourHeight.toPx()).roundToInt()
val appointmentOffsetDays =
ChronoUnit.DAYS.between(
minDate,
appointment.startDate.date.toJavaLocalDate()
).toInt()
val appointmentX = appointmentOffsetDays * dayWidth.roundToPx()
placeable.place(appointmentX, appointmentY)
}
}
}
}

Jetpack Compose: How to create a rating bar?

I'm trying to implement a rating bar. I refer to https://gist.github.com/vitorprado/0ae4ad60c296aefafba4a157bb165e60 but I don't understand anything from this code. It works but when I use this code the stars don't have rounded corners. I want to implement something like the following :
I made very basic sample for this, it would give the basic idea for creating rating bar with sample border and filled png files.
#Composable
private fun RatingBar(
modifier: Modifier = Modifier,
rating: Float,
spaceBetween: Dp = 0.dp
) {
val image = ImageBitmap.imageResource(id = R.drawable.star)
val imageFull = ImageBitmap.imageResource(id = R.drawable.star_full)
val totalCount = 5
val height = LocalDensity.current.run { image.height.toDp() }
val width = LocalDensity.current.run { image.width.toDp() }
val space = LocalDensity.current.run { spaceBetween.toPx() }
val totalWidth = width * totalCount + spaceBetween * (totalCount - 1)
Box(
modifier
.width(totalWidth)
.height(height)
.drawBehind {
drawRating(rating, image, imageFull, space)
})
}
private fun DrawScope.drawRating(
rating: Float,
image: ImageBitmap,
imageFull: ImageBitmap,
space: Float
) {
val totalCount = 5
val imageWidth = image.width.toFloat()
val imageHeight = size.height
val reminder = rating - rating.toInt()
val ratingInt = (rating - reminder).toInt()
for (i in 0 until totalCount) {
val start = imageWidth * i + space * i
drawImage(
image = image,
topLeft = Offset(start, 0f)
)
}
drawWithLayer {
for (i in 0 until totalCount) {
val start = imageWidth * i + space * i
// Destination
drawImage(
image = imageFull,
topLeft = Offset(start, 0f)
)
}
val end = imageWidth * totalCount + space * (totalCount - 1)
val start = rating * imageWidth + ratingInt * space
val size = end - start
// Source
drawRect(
Color.Transparent,
topLeft = Offset(start, 0f),
size = Size(size, height = imageHeight),
blendMode = BlendMode.SrcIn
)
}
}
private fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
Usage
Column {
RatingBar(rating = 3.7f, spaceBetween = 3.dp)
RatingBar(rating = 2.5f, spaceBetween = 2.dp)
RatingBar(rating = 4.5f, spaceBetween = 2.dp)
RatingBar(rating = 1.3f, spaceBetween = 4.dp)
}
Result
Also created a library that uses gestures, other png files and vectors as rating items is available here.
RatingBar(
rating = rating,
space = 2.dp,
imageBackground = imageBackground,
imageForeground = imageForeground,
animationEnabled = false,
gestureEnabled = true,
itemSize = 60.dp
) {
rating = it
}
You can pass the custom drawable as icon. check this code.
Replace your RatingStar() function as it is using canvas to draw star, instead pass the custom drawable.
#Composable
private fun starShow(){
val icon = if (isSelected)
//your selected drawable
else
//your unselected drawable
Icon(
painter = painterResource(id = icon),
contentDescription = null,
tint = MyColor.starColor)
}

How to create watermark text effect in jetpack compose

I would like to create a watermark effect in my app using text as shown in the picture below.
I achieved this by using canvas and bitmap, is there any other reliable way to do this?
Here is my composable function
#Composable
fun WaterMark(
modifier: Modifier = Modifier,
content: (#Composable BoxScope.() -> Unit)? = null,
) {
val watermarkText: String = "some mutable text"
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.textSize = LocalContext.current.dpToPx(24).toFloat()
paint.color = PSCoreColours.psCoreColours.onSurface.hashCode()
paint.textAlign = Paint.Align.LEFT
paint.alpha = (255 * 0.25).toInt()
val baseline: Float = -paint.ascent()
val image: Bitmap = Bitmap.createBitmap(paint.measureText(watermarkText).toInt(),
(baseline + paint.descent()).toInt(),
Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(image)
canvas.drawText(watermarkText, 0f, baseline, paint)
val rotationMatrix: Matrix = Matrix().apply { postRotate(-45f) }
val rotatedImage: Bitmap = Bitmap.createBitmap(image, 0, 0, image.width, image.height, rotationMatrix, true)
val pattern: ImageBitmap = rotatedImage.asImageBitmap()
Box {
content?.let { it() }
Canvas(
modifier = modifier
) {
val totalWidth = size.width / pattern.width
val totalHeight = size.height / pattern.height
var x = 0f
var y = 0f
for (i in 0..totalHeight.toInt()) {
y = (i * pattern.height).toFloat()
for (j in 0..totalWidth.toInt()) {
x = (j * pattern.width).toFloat()
drawImage(
pattern,
colorFilter = null,
topLeft = Offset(x, y),
)
}
}
}
}
}
You can do custom layouts in compose for this
private const val SPACING = 100
#Composable
fun Watermark(
content: #Composable BoxScope.() -> Unit,
) {
Box {
content()
Layout(
content = {
// Repeating the placeables, 6 should do for now but we should be able to calculate this too
repeat(6) {
Text(
text = watermarkText,
..
)
}
}
) { measurables, constraints ->
// Measuring all the placables
val placeables: List<Placeable> = measurables
.map { measurable -> measurable.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
// Calculating the max width of a placable
val maxWidth: Double = placeables.maxOf { it.width }.toDouble()
// Calculating the max width of a tile given the text is rotated
val tileSize: Int = (constraints.maxWidth / atan(maxWidth)).toInt()
placeables
.chunked(2) // Placing 2 columns
.forEachIndexed { index, (first, second) ->
val indexedTileSize: Int = index * tileSize
first.placeRelativeWithLayer(-SPACING, indexedTileSize + SPACING) { rotationZ = -45f }
second.placeRelativeWithLayer(tileSize, indexedTileSize) { rotationZ = -45f }
}
}
}
}
}
Watermark function creates instance of Paint and Bitmap on each recomposition. You should wrap them with remember as in this answer.
However you might, i think, do what you do fully Compose way without Paint and Bitmap either using Modifier.drawWithContent{} and drawText function of DrawScope and using translate or rotate inside DrawScope.
This is a drawText sample to understand how you can create and store TextLayoutResult remember.
And another sample using Modifier.drawWithContent
You can also try using Modifier.drawWithCache to cache TextLayoutResult in layout phase instead of composition phase which is suggested by Google Compose developer works on Text here

Why Custom Layout's modifer's height and width is applied to it's child? How to avoid it?

I've created a custom layout in jetpack compose to align the items in
a circular fashion. I have noticed something strange, or I don't know if I missed something from the android documentation. So, whenever I apply width and height or any of the either to modifier of the Layout. Its child items get that width or height. How should I avoid it, use it just apply to the parent but not child?
Here is the output.
Here is the code:
#Composable
fun CircularRevealMenu(
modifier: Modifier,
contentPadding: Dp = 16.dp,
circleRadius: () -> Float,
content: #Composable () -> Unit
) {
val configuration = LocalConfiguration.current
Layout(content = content, modifier = modifier) { children, constraints ->
val screenWidth = configuration.screenWidthDp.dp.toPx() * circleRadius()
val placeables = children.map { it.measure(constraints) }
val maxItemHeight = placeables.maxOf {
it.height
}
val maxItemWidth = placeables.maxOf {
it.width
}
val gap = 90 / placeables.size
val radiusOffset = (max(maxItemHeight, maxItemWidth) / 2) + contentPadding.toPx()
val radius = screenWidth - radiusOffset
val offset = 180 - gap / 2f
layout(screenWidth.toInt(), screenWidth.toInt()) {
for (i in placeables.indices) {
val radians = Math.toRadians((offset - (gap * i)).toDouble())
placeables[i].placeRelative(
x = (cos(radians) * radius + screenWidth).toInt() - placeables[i].width / 2,
y = (sin(radians) * radius + 0).toInt() - placeables[i].height / 2
)
}
}
}
}
enum class CircularMenuStates { Collapsed, Expanded }
#Preview(showBackground = true)
#Composable
fun PreviewCircularMenu() {
CircularRevealMenu(modifier = Modifier, circleRadius = { 1f }) {
Text(text = "Item 1")
Text(text = "Item 2")
Text(text = "Item 3")
Text(text = "Item 4")
}
}

Jetpack Compose create chat bubble with arrow and border/elevation

How can i create a chat bubble like telegram or whatsapp that has elevation and arrow on left or right side like in the image?
You can define your custom Shape.
For example you can define a Triangle using:
class TriangleEdgeShape(val offset: Int) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val trianglePath = Path().apply {
moveTo(x = 0f, y = size.height-offset)
lineTo(x = 0f, y = size.height)
lineTo(x = 0f + offset, y = size.height)
}
return Outline.Generic(path = trianglePath)
}
}
You can also extending the RoundedCornerShape adding the little triangle in the bottom right corner.
Then you can define something like:
Row(Modifier.height(IntrinsicSize.Max)) {
Column(
modifier = Modifier.background(
color = Color.xxx,
shape = RoundedCornerShape(4.dp,4.dp,0.dp,4.dp)
).width(xxxx)
) {
Text("Chat")
}
Column(
modifier = Modifier.background(
color = Color.xxx,
shape = TriangleEdgeShape(10))
.width(8.dp)
.fillMaxHeight()
){
}
Create a custom shape. This is a better solution than Gabriele's because it lets you maintain an elevation around the entire border. Here's a good article on creating custom shapes:
Custom Shape with Jetpack Compose - Article
and the source code:
Custom Shape with Jetpack Compose - Source code
Building this with a shape, arrow, and shadow is quite complex. I created it using custom Modifier, remember, canvas and drawing path. Full implementation is available in this repo.
I can sum the process as
Step1
Create a state for wrapping properties
class BubbleState internal constructor(
var backgroundColor: Color = DefaultBubbleColor,
var cornerRadius: BubbleCornerRadius = BubbleCornerRadius(
topLeft = 8.dp,
topRight = 8.dp,
bottomLeft = 8.dp,
bottomRight = 8.dp,
),
var alignment: ArrowAlignment = ArrowAlignment.None,
var arrowShape: ArrowShape = ArrowShape.TRIANGLE_RIGHT,
var arrowOffsetX: Dp = 0.dp,
var arrowOffsetY: Dp = 0.dp,
var arrowWidth: Dp = 14.dp,
var arrowHeight: Dp = 14.dp,
var arrowRadius: Dp = 0.dp,
var drawArrow: Boolean = true,
var shadow: BubbleShadow? = null,
var padding: BubblePadding? = null,
var clickable: Boolean = false
) {
/**
* Top position of arrow. This is read-only for implementation. It's calculated when arrow
* positions are calculated or adjusted based on width/height of bubble,
* offsetX/y, arrow width/height.
*/
var arrowTop: Float = 0f
internal set
/**
* Bottom position of arrow. This is read-only for implementation. It's calculated when arrow
* positions are calculated or adjusted based on width/height of bubble,
* offsetX/y, arrow width/height.
*/
var arrowBottom: Float = 0f
internal set
/**
* Right position of arrow. This is read-only for implementation. It's calculated when arrow
* positions are calculated or adjusted based on width/height of bubble,
* offsetX/y, arrow width/height.
*/
var arrowLeft: Float = 0f
internal set
/**
* Right position of arrow. This is read-only for implementation. It's calculated when arrow
* positions are calculated or adjusted based on width/height of bubble,
* offsetX/y, arrow width/height.
*/
var arrowRight: Float = 0f
internal set
/**
* Arrow is on left side of the bubble
*/
fun isHorizontalLeftAligned(): Boolean =
(alignment == ArrowAlignment.LeftTop
|| alignment == ArrowAlignment.LeftBottom
|| alignment == ArrowAlignment.LeftCenter)
/**
* Arrow is on right side of the bubble
*/
fun isHorizontalRightAligned(): Boolean =
(alignment == ArrowAlignment.RightTop
|| alignment == ArrowAlignment.RightBottom
|| alignment == ArrowAlignment.RightCenter)
/**
* Arrow is on top left or right side of the bubble
*/
fun isHorizontalTopAligned(): Boolean =
(alignment == ArrowAlignment.LeftTop || alignment == ArrowAlignment.RightTop)
/**
* Arrow is on top left or right side of the bubble
*/
fun isHorizontalBottomAligned(): Boolean =
(alignment == ArrowAlignment.LeftBottom || alignment == ArrowAlignment.RightBottom)
/**
* Check if arrow is horizontally positioned either on left or right side
*/
fun isArrowHorizontallyPositioned(): Boolean =
isHorizontalLeftAligned()
|| isHorizontalRightAligned()
/**
* Arrow is at the bottom of the bubble
*/
fun isVerticalBottomAligned(): Boolean =
alignment == ArrowAlignment.BottomLeft ||
alignment == ArrowAlignment.BottomRight ||
alignment == ArrowAlignment.BottomCenter
/**
* Arrow is at the yop of the bubble
*/
fun isVerticalTopAligned(): Boolean =
alignment == ArrowAlignment.TopLeft ||
alignment == ArrowAlignment.TopRight ||
alignment == ArrowAlignment.TopCenter
/**
* Arrow is on left side of the bubble
*/
fun isVerticalLeftAligned(): Boolean =
(alignment == ArrowAlignment.BottomLeft) || (alignment == ArrowAlignment.TopLeft)
/**
* Arrow is on right side of the bubble
*/
fun isVerticalRightAligned(): Boolean =
(alignment == ArrowAlignment.BottomRight) || (alignment == ArrowAlignment.TopRight)
/**
* Check if arrow is vertically positioned either on top or at the bottom of bubble
*/
fun isArrowVerticallyPositioned(): Boolean = isVerticalBottomAligned() || isVerticalTopAligned()
}
Step 2
Create function that returns remember to not create BubbleState at each recomposition.
fun rememberBubbleState(
backgroundColor: Color = DefaultBubbleColor,
cornerRadius: BubbleCornerRadius = BubbleCornerRadius(
topLeft = 8.dp,
topRight = 8.dp,
bottomLeft = 8.dp,
bottomRight = 8.dp
),
alignment: ArrowAlignment = ArrowAlignment.None,
arrowShape: ArrowShape = ArrowShape.TRIANGLE_RIGHT,
arrowOffsetX: Dp = 0.dp,
arrowOffsetY: Dp = 0.dp,
arrowWidth: Dp = 14.dp,
arrowHeight: Dp = 14.dp,
arrowRadius: Dp = 0.dp,
drawArrow: Boolean = true,
shadow: BubbleShadow? = null,
padding: BubblePadding? = null,
clickable:Boolean = false
): BubbleState {
return remember {
BubbleState(
backgroundColor = backgroundColor,
cornerRadius = cornerRadius,
alignment = alignment,
arrowShape = arrowShape,
arrowOffsetX = arrowOffsetX,
arrowOffsetY = arrowOffsetY,
arrowWidth = arrowWidth,
arrowHeight = arrowHeight,
arrowRadius = arrowRadius,
drawArrow = drawArrow,
shadow = shadow,
padding = padding,
clickable = clickable
)
}
}
Step 3
Measuring layout
We need to calculate space for arrow tip based on it's location, use Constraints.offset to limit placeable dimensions when measuring for our content and constrain width/height to not overflow parent.
internal fun MeasureScope.measureBubbleResult(
bubbleState: BubbleState,
measurable: Measurable,
constraints: Constraints,
rectContent: BubbleRect,
path: Path
): MeasureResult {
val arrowWidth = (bubbleState.arrowWidth.value * density).roundToInt()
val arrowHeight = (bubbleState.arrowHeight.value * density).roundToInt()
// Check arrow position
val isHorizontalLeftAligned = bubbleState.isHorizontalLeftAligned()
val isVerticalTopAligned = bubbleState.isVerticalTopAligned()
val isHorizontallyPositioned = bubbleState.isArrowHorizontallyPositioned()
val isVerticallyPositioned = bubbleState.isArrowVerticallyPositioned()
// Offset to limit max width when arrow is horizontally placed
// if we don't remove arrowWidth bubble will overflow from it's parent as much as arrow
// width is. So we measure our placeable as content + arrow width
val offsetX: Int = if (isHorizontallyPositioned) {
arrowWidth
} else 0
// Offset to limit max height when arrow is vertically placed
val offsetY: Int = if (isVerticallyPositioned) {
arrowHeight
} else 0
val placeable = measurable.measure(constraints.offset(-offsetX, -offsetY))
val desiredWidth = constraints.constrainWidth(placeable.width + offsetX)
val desiredHeight: Int = constraints.constrainHeight(placeable.height + offsetY)
setContentRect(
bubbleState,
rectContent,
desiredWidth,
desiredHeight,
density = density
)
getBubbleClipPath(
path = path,
state = bubbleState,
contentRect = rectContent,
density = density
)
// Position of content(Text or Column/Row/Box for instance) in Bubble
// These positions effect placeable area for our content
// if xPos is greater than 0 it's required to translate background path(bubble) to match total
// area since left of xPos is not usable(reserved for arrowWidth) otherwise
val xPos = if (isHorizontalLeftAligned) arrowWidth else 0
val yPos = if (isVerticalTopAligned) arrowHeight else 0
return layout(desiredWidth, desiredHeight) {
placeable.place(xPos, yPos)
}
}
Also we need a Rectangle to capture content position that does exclude arrow dimensions.
Step 4 Create path using state that wraps arrow direction, offset in y or x axis and with draw option and rectangle we got from previous step is bit long, you can check it in source code here if you wish. Also still no rounded or curved paths, if you can help with it, it's more than welcome.
Step 5
Create a composed(stateful) Modifier to layout, and draw our bubble behind our content.
fun Modifier.drawBubble(bubbleState: BubbleState) = composed(
// pass inspector information for debug
inspectorInfo = debugInspectorInfo {
// name should match the name of the modifier
name = "drawBubble"
// add name and value of each argument
properties["bubbleState"] = bubbleState
},
factory = {
val rectContent = remember { BubbleRect() }
val path = remember { Path() }
var pressed by remember { mutableStateOf(false) }
Modifier
.layout { measurable, constraints ->
// println("Modifier.drawBubble() LAYOUT align:${bubbleState.alignment}")
measureBubbleResult(bubbleState, measurable, constraints, rectContent, path)
}
.materialShadow(bubbleState, path, true)
.drawBehind {
// println(
// "✏️ Modifier.drawBubble() DRAWING align:${bubbleState.alignment}," +
// " size: $size, path: $path, rectContent: $rectContent"
// )
val left = if (bubbleState.isHorizontalLeftAligned())
-bubbleState.arrowWidth.toPx() else 0f
translate(left = left) {
drawPath(
path = path,
color = if (pressed) bubbleState.backgroundColor.darkenColor(.9f)
else bubbleState.backgroundColor,
)
}
}
.then(
if (bubbleState.clickable) {
this.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
val down: PointerInputChange = awaitFirstDown()
pressed = down.pressed
waitForUpOrCancellation()
pressed = false
}
}
}
} else this
)
.then(
bubbleState.padding?.let { padding ->
this.padding(
padding.start,
padding.top,
padding.end,
padding.bottom
)
} ?: this
)
}
)

Categories

Resources