All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.Border.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

The newest version!
/*
 * Copyright 2020 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.foundation

import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.CacheDrawModifierNode
import androidx.compose.ui.draw.CacheDrawScope
import androidx.compose.ui.draw.DrawResult
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSimple
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min

/**
 * Modify element to add border with appearance specified with a [border] and a [shape] and clip it.
 *
 * @sample androidx.compose.foundation.samples.BorderSample()
 * @param border [BorderStroke] class that specifies border appearance, such as size and color
 * @param shape shape of the border
 */
@Stable
fun Modifier.border(border: BorderStroke, shape: Shape = RectangleShape) =
    border(width = border.width, brush = border.brush, shape = shape)

/**
 * Modify element to add border with appearance specified with a [width], a [color] and a [shape]
 * and clip it.
 *
 * @sample androidx.compose.foundation.samples.BorderSampleWithDataClass()
 * @param width width of the border. Use [Dp.Hairline] for a hairline border.
 * @param color color to paint the border with
 * @param shape shape of the border
 */
@Stable
fun Modifier.border(width: Dp, color: Color, shape: Shape = RectangleShape) =
    border(width, SolidColor(color), shape)

/**
 * Modify element to add border with appearance specified with a [width], a [brush] and a [shape]
 * and clip it.
 *
 * @sample androidx.compose.foundation.samples.BorderSampleWithBrush()
 * @sample androidx.compose.foundation.samples.BorderSampleWithDynamicData()
 * @param width width of the border. Use [Dp.Hairline] for a hairline border.
 * @param brush brush to paint the border with
 * @param shape shape of the border
 */
@Stable
fun Modifier.border(width: Dp, brush: Brush, shape: Shape) =
    this then BorderModifierNodeElement(width, brush, shape)

internal data class BorderModifierNodeElement(val width: Dp, val brush: Brush, val shape: Shape) :
    ModifierNodeElement() {
    override fun create() = BorderModifierNode(width, brush, shape)

    override fun update(node: BorderModifierNode) {
        node.width = width
        node.brush = brush
        node.shape = shape
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "border"
        properties["width"] = width
        if (brush is SolidColor) {
            properties["color"] = brush.value
            value = brush.value
        } else {
            properties["brush"] = brush
        }
        properties["shape"] = shape
    }
}

