13

I am trying to draw a gradient background in Jetpack Compose, and I would like the gradient to have a fixed angle regardless of the shape of the object I'm drawing into.

However, using Modifier.background(brush=...), the best I can find is linearGradient which calculates the angle from a fixed start and end point of a gradient.

For example, is there a way I can specify that I want a 45 degree angle for my gradient without knowing the final size it's going to be?

Edit: I would like a solution that can work for any given angle, not just 45 degrees.

machfour
  • 1,929
  • 2
  • 14
  • 21

7 Answers7

15

You can use the parameters start and end to achieve a 45 degree angle.

Something like:

val gradient45 = Brush.linearGradient(
    colors = listOf(Color.Yellow, Color.Red),
    start = Offset(0f, Float.POSITIVE_INFINITY),
    end = Offset(Float.POSITIVE_INFINITY, 0f)
)

enter image description here

Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841
  • 1
    I'm not sure that this gives 45 degrees if the shape isn't a square. Docs for `linearGradient` say that `Offset.Infinite` refers to the position at the far right and bottom of the drawing area, which I take to mean that the parameters you have passed refer to the top right and bottom left of the drawing area. So it won't be 45 degrees for rectangles. – machfour Jul 02 '21 at 09:06
  • 1
    Also, I'm looking for a more general solution that I can use for other angles, not just 45 degrees. Apologies for the wording in the question that allowed a special case solution. – machfour Jul 02 '21 at 09:07
14

You can use Modifier.drawBehind() and calculate the coordinates of points to draw gradient color.

fun Modifier.gradientBackground(colors: List<Color>, angle: Float) = this.then(
    Modifier.drawBehind {
        val angleRad = angle / 180f * PI
        val x = cos(angleRad).toFloat() //Fractional x
        val y = sin(angleRad).toFloat() //Fractional y

        val radius = sqrt(size.width.pow(2) + size.height.pow(2)) / 2f
        val offset = center + Offset(x * radius, y * radius)

        val exactOffset = Offset(
            x = min(offset.x.coerceAtLeast(0f), size.width),
            y = size.height - min(offset.y.coerceAtLeast(0f), size.height)
        )

        drawRect(
            brush = Brush.linearGradient(
                colors = colors,
                start = Offset(size.width, size.height) - exactOffset,
                end = exactOffset
            ),
            size = size
        )
    }
)

example:

Modifier
    .gradientBackground(listOf(Color.Red, Color.Green), angle = 45f)

1

Ehsan msz
  • 1,774
  • 2
  • 13
  • 26
  • 1
    Awesome. I wish there was a quicker way to do this but this is the approach I needed. Thanks! I have added another answer with a slightly adapted version of your code, but I'll accept your answer :) – machfour Jul 03 '21 at 03:55
  • this answer was helpful for me too, but it has an error, check this solution if you are interested https://stackoverflow.com/a/71577924/6941949 – Mukhtar Bimurat Mar 22 '22 at 19:32
  • Gabriele Mariotti's answer is cleaner. – kc_dev Jul 24 '22 at 15:12
9

I created a GradientOffset class which lets you rotate gradients by 45 degrees.

Enum that stores rotation angles and data class that stores Offsets.

    /**
     * Offset for [Brush.linearGradient] to rotate gradient depending on [start] and [end] offsets.
     */
    data class GradientOffset(val start: Offset, val end: Offset)
    
    enum class GradientAngle {
        CW0, CW45, CW90, CW135, CW180, CW225, CW270, CW315
    }

Rotation function

