257 lines
9.7 KiB
Kotlin
257 lines
9.7 KiB
Kotlin
|
|
package no.naiv.tiltshift.ui
|
||
|
|
|
||
|
|
import androidx.compose.foundation.Canvas
|
||
|
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||
|
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||
|
|
import androidx.compose.foundation.gestures.calculateCentroid
|
||
|
|
import androidx.compose.foundation.gestures.calculateRotation
|
||
|
|
import androidx.compose.foundation.gestures.calculateZoom
|
||
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
||
|
|
import androidx.compose.runtime.Composable
|
||
|
|
import androidx.compose.runtime.getValue
|
||
|
|
import androidx.compose.runtime.mutableFloatStateOf
|
||
|
|
import androidx.compose.runtime.mutableStateOf
|
||
|
|
import androidx.compose.runtime.remember
|
||
|
|
import androidx.compose.runtime.setValue
|
||
|
|
import androidx.compose.ui.Modifier
|
||
|
|
import androidx.compose.ui.geometry.Offset
|
||
|
|
import androidx.compose.ui.graphics.Color
|
||
|
|
import androidx.compose.ui.graphics.PathEffect
|
||
|
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||
|
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||
|
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||
|
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
||
|
|
import androidx.compose.ui.unit.dp
|
||
|
|
import no.naiv.tiltshift.effect.BlurParameters
|
||
|
|
import kotlin.math.PI
|
||
|
|
import kotlin.math.cos
|
||
|
|
import kotlin.math.sin
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Type of gesture being performed.
|
||
|
|
*/
|
||
|
|
private enum class GestureType {
|
||
|
|
NONE,
|
||
|
|
DRAG_POSITION, // Single finger drag to move focus position
|
||
|
|
ROTATE, // Two-finger rotation
|
||
|
|
PINCH_SIZE, // Pinch near blur edges to resize
|
||
|
|
PINCH_ZOOM // Pinch in center to zoom camera
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Overlay that shows tilt-shift effect controls and handles gestures.
|
||
|
|
*/
|
||
|
|
@Composable
|
||
|
|
fun TiltShiftOverlay(
|
||
|
|
params: BlurParameters,
|
||
|
|
onParamsChange: (BlurParameters) -> Unit,
|
||
|
|
onZoomChange: (Float) -> Unit,
|
||
|
|
modifier: Modifier = Modifier
|
||
|
|
) {
|
||
|
|
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
|
||
|
|
var initialZoom by remember { mutableFloatStateOf(1f) }
|
||
|
|
var initialAngle by remember { mutableFloatStateOf(0f) }
|
||
|
|
var initialSize by remember { mutableFloatStateOf(0.3f) }
|
||
|
|
|
||
|
|
Canvas(
|
||
|
|
modifier = modifier
|
||
|
|
.fillMaxSize()
|
||
|
|
.pointerInput(Unit) {
|
||
|
|
awaitEachGesture {
|
||
|
|
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||
|
|
currentGesture = GestureType.NONE
|
||
|
|
|
||
|
|
var previousCentroid = firstDown.position
|
||
|
|
var previousPointerCount = 1
|
||
|
|
var accumulatedRotation = 0f
|
||
|
|
var accumulatedZoom = 1f
|
||
|
|
|
||
|
|
initialAngle = params.angle
|
||
|
|
initialSize = params.size
|
||
|
|
initialZoom = 1f
|
||
|
|
|
||
|
|
do {
|
||
|
|
val event = awaitPointerEvent()
|
||
|
|
val pointers = event.changes.filter { it.pressed }
|
||
|
|
|
||
|
|
if (pointers.isEmpty()) break
|
||
|
|
|
||
|
|
val centroid = if (pointers.size >= 2) {
|
||
|
|
event.calculateCentroid()
|
||
|
|
} else {
|
||
|
|
pointers.first().position
|
||
|
|
}
|
||
|
|
|
||
|
|
when {
|
||
|
|
// Two or more fingers
|
||
|
|
pointers.size >= 2 -> {
|
||
|
|
val rotation = event.calculateRotation()
|
||
|
|
val zoom = event.calculateZoom()
|
||
|
|
|
||
|
|
// Determine gesture type based on touch positions
|
||
|
|
if (currentGesture == GestureType.NONE || currentGesture == GestureType.DRAG_POSITION) {
|
||
|
|
currentGesture = determineGestureType(
|
||
|
|
centroid,
|
||
|
|
size.width.toFloat(),
|
||
|
|
size.height.toFloat(),
|
||
|
|
params
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
when (currentGesture) {
|
||
|
|
GestureType.ROTATE -> {
|
||
|
|
accumulatedRotation += rotation
|
||
|
|
val newAngle = initialAngle + accumulatedRotation
|
||
|
|
onParamsChange(params.copy(angle = newAngle))
|
||
|
|
}
|
||
|
|
GestureType.PINCH_SIZE -> {
|
||
|
|
accumulatedZoom *= zoom
|
||
|
|
val newSize = (initialSize * accumulatedZoom)
|
||
|
|
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
|
||
|
|
onParamsChange(params.copy(size = newSize))
|
||
|
|
}
|
||
|
|
GestureType.PINCH_ZOOM -> {
|
||
|
|
onZoomChange(zoom)
|
||
|
|
}
|
||
|
|
else -> {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Single finger
|
||
|
|
pointers.size == 1 -> {
|
||
|
|
if (currentGesture == GestureType.NONE) {
|
||
|
|
currentGesture = GestureType.DRAG_POSITION
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentGesture == GestureType.DRAG_POSITION) {
|
||
|
|
val deltaY = (centroid.y - previousCentroid.y) / size.height
|
||
|
|
val newPosition = (params.position + deltaY).coerceIn(0f, 1f)
|
||
|
|
onParamsChange(params.copy(position = newPosition))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
previousCentroid = centroid
|
||
|
|
previousPointerCount = pointers.size
|
||
|
|
|
||
|
|
// Consume all pointer changes
|
||
|
|
pointers.forEach { it.consume() }
|
||
|
|
} while (event.type != PointerEventType.Release)
|
||
|
|
|
||
|
|
currentGesture = GestureType.NONE
|
||
|
|
}
|
||
|
|
}
|
||
|
|
) {
|
||
|
|
drawTiltShiftOverlay(params)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determines the type of two-finger gesture based on touch position.
|
||
|
|
*/
|
||
|
|
private fun determineGestureType(
|
||
|
|
centroid: Offset,
|
||
|
|
width: Float,
|
||
|
|
height: Float,
|
||
|
|
params: BlurParameters
|
||
|
|
): GestureType {
|
||
|
|
// Calculate distance from focus center line
|
||
|
|
val focusCenterY = height * params.position
|
||
|
|
val focusHalfHeight = height * params.size * 0.5f
|
||
|
|
|
||
|
|
// Rotate centroid to align with focus line
|
||
|
|
val dx = centroid.x - width / 2f
|
||
|
|
val dy = centroid.y - focusCenterY
|
||
|
|
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
|
||
|
|
|
||
|
|
val distFromCenter = kotlin.math.abs(rotatedY)
|
||
|
|
|
||
|
|
return when {
|
||
|
|
// Near the edges of the blur zone -> size adjustment
|
||
|
|
distFromCenter > focusHalfHeight * 0.7f && distFromCenter < focusHalfHeight * 1.5f -> {
|
||
|
|
GestureType.PINCH_SIZE
|
||
|
|
}
|
||
|
|
// Inside the focus zone -> rotation
|
||
|
|
distFromCenter < focusHalfHeight * 0.7f -> {
|
||
|
|
GestureType.ROTATE
|
||
|
|
}
|
||
|
|
// Outside -> camera zoom
|
||
|
|
else -> {
|
||
|
|
GestureType.PINCH_ZOOM
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Draws the tilt-shift visualization overlay.
|
||
|
|
*/
|
||
|
|
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
|
||
|
|
val width = size.width
|
||
|
|
val height = size.height
|
||
|
|
|
||
|
|
val centerY = height * params.position
|
||
|
|
val focusHalfHeight = height * params.size * 0.5f
|
||
|
|
val angleDegrees = params.angle * (180f / PI.toFloat())
|
||
|
|
|
||
|
|
// Colors for overlay
|
||
|
|
val focusLineColor = Color(0xFFFFB300) // Amber
|
||
|
|
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
|
||
|
|
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
|
||
|
|
|
||
|
|
rotate(angleDegrees, pivot = Offset(width / 2f, centerY)) {
|
||
|
|
// Draw blur zone indicators (top and bottom)
|
||
|
|
drawRect(
|
||
|
|
color = blurZoneColor,
|
||
|
|
topLeft = Offset(0f, 0f),
|
||
|
|
size = androidx.compose.ui.geometry.Size(width, centerY - focusHalfHeight)
|
||
|
|
)
|
||
|
|
drawRect(
|
||
|
|
color = blurZoneColor,
|
||
|
|
topLeft = Offset(0f, centerY + focusHalfHeight),
|
||
|
|
size = androidx.compose.ui.geometry.Size(width, height - (centerY + focusHalfHeight))
|
||
|
|
)
|
||
|
|
|
||
|
|
// Draw focus zone boundary lines
|
||
|
|
drawLine(
|
||
|
|
color = focusLineColor,
|
||
|
|
start = Offset(0f, centerY - focusHalfHeight),
|
||
|
|
end = Offset(width, centerY - focusHalfHeight),
|
||
|
|
strokeWidth = 2.dp.toPx(),
|
||
|
|
pathEffect = dashEffect
|
||
|
|
)
|
||
|
|
drawLine(
|
||
|
|
color = focusLineColor,
|
||
|
|
start = Offset(0f, centerY + focusHalfHeight),
|
||
|
|
end = Offset(width, centerY + focusHalfHeight),
|
||
|
|
strokeWidth = 2.dp.toPx(),
|
||
|
|
pathEffect = dashEffect
|
||
|
|
)
|
||
|
|
|
||
|
|
// Draw center focus line
|
||
|
|
drawLine(
|
||
|
|
color = focusLineColor,
|
||
|
|
start = Offset(0f, centerY),
|
||
|
|
end = Offset(width, centerY),
|
||
|
|
strokeWidth = 3.dp.toPx()
|
||
|
|
)
|
||
|
|
|
||
|
|
// Draw rotation indicator at center
|
||
|
|
val indicatorRadius = 30.dp.toPx()
|
||
|
|
drawCircle(
|
||
|
|
color = focusLineColor.copy(alpha = 0.5f),
|
||
|
|
radius = indicatorRadius,
|
||
|
|
center = Offset(width / 2f, centerY),
|
||
|
|
style = Stroke(width = 2.dp.toPx())
|
||
|
|
)
|
||
|
|
|
||
|
|
// Draw angle tick mark
|
||
|
|
val tickLength = 15.dp.toPx()
|
||
|
|
drawLine(
|
||
|
|
color = focusLineColor,
|
||
|
|
start = Offset(width / 2f, centerY - indicatorRadius + tickLength),
|
||
|
|
end = Offset(width / 2f, centerY - indicatorRadius - 5.dp.toPx()),
|
||
|
|
strokeWidth = 3.dp.toPx()
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|