internal class BorderModifierNode(
    widthParameter: Dp,
    brushParameter: Brush,
    shapeParameter: Shape
) : DelegatingNode() {

    // BorderCache object that is lazily allocated depending on the type of shape
    // This object is only used for generic shapes and rounded rectangles with different corner
    // radius sizes.
    // Note: Extension functions that use BorderCache are part of this class.
    private var borderCache: BorderCache? = null

    var width = widthParameter
        set(value) {
            if (field != value) {
                field = value
                drawWithCacheModifierNode.invalidateDrawCache()
            }
        }

    var brush = brushParameter
        set(value) {
            if (field != value) {
                field = value
                drawWithCacheModifierNode.invalidateDrawCache()
            }
        }

    var shape = shapeParameter
        set(value) {
            if (field != value) {
                field = value
                drawWithCacheModifierNode.invalidateDrawCache()
            }
        }

    private val drawWithCacheModifierNode =
        delegate(
            CacheDrawModifierNode {
                val hasValidBorderParams = width.toPx() >= 0f && size.minDimension > 0f
                if (!hasValidBorderParams) {
                    drawContentWithoutBorder()
                } else {
                    val strokeWidthPx =
                        min(
                            if (width == Dp.Hairline) 1f else ceil(width.toPx()),
                            ceil(size.minDimension / 2)
                        )
                    val halfStroke = strokeWidthPx / 2
                    val topLeft = Offset(halfStroke, halfStroke)
                    val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx)
                    // The stroke is larger than the drawing area so just draw a full shape instead
                    val fillArea = (strokeWidthPx * 2) > size.minDimension
                    when (val outline = shape.createOutline(size, layoutDirection, this)) {
                        is Outline.Generic ->
                            drawGenericBorder(brush, outline, fillArea, strokeWidthPx)
                        is Outline.Rounded ->
                            drawRoundRectBorder(
                                brush,
                                outline,
                                topLeft,
                                borderSize,
                                fillArea,
                                strokeWidthPx
                            )
                        is Outline.Rectangle ->
                            drawRectBorder(brush, topLeft, borderSize, fillArea, strokeWidthPx)
                    }
                }
            }
        )

    /**
     * Border implementation for generic paths. Note it is possible to be given paths that do not
     * make sense in the context of a border (ex. a figure 8 path or a non-enclosed shape) We do not
     * handle that here as we expect developers to give us enclosed, non-overlapping paths.
     */
    private fun CacheDrawScope.drawGenericBorder(
        brush: Brush,
        outline: Outline.Generic,
        fillArea: Boolean,
        strokeWidth: Float
    ): DrawResult =
        if (fillArea) {
            onDrawWithContent {
                drawContent()
                drawPath(outline.path, brush = brush)
            }
        } else {
            // Optimization, if we are only drawing a solid color border, we only need an alpha8
            // mask as we can draw the mask with a tint.
            // Otherwise we need to allocate a full ImageBitmap and draw it normally
            val config: ImageBitmapConfig
            val colorFilter: ColorFilter?
            if (brush is SolidColor) {
                config = ImageBitmapConfig.Alpha8
                // The brush is drawn into the mask with the corresponding color including the
                // alpha channel so when we tint we should not apply the alpha as it would end up
                // modulating it twice
                colorFilter = ColorFilter.tint(brush.value.copy(alpha = 1f))
            } else {
                config = ImageBitmapConfig.Argb8888
                colorFilter = null
            }

            val pathBounds = outline.path.getBounds()
            // Create a mask path that includes a rectangle with the original path cut out of it.
            // Note: borderCache is part of the class that defines this extension function.
            if (borderCache == null) {
                borderCache = BorderCache()
            }
            val maskPath =
                borderCache!!.obtainPath().apply {
                    reset()
                    addRect(pathBounds)
                    op(this, outline.path, PathOperation.Difference)
                }

            val cacheImageBitmap: ImageBitmap
            val pathBoundsSize =
                IntSize(ceil(pathBounds.width).toInt(), ceil(pathBounds.height).toInt())
            with(borderCache!!) {
                // Draw into offscreen bitmap with the size of the path
                // We need to draw into this intermediate bitmap to act as a layer
                // and make sure that the clearing logic does not generate underdraw
                // into the target we are rendering into
                cacheImageBitmap =
                    drawBorderCache(pathBoundsSize, config) {
                        // Paths can have offsets, so translate to keep the drawn path
                        // within the bounds of the mask bitmap
                        translate(-pathBounds.left, -pathBounds.top) {
                            // Draw the path with a stroke width twice the provided value.
                            // Because strokes are centered, this will draw both and inner and
                            // outer stroke with the desired stroke width
                            drawPath(
                                path = outline.path,
                                brush = brush,
                                style = Stroke(strokeWidth * 2)
                            )

                            // Scale the canvas slightly to cover the background that may be visible
                            // after clearing the outer stroke
                            scale((size.width + 1) / size.width, (size.height + 1) / size.height) {
                                // Remove the outer stroke by clearing the inverted mask path
                                drawPath(
                                    path = maskPath,
                                    brush = brush,
                                    blendMode = BlendMode.Clear
                                )
                            }
                        }
                    }
            }

            onDrawWithContent {
                drawContent()
                translate(pathBounds.left, pathBounds.top) {
                    drawImage(cacheImageBitmap, srcSize = pathBoundsSize, colorFilter = colorFilter)
                }
            }
        }

    /** Border implementation for simple rounded rects and those with different corner radii */
    private fun CacheDrawScope.drawRoundRectBorder(
        brush: Brush,
        outline: Outline.Rounded,
        topLeft: Offset,
        borderSize: Size,
        fillArea: Boolean,
        strokeWidth: Float
    ): DrawResult {
        return if (outline.roundRect.isSimple) {
            val cornerRadius = outline.roundRect.topLeftCornerRadius
            val halfStroke = strokeWidth / 2
            val borderStroke = Stroke(strokeWidth)
            onDrawWithContent {
                drawContent()
                when {
                    fillArea -> {
                        // If the drawing area is smaller than the stroke being drawn
                        // drawn all around it just draw a filled in rounded rect
                        drawRoundRect(brush, cornerRadius = cornerRadius)
                    }
                    cornerRadius.x < halfStroke -> {
                        // If the corner radius is smaller than half of the stroke width
                        // then the interior curvature of the stroke will be a sharp edge
                        // In this case just draw a normal filled in rounded rect with the
                        // desired corner radius but clipping out the interior rectangle
                        clipRect(
                            strokeWidth,
                            strokeWidth,
                            size.width - strokeWidth,
                            size.height - strokeWidth,
                            clipOp = ClipOp.Difference
                        ) {
                            drawRoundRect(brush, cornerRadius = cornerRadius)
                        }
                    }
                    else -> {
                        // Otherwise draw a stroked rounded rect with the corner radius
                        // shrunk by half of the stroke width. This will ensure that the
                        // outer curvature of the rounded rectangle will have the desired
                        // corner radius.
                        drawRoundRect(
                            brush = brush,
                            topLeft = topLeft,
                            size = borderSize,
                            cornerRadius = cornerRadius.shrink(halfStroke),
                            style = borderStroke
                        )
                    }
                }
            }
        } else {
            // Note: borderCache is part of the class that defines this extension function.
            if (borderCache == null) {
                borderCache = BorderCache()
            }
            val path = borderCache!!.obtainPath()
            val roundedRectPath =
                createRoundRectPath(path, outline.roundRect, strokeWidth, fillArea)
            onDrawWithContent {
                drawContent()
                drawPath(roundedRectPath, brush = brush)
            }
        }
    }
}

