Signal-Android/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorGradientTool...

344 wiersze
11 KiB
Kotlin

package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.core.content.ContextCompat
import androidx.core.view.GestureDetectorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.pow
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Renders the gradient customization tool.
*
* The Gradient customization tool is two selectable circles on either side
* of a rectangle with a pipe connecting them, a TOP and a BOTTOM (an edge)
*
* The user can then swap between the selected edge via a touch-down and can
* drag the selected edge such that it traces around the outline of the square.
* The other edge traces along the opposite side of the rectangle.
*
* The way the position along the edge is determined is by dividing the rectangle
* into 8 right-angled triangles, all joining at the center. Using the specified
* angle, we can determine which "octant" the top edge should be in, and can
* determine its distance from the center point of the relevant edge, and use
* similar logic to determine where the bottom edge lies.
*
* All of the math assumes an origin at the dead center of the view, and
* that 0deg corresponds to a vector pointing directly towards the right hand
* side of the view. This doesn't quite line up with what the gradient rendering
* math requires, so we apply a simple function to degrees when it comes into or
* leaves this tool (see `Float.invert`)
*/
class CustomChatColorGradientToolView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val clipRect = Rect()
private val rect = RectF()
private val center = PointF()
private val top = PointF()
private val bottom = PointF()
private var selectedEdge: CustomChatColorEdge = CustomChatColorEdge.TOP
private var degrees: Float = 18f
private var listener: Listener? = null
private val thumbRadius: Float = ViewUtil.dpToPx(THUMB_RADIUS).toFloat()
private val thumbBorder: Float = ViewUtil.dpToPx(THUMB_BORDER).toFloat()
private val thumbBorderSelected: Float = ViewUtil.dpToPx(THUMB_BORDER_SELECTED).toFloat()
private val opaqueThumbRadius: Float = ViewUtil.dpToPx(OPAQUE_THUMB_RADIUS).toFloat()
private val opaqueThumbPadding: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING).toFloat()
private val opaqueThumbPaddingSelected: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING_SELECTED).toFloat()
private val pipeWidth: Float = ViewUtil.dpToPx(PIPE_WIDTH).toFloat()
private val pipeBorder: Float = ViewUtil.dpToPx(PIPE_BORDER).toFloat()
private val topColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
private val bottomColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
}
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val thumbBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
}
private val thumbBorderPaintSelected = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_60)
}
private val pipePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = pipeWidth - pipeBorder * 2
style = Paint.Style.STROKE
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val pipeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = pipeWidth
style = Paint.Style.STROKE
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
}
private val gestureListener = GestureListener()
private val gestureDetectorCompat = GestureDetectorCompat(context, gestureListener)
fun setTopColor(@ColorInt color: Int) {
topColorPaint.color = color
invalidate()
}
fun setBottomColor(@ColorInt color: Int) {
bottomColorPaint.color = color
invalidate()
}
fun setSelectedEdge(selectedEdge: CustomChatColorEdge) {
if (this.selectedEdge == selectedEdge) {
return
}
this.selectedEdge = selectedEdge
invalidate()
listener?.onSelectedEdgeChanged(selectedEdge)
}
fun setDegrees(degrees: Float) {
setDegreesInternal(degrees.invertDegrees())
}
private fun setDegreesInternal(degrees: Float) {
if (this.degrees == degrees) {
return
}
this.degrees = degrees
invalidate()
listener?.onDegreesChanged(degrees.invertDegrees())
}
private fun Float.invertDegrees(): Float = 360f - rotate(90f)
fun setListener(listener: Listener) {
this.listener = listener
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP) {
listener?.onGestureFinished()
} else if (event.action == MotionEvent.ACTION_DOWN) {
listener?.onGestureStarted()
}
return gestureDetectorCompat.onTouchEvent(event)
}
override fun onDraw(canvas: Canvas) {
canvas.getClipBounds(clipRect)
rect.set(clipRect)
rect.inset(thumbRadius, thumbRadius)
center.set(rect.width() / 2f, rect.height() / 2f)
val alpha = atan((rect.height() / rect.width())).toDegrees()
val beta = (360.0 - alpha * 4) / 4f
if (degrees < alpha) {
// Right top
val a = center.x
val b = a * tan(degrees.toRadians())
top.set(rect.width(), center.y - b)
bottom.set(0f, center.y + b)
} else if (degrees < 90f) {
// Top right
val phi = 90f - degrees
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x + b, 0f)
bottom.set(center.x - b, rect.height())
} else if (degrees < (90f + beta)) {
// Top left
val phi = degrees - 90f
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x - b, 0f)
bottom.set(center.x + b, rect.height())
} else if (degrees < 180f) {
// left top
val phi = 180f - degrees
val a = center.x
val b = a * tan(phi.toRadians())
top.set(0f, center.y - b)
bottom.set(rect.width(), center.y + b)
} else if (degrees < (180f + alpha)) {
// left bottom
val phi = degrees - 180f
val a = center.x
val b = a * tan(phi.toRadians())
top.set(0f, center.y + b)
bottom.set(rect.width(), center.y - b)
} else if (degrees < 270f) {
// bottom left
val phi = 270f - degrees
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x - b, rect.height())
bottom.set(center.x + b, 0f)
} else if (degrees < (270f + beta)) {
// bottom right
val phi = degrees - 270f
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x + b, rect.height())
bottom.set(center.x - b, 0f)
} else {
// right bottom
val phi = 360f - degrees
val a = center.x
val b = a * tan(phi.toRadians())
top.set(rect.width(), center.y + b)
bottom.set(0f, center.y - b)
}
val (selected, other) = when (selectedEdge) {
CustomChatColorEdge.TOP -> top to bottom
CustomChatColorEdge.BOTTOM -> bottom to top
}
val (selectedPaint, otherPaint) = when (selectedEdge) {
CustomChatColorEdge.TOP -> topColorPaint to bottomColorPaint
CustomChatColorEdge.BOTTOM -> bottomColorPaint to topColorPaint
}
canvas.apply {
save()
translate(rect.top, rect.left)
drawLine(selected.x, selected.y, other.x, other.y, pipeBorderPaint)
drawLine(selected.x, selected.y, other.x, other.y, pipePaint)
drawCircle(other.x, other.y, opaqueThumbRadius + thumbBorder, thumbBorderPaint)
drawCircle(other.x, other.y, opaqueThumbRadius, backgroundPaint)
drawCircle(other.x, other.y, opaqueThumbRadius - opaqueThumbPadding, otherPaint)
drawCircle(selected.x, selected.y, opaqueThumbRadius + thumbBorderSelected, thumbBorderPaintSelected)
drawCircle(selected.x, selected.y, opaqueThumbRadius, backgroundPaint)
drawCircle(selected.x, selected.y, opaqueThumbRadius - opaqueThumbPaddingSelected, selectedPaint)
restore()
}
top.offset(rect.top, rect.left)
bottom.offset(rect.top, rect.left)
}
private fun Float.toDegrees(): Float = this * (180f / Math.PI.toFloat())
private fun Float.toRadians(): Float = this * (Math.PI.toFloat() / 180f)
private fun PointF.distance(other: PointF): Float = abs(sqrt((this.x - other.x).pow(2) + (this.y - other.y).pow(2)))
private fun PointF.dotProduct(other: PointF): Float = (this.x * other.x) + (this.y * other.y)
private fun PointF.determinate(other: PointF): Float = (this.x * other.y) - (this.y * other.x)
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
var activePointerId: Int = MotionEvent.INVALID_POINTER_ID
override fun onDown(e: MotionEvent): Boolean {
activePointerId = e.getPointerId(0)
val touchPoint = PointF(e.getX(activePointerId), e.getY(activePointerId))
val distanceFromTop = touchPoint.distance(top)
if (distanceFromTop <= thumbRadius) {
setSelectedEdge(CustomChatColorEdge.TOP)
return true
}
val distanceFromBottom = touchPoint.distance(bottom)
if (distanceFromBottom <= thumbRadius) {
setSelectedEdge(CustomChatColorEdge.BOTTOM)
return true
}
return false
}
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
val a = PointF(e2.getX(activePointerId) - center.x, e2.getY(activePointerId) - center.y)
val b = PointF(center.x, 0f)
val dot = a.dotProduct(b)
val det = a.determinate(b)
val offset = if (selectedEdge == CustomChatColorEdge.BOTTOM) 180f else 0f
val degrees = (atan2(det, dot).toDegrees() + 360f + offset) % 360f
setDegreesInternal(degrees)
return true
}
}
private fun Float.rotate(degrees: Float): Float = (this + degrees + 360f) % 360f
interface Listener {
fun onGestureStarted()
fun onGestureFinished()
fun onDegreesChanged(degrees: Float)
fun onSelectedEdgeChanged(edge: CustomChatColorEdge)
}
companion object {
@Dimension(unit = Dimension.DP)
private const val THUMB_RADIUS = 24
@Dimension(unit = Dimension.DP)
private const val THUMB_BORDER = 1
@Dimension(unit = Dimension.DP)
private const val THUMB_BORDER_SELECTED = 4
@Dimension(unit = Dimension.DP)
private const val OPAQUE_THUMB_RADIUS = 20
@Dimension(unit = Dimension.DP)
private const val OPAGUE_THUMB_PADDING = 2
@Dimension(unit = Dimension.DP)
private const val OPAGUE_THUMB_PADDING_SELECTED = 1
@Dimension(unit = Dimension.DP)
private const val PIPE_WIDTH = 6
@Dimension(unit = Dimension.DP)
private const val PIPE_BORDER = 1
}
}