/**
 *
 * Get a [GradientOffset] that rotate a gradient clockwise with specified angle in degrees.
 * Default value for [GradientOffset] is [GradientAngle.CW0] which is 0 degrees
 * that returns a horizontal gradient.
 *
 * Get start and end offsets that are limited between [0f, Float.POSITIVE_INFINITY] in x and
 * y axes wrapped in [GradientOffset].
 * Infinity is converted to Composable width on x axis, height on y axis in shader.
 *
 * Default angle for [Brush.linearGradient] when no offset is 0 degrees in Compose ,
 * [Brush.verticalGradient]  is [Brush.linearGradient] with 90 degrees.
 *
 * ```
 *  0 degrees
 *  start = Offset(0f,0f),
 *  end = Offset(Float.POSITIVE_INFINITY,0f)
 *
 * 45 degrees
 * start = Offset(0f, Float.POSITIVE_INFINITY),
 * end = Offset(Float.POSITIVE_INFINITY, 0f)
 *
 * 90 degrees
 * start = Offset(0f, Float.POSITIVE_INFINITY),
 * end = Offset.Zero
 *
 * 135 degrees
 * start = Offset.Infinity,
 * end = Offset.Zero
 *
 * 180 degrees
 * start = Offset(Float.POSITIVE_INFINITY, 0f),
 * end = Offset.Zero,
 *
 * ```
 */
fun GradientOffset(angle: GradientAngle = GradientAngle.CW0): GradientOffset {
    return when (angle) {
        GradientAngle.CW45 -> GradientOffset(
            start = Offset.Zero,
            end = Offset.Infinite
        )
        GradientAngle.CW90 -> GradientOffset(
            start = Offset.Zero,
            end = Offset(0f, Float.POSITIVE_INFINITY)
        )
        GradientAngle.CW135 -> GradientOffset(
            start = Offset(Float.POSITIVE_INFINITY, 0f),
            end = Offset(0f, Float.POSITIVE_INFINITY)
        )
        GradientAngle.CW180 -> GradientOffset(
            start = Offset(Float.POSITIVE_INFINITY, 0f),
            end = Offset.Zero,
        )
        GradientAngle.CW225 -> GradientOffset(
            start = Offset.Infinite,
            end = Offset.Zero
        )
        GradientAngle.CW270 -> GradientOffset(
            start = Offset(0f, Float.POSITIVE_INFINITY),
            end = Offset.Zero
        )
        GradientAngle.CW315 -> GradientOffset(
            start = Offset(0f, Float.POSITIVE_INFINITY),
            end = Offset(Float.POSITIVE_INFINITY, 0f)
        )
        else -> GradientOffset(
            start = Offset.Zero,
            end = Offset(Float.POSITIVE_INFINITY, 0f)
        )
    }
}

Usage is very simple, rotate any gradient clockwise by setting GradientAngle.CW

// Offsets for gradients based on selected angle
var gradientOffset by remember {
    mutableStateOf(GradientOffset(GradientAngle.CW45))
}


Brush.linearGradient(
    listOf(Color.Red, Color.Green, Color.Blue),
    start = gradientOffset.start,
    end = gradientOffset.end
)

Result

enter image description here

Repo link if you wish to try it

Note

To have gradients that can rotate to any angle you need to implement your own LinearGradient class that extends ShaderBrush then calculate rotation to position using simple trigonometry.

Thracian
  • 43,021
  • 16
  • 133
  • 222
  • but how to animate it correctly? we can't use `Float.MAX_VALUE` or `Float.POSITIVE_INFINITY` as animated value for `Offset`: https://stackoverflow.com/questions/76779131/animate-linear-gradient-brush-infinitely-and-reversely-in-compose – user924 Jul 27 '23 at 11:10
7

Edit 2022-04-06