/**
 * Helper object that handles lazily allocating and re-using objects to render the border into an
 * offscreen ImageBitmap
 */
private data class BorderCache(
    private var imageBitmap: ImageBitmap? = null,
    private var canvas: androidx.compose.ui.graphics.Canvas? = null,
    private var canvasDrawScope: CanvasDrawScope? = null,
    private var borderPath: Path? = null
) {
    inline fun CacheDrawScope.drawBorderCache(
        borderSize: IntSize,
        config: ImageBitmapConfig,
        block: DrawScope.() -> Unit
    ): ImageBitmap {

        var targetImageBitmap = imageBitmap
        var targetCanvas = canvas
        // If we previously had allocated a full Argb888 ImageBitmap but are only requiring
        // an alpha mask, just re-use the same ImageBitmap instead of allocating a new one
        val compatibleConfig =
            targetImageBitmap?.config == ImageBitmapConfig.Argb8888 ||
                config == targetImageBitmap?.config
        if (
            targetImageBitmap == null ||
                targetCanvas == null ||
                size.width > targetImageBitmap.width ||
                size.height > targetImageBitmap.height ||
                !compatibleConfig
        ) {
            targetImageBitmap =
                ImageBitmap(borderSize.width, borderSize.height, config = config).also {
                    imageBitmap = it
                }
            targetCanvas =
                androidx.compose.ui.graphics.Canvas(targetImageBitmap).also { canvas = it }
        }

        val targetDrawScope = canvasDrawScope ?: CanvasDrawScope().also { canvasDrawScope = it }
        val drawSize = borderSize.toSize()
        targetDrawScope.draw(this, layoutDirection, targetCanvas, drawSize) {
            // Clear the previously rendered portion within this ImageBitmap as we could
            // be re-using it
            drawRect(color = Color.Black, size = drawSize, blendMode = BlendMode.Clear)
            block()
        }
        targetImageBitmap.prepareToDraw()
        return targetImageBitmap
    }

    fun obtainPath(): Path = borderPath ?: Path().also { borderPath = it }
}

/**
 * Border implementation for invalid parameters that just draws the content as the given border
 * parameters are infeasible (ex. negative border width)
 */
private fun CacheDrawScope.drawContentWithoutBorder(): DrawResult = onDrawWithContent {
    drawContent()
}

/** Border implementation for rectangular borders */
private fun CacheDrawScope.drawRectBorder(
    brush: Brush,
    topLeft: Offset,
    borderSize: Size,
    fillArea: Boolean,
    strokeWidthPx: Float
): DrawResult {
    // If we are drawing a rectangular stroke, just offset it by half the stroke
    // width as strokes are always drawn centered on their geometry.
    // If the border is larger than the drawing area, just fill the area with a
    // solid rectangle
    val rectTopLeft = if (fillArea) Offset.Zero else topLeft
    val size = if (fillArea) size else borderSize
    val style = if (fillArea) Fill else Stroke(strokeWidthPx)
    return onDrawWithContent {
        drawContent()
        drawRect(brush = brush, topLeft = rectTopLeft, size = size, style = style)
    }
}

/**
 * Helper method that creates a round rect with the inner region removed by the given stroke width
 */
private fun createRoundRectPath(
    targetPath: Path,
    roundedRect: RoundRect,
    strokeWidth: Float,
    fillArea: Boolean
): Path =
    targetPath.apply {
        reset()
        addRoundRect(roundedRect)
        if (!fillArea) {
            val insetPath =
                Path().apply { addRoundRect(createInsetRoundedRect(strokeWidth, roundedRect)) }
            op(this, insetPath, PathOperation.Difference)
        }
    }

private fun createInsetRoundedRect(widthPx: Float, roundedRect: RoundRect) =
    RoundRect(
        left = widthPx,
        top = widthPx,
        right = roundedRect.width - widthPx,
        bottom = roundedRect.height - widthPx,
        topLeftCornerRadius = roundedRect.topLeftCornerRadius.shrink(widthPx),
        topRightCornerRadius = roundedRect.topRightCornerRadius.shrink(widthPx),
        bottomLeftCornerRadius = roundedRect.bottomLeftCornerRadius.shrink(widthPx),
        bottomRightCornerRadius = roundedRect.bottomRightCornerRadius.shrink(widthPx)
    )

/**
 * Helper method to shrink the corner radius by the given value, clamping to 0 if the resultant
 * corner radius would be negative
 */
private fun CornerRadius.shrink(value: Float): CornerRadius =
    CornerRadius(max(0f, this.x - value), max(0f, this.y - value))




© 2015 - 2025 Weber Informatics LLC | Privacy Policy