kopia lustrzana https://github.com/ryukoposting/Signal-Android
344 wiersze
11 KiB
Kotlin
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
|
|
}
|
|
}
|