Border.kt

/*
 * 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.remember
import androidx.compose.ui.ContentDrawScope
import androidx.compose.ui.DrawModifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Radius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSimple
import androidx.compose.ui.geometry.outerRect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.addOutline
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.platform.InspectableParameter
import androidx.compose.ui.platform.ParameterElement
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.util.nativeClass

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

/**
 * Returns a [Modifier] that adds border with appearance specified with [width], [color] and a
 * [shape], pads the content by the [width] and clips 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
 */
fun Modifier.border(width: Dp, color: Color, shape: Shape = RectangleShape) =
    border(width, SolidColor(color), shape)

/**
 * Returns a [Modifier] that adds border with appearance specified with [width], [brush] and a
 * [shape], pads the content by the [width] and clips it.
 *
 * @sample androidx.compose.foundation.samples.BorderSampleWithBrush()
 *
 * @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
 */
fun Modifier.border(width: Dp, brush: Brush, shape: Shape): Modifier = composed {
    BorderModifier(remember { BorderModifierCache() }, shape, width, brush)
}

/**
 * Returns a [Modifier] that adds border with appearance specified with a [border] and a [shape]
 *
 * @sample androidx.compose.foundation.samples.BorderSample()
 *
 * @param border [Border] class that specifies border appearance, such as size and color
 * @param shape shape of the border
 */
@Deprecated(
    "Use Modifier.border instead", replaceWith = ReplaceWith(
        "this.border(BorderStroke(border.size, border.brush), shape)",
        "androidx.ui.foundation.border"
    )
)
@Suppress("DEPRECATION")
fun Modifier.drawBorder(border: Border, shape: Shape = RectangleShape) =
    drawBorder(size = border.size, brush = border.brush, shape = shape)

/**
 * Returns a [Modifier] that adds border with appearance specified with [size], [color] and a
 * [shape]
 *
 * @sample androidx.compose.foundation.samples.BorderSampleWithDataClass()
 *
 * @param size 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
 */
@Deprecated(
    "Use Modifier.border instead", replaceWith = ReplaceWith(
        "this.border(size, color, shape)",
        "androidx.ui.foundation.border"
    )
)
@Suppress("DEPRECATION")
fun Modifier.drawBorder(size: Dp, color: Color, shape: Shape = RectangleShape) =
    border(size, SolidColor(color), shape)

/**
 * Returns a [Modifier] that adds border with appearance specified with [size], [brush] and a
 * [shape]
 *
 * @sample androidx.compose.foundation.samples.BorderSampleWithBrush()
 *
 * @param size 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
 */
@Deprecated(
    "Use Modifier.border instead", replaceWith = ReplaceWith(
        "this.border(size, brush, shape)",
        "androidx.ui.foundation.border"
    )
)
fun Modifier.drawBorder(size: Dp, brush: Brush, shape: Shape): Modifier = composed {
    BorderModifier(remember { BorderModifierCache() }, shape, size, brush)
}

private class BorderModifier(
    private val cache: BorderModifierCache,
    private val shape: Shape,
    private val borderWidth: Dp,
    private val brush: Brush
) : DrawModifier, InspectableParameter {

    // put params to constructor to ensure proper equals and update cache after construction
    init {
        cache.lastShape = shape
        cache.borderSize = borderWidth
    }

    override fun ContentDrawScope.draw() {
        val density = this
        with(cache) {
            drawContent()
            modifierSize = size
            val outline = modifierSizeOutline(density)
            val borderSize =
                if (borderWidth == Dp.Hairline) 1f else borderWidth.value * density.density
            if (borderSize <= 0 || size.minDimension <= 0.0f) {
                return
            } else if (outline is Outline.Rectangle) {
                // shortcut to make rectangle shapes draw faster
                drawRoundRectBorder(borderSize, outline.rect, 0f, brush)
            } else if (outline is Outline.Rounded && outline.roundRect.isSimple) {
                // shortcut to make rounded rectangles draw faster
                val radius = outline.roundRect.bottomLeftRadiusY
                drawRoundRectBorder(
                    borderSize,
                    outline.roundRect.outerRect(),
                    radius,
                    brush
                )
            } else {
                // general path-difference (rect-difference) implementation for the rest
                drawPath(borderPath(density, borderSize), brush)
            }
        }
    }

    private fun DrawScope.drawRoundRectBorder(
        borderSize: Float,
        rect: Rect,
        radius: Float,
        brush: Brush
    ) {
        val fillWithBorder = borderSize * 2 >= rect.minDimension
        val style = if (fillWithBorder) Fill else Stroke(borderSize)

        val delta = if (fillWithBorder) 0f else borderSize / 2
        drawRoundRect(
            brush,
            topLeft = Offset(rect.left + delta, rect.top + delta),
            size = Size(rect.width - 2 * delta, rect.height - 2 * delta),
            radius = Radius(radius),
            style = style
        )
    }

    // cannot make DrawBorder data class because of the cache, though need hashcode/equals
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (this.nativeClass() != other?.nativeClass()) return false

        other as BorderModifier

        if (shape != other.shape) return false
        if (borderWidth != other.borderWidth) return false
        if (brush != other.brush) return false

        return true
    }

    override fun hashCode(): Int {
        var result = shape.hashCode()
        result = 31 * result + borderWidth.hashCode()
        result = 31 * result + brush.hashCode()
        return result
    }

    override val inspectableElements: Sequence<ParameterElement>
        get() {
            val brushParam = if (brush is SolidColor) {
                ParameterElement("color", brush.value)
            } else {
                ParameterElement("brush", brush)
            }
            return sequenceOf(
                brushParam,
                ParameterElement("shape", shape),
                ParameterElement("width", borderWidth)
            )
        }
    override val nameFallback = "border"
    override val valueOverride: Any?
        get() = (brush as? SolidColor)?.value ?: brush
}

private class BorderModifierCache {
    private val outerPath = Path()
    private val innerPath = Path()
    private val diffPath = Path()
    private var dirtyPath = true
    private var dirtyOutline = true
    private var outline: Outline? = null

    var lastShape: Shape? = null
        set(value) {
            if (value != field) {
                field = value
                dirtyPath = true
                dirtyOutline = true
            }
        }

    var borderSize: Dp = Dp.Unspecified
        set(value) {
            if (value != field) {
                field = value
                dirtyPath = true
            }
        }

    var modifierSize: Size? = null
        set(value) {
            if (value != field) {
                field = value
                dirtyPath = true
                dirtyOutline = true
            }
        }

    fun modifierSizeOutline(density: Density): Outline {
        if (dirtyOutline) {
            outline = lastShape?.createOutline(modifierSize!!, density)
            dirtyOutline = false
        }
        return outline!!
    }

    fun borderPath(density: Density, borderPixelSize: Float): Path {
        if (dirtyPath) {
            val size = modifierSize!!
            diffPath.reset()
            outerPath.reset()
            innerPath.reset()
            if (borderPixelSize * 2 >= size.minDimension) {
                diffPath.addOutline(modifierSizeOutline(density))
            } else {
                outerPath.addOutline(lastShape!!.createOutline(size, density))
                val sizeMinusBorder =
                    Size(
                        size.width - borderPixelSize * 2,
                        size.height - borderPixelSize * 2
                    )
                innerPath.addOutline(lastShape!!.createOutline(sizeMinusBorder, density))
                innerPath.shift(Offset(borderPixelSize, borderPixelSize))

                // now we calculate the diff between the inner and the outer paths
                diffPath.op(outerPath, innerPath, PathOperation.difference)
            }
            dirtyPath = false
        }
        return diffPath
    }
}