I realised there is an error in the original code, which distorts the gradient angle. Some more trigonometry is needed in order to constrain the gradient start and ends to within the canvas area (if that is what is desired) while also preserving the gradient angle. Here is the updated solution, with bonus ASCII art.

    fun Modifier.angledGradientBackground(colors: List<Color>, degrees: Float) = this.then(
    drawBehind {
        /*
        Have to compute length of gradient vector so that it lies within
        the visible rectangle.
        --------------------------------------------
        | length of gradient ^  /                  |
        |             --->  /  /                   |
        |                  /  / <- rotation angle  |
        |                 /  o --------------------|  y
        |                /  /                      |
        |               /  /                       |
        |              v  /                        |
        --------------------------------------------
                             x

                   diagonal angle = atan2(y, x)
                 (it's hard to draw the diagonal)

        Simply rotating the diagonal around the centre of the rectangle
        will lead to points outside the rectangle area. Further, just
        truncating the coordinate to be at the nearest edge of the
        rectangle to the rotated point will distort the angle.
        Let α be the desired gradient angle (in radians) and γ be the
        angle of the diagonal of the rectangle.
        The correct for the length of the gradient is given by:
        x/|cos(α)|  if -γ <= α <= γ,   or   π - γ <= α <= π + γ
        y/|sin(α)|  if  γ <= α <= π - γ, or π + γ <= α <= 2π - γ
        where γ ∈ (0, π/2) is the angle that the diagonal makes with
        the base of the rectangle.

        */

        val (x, y) = size
        val gamma = atan2(y, x)

        if (gamma == 0f || gamma == (PI / 2).toFloat()) {
            // degenerate rectangle
            return@drawBehind
        }

        val degreesNormalised = (degrees % 360).let { if (it < 0) it + 360 else it }

        val alpha = (degreesNormalised * PI / 180).toFloat()

        val gradientLength = when (alpha) {
            // ray from centre cuts the right edge of the rectangle
            in 0f..gamma, in (2*PI - gamma)..2*PI -> { x / cos(alpha) }
            // ray from centre cuts the top edge of the rectangle
            in gamma..(PI - gamma).toFloat() -> { y / sin(alpha) }
            // ray from centre cuts the left edge of the rectangle
            in (PI - gamma)..(PI + gamma) -> { x / -cos(alpha) }
            // ray from centre cuts the bottom edge of the rectangle
            in (PI + gamma)..(2*PI - gamma) -> { y / -sin(alpha) }
            // default case (which shouldn't really happen)
            else -> hypot(x, y)
        }

        val centerOffsetX = cos(alpha) * gradientLength / 2
        val centerOffsetY = sin(alpha) * gradientLength / 2

        drawRect(
            brush = Brush.linearGradient(
                colors = colors,
                // negative here so that 0 degrees is left -> right
                and 90 degrees is top -> bottom
                start = Offset(center.x - centerOffsetX,center.y - centerOffsetY),
                end = Offset(center.x + centerOffsetX, center.y + centerOffsetY)
            ),
            size = size
        )
    }
)

Old answer

This was my final solution based on @Ehan msz's code. I tweaked his solution so that 0 degrees corresponds to a left-to-right gradient direction, and 90 degrees corresponds to a top-to-bottom direction.

