/*
* Copyright 2021 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.ui.platform
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
/**
* Returns `true` if ([x], [y]) is within [outline]. For some outlines that don't require a [Path],
* the exact point is used to calculate whether the point is inside [outline]. When a [Path] is
* required, a 0.01 x 0.01 box around ([x], [y]) is used to intersect with the path to determine
* the result.
*
* The [tmpTouchPointPath] and [tmpOpPath] are temporary Paths that are cleared after use and will
* be used in the calculation of the intersection. These must be empty when passed as parameters or
* can be `null` to allocate locally.
*/
internal fun isInOutline(
outline: Outline,
x: Float,
y: Float,
tmpTouchPointPath: Path? = null,
tmpOpPath: Path? = null
): Boolean = when (outline) {
is Outline.Rectangle -> isInRectangle(outline.rect, x, y)
is Outline.Rounded -> isInRoundedRect(outline, x, y, tmpTouchPointPath, tmpOpPath)
is Outline.Generic -> isInPath(outline.path, x, y, tmpTouchPointPath, tmpOpPath)
}
private fun isInRectangle(rect: Rect, x: Float, y: Float) =
rect.left <= x && x < rect.right && rect.top <= y && y < rect.bottom
/**
* Returns `true` if ([x], [y]) is within [outline].
*/
private fun isInRoundedRect(
outline: Outline.Rounded,
x: Float,
y: Float,
touchPointPath: Path?,
opPath: Path?
): Boolean {
val rrect = outline.roundRect
// first, everything that is outside the rect
if (x < rrect.left || x >= rrect.right || y < rrect.top || y >= rrect.bottom) {
return false
}
// This algorithm assumes that the corner radius isn't greater than the size of the Rect.
// There's a complex algorithm to handle cases beyond that, so we'll fall back to
// the Path algorithm to handle it
if (!rrect.cornersFit()) {
val path = opPath ?: Path()
path.addRoundRect(rrect)
return isInPath(path, x, y, touchPointPath, opPath)
}
val topLeftX = rrect.left + rrect.topLeftCornerRadius.x
val topLeftY = rrect.top + rrect.topLeftCornerRadius.y
val topRightX = rrect.right - rrect.topRightCornerRadius.x
val topRightY = rrect.top + rrect.topRightCornerRadius.y
val bottomRightX = rrect.right - rrect.bottomRightCornerRadius.x
val bottomRightY = rrect.bottom - rrect.bottomRightCornerRadius.y
val bottomLeftX = rrect.bottom - rrect.bottomLeftCornerRadius.y
val bottomLeftY = rrect.left + rrect.bottomLeftCornerRadius.x
return if (x < topLeftX && y < topLeftY) {
// top-left corner
isWithinEllipse(x, y, rrect.topLeftCornerRadius, topLeftX, topLeftY)
} else if (x < bottomLeftY && y > bottomLeftX) {
// bottom-left corner
isWithinEllipse(x, y, rrect.bottomLeftCornerRadius, bottomLeftY, bottomLeftX)
} else if (x > topRightX && y < topRightY) {
// top-right corner
isWithinEllipse(x, y, rrect.topRightCornerRadius, topRightX, topRightY)
} else if (x > bottomRightX && y > bottomRightY) {
// bottom-right corner
isWithinEllipse(x, y, rrect.bottomRightCornerRadius, bottomRightX, bottomRightY)
} else {
true // not at a corner, so it must be inside
}
}
/**
* Returns `true` if the rounded rectangle has rounded corners that fit within the sides or
* `false` if the rounded sides add up to a greater size that a side.
*/
private fun RoundRect.cornersFit() = topLeftCornerRadius.x + topRightCornerRadius.x <= width &&
bottomLeftCornerRadius.x + bottomRightCornerRadius.x <= width &&
topLeftCornerRadius.y + bottomLeftCornerRadius.y <= height &&
topRightCornerRadius.y + bottomRightCornerRadius.y <= height
/**
* Used to determine whether a point is within a rounded corner, this returns `true` if the point
* ([x], [y]) is within the ellipse centered at ([centerX], [centerY]) with the horizontal and
* vertical radii given by [cornerRadius].
*/
private fun isWithinEllipse(
x: Float,
y: Float,
cornerRadius: CornerRadius,
centerX: Float,
centerY: Float
): Boolean {
val px = x - centerX
val py = y - centerY
val radiusX = cornerRadius.x
val radiusY = cornerRadius.y
return (px * px) / (radiusX * radiusX) + (py * py) / (radiusY * radiusY) <= 1f
}
/**
* Returns `true` if the 0.01 x 0.01 box around ([x], [y]) has any point with [path].
*
* The [tmpTouchPointPath] and [tmpOpPath] are temporary Paths that are cleared after use and will
* be used in the calculation of the intersection. These must be empty when passed as parameters or
* can be `null` to allocate locally.
*/
private fun isInPath(
path: Path,
x: Float,
y: Float,
tmpTouchPointPath: Path?,
tmpOpPath: Path?
): Boolean {
val rect = Rect(x - 0.005f, y - 0.005f, x + 0.005f, y + 0.005f)
val touchPointPath = tmpTouchPointPath ?: Path()
touchPointPath.addRect(
rect
)
val opPath = tmpOpPath ?: Path()
opPath.op(path, touchPointPath, PathOperation.Intersect)
val isClipped = opPath.isEmpty
opPath.reset()
touchPointPath.reset()
return !isClipped
}