androidandroid-jetpack-compose

Angled gradient background in Jetpack Compose


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.


Solution

  • 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
        )
    }