fun Modifier.angledGradient(colors: List<Color>, degrees: Float) = this.then(
Modifier.drawBehind {
    val rad = (degrees * PI / 180).toFloat()
    val diagonal = sqrt(size.width * size.width + size.height * size.height)
    val centerOffsetX = cos(rad) * diagonal / 2
    val centerOffsetY = sin(rad) * diagonal / 2

    // negative so that 0 degrees is left -> right and 90 degrees is top -> bottom
    val startOffset = Offset(
        x = (center.x - centerOffsetX).coerceIn(0f, size.width),
        y = (center.y - centerOffsetY).coerceIn(0f, size.height)
    )
    val endOffset = Offset(
        x = (center.x + centerOffsetX).coerceIn(0f, size.width),
        y = (center.y + centerOffsetY).coerceIn(0f, size.height)
    )

    drawRect(
        brush = Brush.linearGradient(
            colors = colors,
            start = startOffset,
            end = endOffset
        ),
        size = size
    )
}
machfour
  • 1,929
  • 2
  • 14
  • 21
3

The first solution has an error, as offset can be negative values as well (you will notice it when you check with 60 degree angle and compare with CSS gradient).

I've made a universal solution that supports any angle, and wrote a medium article about it (thanks to the first solution for the idea). Check it out if necessary

Mukhtar Bimurat
  • 356
  • 2
  • 18
  • Can you explain what the error is exactly? I'm still using the code I posted in my own answer - which is a variant of the one you mentioned - to make a 60 degree gradient in my own app, and it seems to do exactly what I need. – machfour Apr 02 '22 at 16:39
  • 2
    In CSS gradient startOffset and endOffset can be negative values (see here, red blue points: https://codepen.io/enbee81/pen/zYrXVGo) but in the first solution it never happens – Mukhtar Bimurat Apr 03 '22 at 17:52
  • 1
    I see what you mean, and I suppose if you want to faithfully reproduce CSS behaviour, you should be aware of this. But it is not really an "error", rather a design choice, to restrict the gradient start and end to within the visible area. It depends how much you want to see of the extremes of the colours. For my application, I want to see them more, so constraining makes sense. – machfour Apr 05 '22 at 15:47
  • 2
    Actually, when I was reading the article you posted, I noticed that there was indeed an error in my solution and the one you pointed out, but it's not that the offsets can't be negative. The .coerce*() operator is the wrong one to use because it only affects one coordinate, however this distorts the gradient angle. I've updated my answer to have the corrected code. – machfour Apr 06 '22 at 02:33
0

You need to define a directional vector using the offsets. For any angle, you would need to explicitly specify % within the maximum drawing area in respect to which way the vector should be pointing.

enter image description here

EpicPandaForce
  • 79,669
  • 27
  • 256
  • 428
0

Reworked the machfour variant, so that the gradient goes beyond the background (so that there were no single-color areas, as in the example below).

  • Mukhtar Bimurat has a more complete version, but I was curious to try it myself

> image

    fun Modifier.angledGradientBackground(colorStops: Array<Pair<Float, Color>>, degrees: Float) = this.then(
    drawBehind {
        val (x, y) = size

        val degreesNormalised = (degrees % 360).let { if (it < 0) it + 360 else it }
        val angleN = 90 - (degreesNormalised % 90)
        val angleNRad = Math.toRadians(angleN.toDouble())

        val hypot1 = abs((y * cos(angleNRad)))
        val x1 = (abs((hypot1 * sin(angleNRad)))).toFloat()
        val y1 = (abs((hypot1 * cos(angleNRad)))).toFloat()

        val hypot2 = abs((x * cos(angleNRad)))
        val x2 = (abs((hypot2 * cos(angleNRad)))).toFloat()
        val y2 = (abs((hypot2 * sin(angleNRad)))).toFloat()

        val offset = when  {
            degreesNormalised > 0f && degreesNormalised < 90f -> arrayOf(
                0f - x1, y - y1,
                x - x2, y + y2)
            degreesNormalised == 90f -> arrayOf(0f, 0f, 0f, y)
            degreesNormalised > 90f && degreesNormalised < 180f -> arrayOf(
                0f + x2, 0f - y2,
                0f - x1, y - y1)
            degreesNormalised == 180f -> arrayOf(x, 0f, 0f, 0f)
            degreesNormalised > 180f && degreesNormalised < 270f -> arrayOf(
                x + x1, 0f + y1,
                0f + x2, 0f - y2)
            degreesNormalised == 270f -> arrayOf(x, y, x, 0f)
            degreesNormalised > 270f && degreesNormalised < 360f -> arrayOf(
                x - x2, y + y2,
                x + x1, 0f + y1)
            else -> arrayOf(0f, y, x, y)
        }

        drawRect(
            brush = androidx.compose.ui.graphics.Brush.linearGradient(
                colorStops = colorStops,
                /*colors = colors,*/
                start = Offset(offset[0],offset[1]),
                end = Offset(offset[2], offset[3])
            ),
            size = size
        )
    }
)
bazimax
  • 1
  • 2