tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Ole-Morten Duesund 72156f4e5d Add dark outlines to overlay guides and improve gesture zones
- Draw all guide lines with a dark outline behind the accent color
  to ensure visibility over bright/amber camera scenes
- Clamp gesture zone focus size to MIN_FOCUS_SIZE_PX (150px) so
  rotation zone remains usable at small focus sizes
- Add semantics contentDescription to Canvas for TalkBack
- Use AppColors for centralized color references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:07:50 +01:00

469 lines
19 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.calculateZoom
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Type of gesture being performed.
*/
private enum class GestureType {
NONE,
DRAG_POSITION, // Single finger drag to move focus center
ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch far outside to zoom camera
}
// Sensitivity factor for size pinch (lower = less sensitive)
private const val SIZE_SENSITIVITY = 0.3f
private const val ZOOM_SENSITIVITY = 0.5f
/** Minimum focus size (in px) for gesture zones to ensure usable touch targets. */
private const val MIN_FOCUS_SIZE_PX = 150f
/**
* Calculates the angle between two touch points.
*/
private fun angleBetweenPoints(p1: Offset, p2: Offset): Float {
return atan2(p2.y - p1.y, p2.x - p1.x)
}
/**
* Overlay that shows tilt-shift effect controls and handles gestures.
*/
@Composable
fun TiltShiftOverlay(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onZoomChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
// Use rememberUpdatedState to always get latest values inside pointerInput
val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
val currentOnZoomChange by rememberUpdatedState(onZoomChange)
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
val modeLabel = if (params.mode == BlurMode.LINEAR) "linear" else "radial"
Canvas(
modifier = modifier
.fillMaxSize()
.semantics {
contentDescription = "Tilt-shift overlay: $modeLabel mode. " +
"Drag to move focus. Pinch near edges to resize. " +
"Pinch near center to rotate. Use sliders in controls panel for alternative adjustment."
}
.pointerInput(Unit) {
awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
currentGesture = GestureType.NONE
// Capture initial state at gesture start
val gestureStartParams = currentParams
var initialTouchAngle = 0f
var initialDragCentroid = firstDown.position
var accumulatedZoom = 1f
var rotationInitialized = false
var dragInitialized = false
var previousCentroid = firstDown.position
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 p1 = pointers[0].position
val p2 = pointers[1].position
val currentTouchAngle = angleBetweenPoints(p1, p2)
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(),
currentParams
)
rotationInitialized = false
}
when (currentGesture) {
GestureType.ROTATE -> {
if (!rotationInitialized) {
initialTouchAngle = currentTouchAngle
rotationInitialized = true
} else {
val angleDelta = currentTouchAngle - initialTouchAngle
val newAngle = gestureStartParams.angle + angleDelta
currentOnParamsChange(currentParams.copy(angle = newAngle))
}
}
GestureType.PINCH_SIZE -> {
val dampenedZoom = 1f + (zoom - 1f) * SIZE_SENSITIVITY
accumulatedZoom *= dampenedZoom
val newSize = (gestureStartParams.size * accumulatedZoom)
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
currentOnParamsChange(currentParams.copy(size = newSize))
}
GestureType.PINCH_ZOOM -> {
val dampenedZoom = 1f + (zoom - 1f) * ZOOM_SENSITIVITY
currentOnZoomChange(dampenedZoom)
}
else -> {}
}
}
// Single finger - drag to move focus center 1:1
pointers.size == 1 -> {
if (currentGesture == GestureType.NONE) {
currentGesture = GestureType.DRAG_POSITION
dragInitialized = false
}
if (currentGesture == GestureType.DRAG_POSITION) {
if (!dragInitialized) {
initialDragCentroid = centroid
dragInitialized = true
} else {
// Calculate total drag from initial touch point
val totalDragX = (centroid.x - initialDragCentroid.x) / size.width
val totalDragY = (centroid.y - initialDragCentroid.y) / size.height
val newX = (gestureStartParams.positionX + totalDragX).coerceIn(0f, 1f)
val newY = (gestureStartParams.positionY + totalDragY).coerceIn(0f, 1f)
currentOnParamsChange(currentParams.copy(positionX = newX, positionY = newY))
}
}
}
}
previousCentroid = centroid
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.
*
* Zones (from center outward):
* - Very center (< 30% of focus size): Rotation
* - Near focus region (30% - 200% of focus size): Size adjustment
* - Far outside (> 200%): Camera zoom
*
* Focus size is clamped to a minimum pixel value to keep zones usable at small sizes.
*/
private fun determineGestureType(
centroid: Offset,
width: Float,
height: Float,
params: BlurParameters
): GestureType {
val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY
// Clamp focus size to minimum to keep rotation zone reachable
val focusSize = maxOf(height * params.size * 0.5f, MIN_FOCUS_SIZE_PX)
val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY
val distFromCenter = when (params.mode) {
BlurMode.LINEAR -> {
// For linear mode, use perpendicular distance to focus line
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
kotlin.math.abs(rotatedY)
}
BlurMode.RADIAL -> {
// For radial mode, use distance from center
sqrt(dx * dx + dy * dy)
}
}
return when {
// Very center of focus zone -> rotation (small area)
distFromCenter < focusSize * 0.3f -> GestureType.ROTATE
// Near the blur effect -> size adjustment (large area)
distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE
// Far outside -> camera zoom
else -> GestureType.PINCH_ZOOM
}
}
/**
* Draws the tilt-shift visualization overlay.
* Supports both linear and radial modes.
*/
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
when (params.mode) {
BlurMode.LINEAR -> drawLinearOverlay(params)
BlurMode.RADIAL -> drawRadialOverlay(params)
}
}
/**
* Draws the linear mode overlay (horizontal band with rotation).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/
private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val width = size.width
val height = size.height
val centerX = width * params.positionX
val centerY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
val focusLineColor = AppColors.Accent
val outlineColor = AppColors.OverlayOutline
val blurZoneColor = AppColors.OverlayLinearBlur
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = sqrt(width * width + height * height)
val extendedHalf = diagonal
val outlineWidth = 4.dp.toPx()
val lineWidth = 2.dp.toPx()
val centerLineWidth = 3.dp.toPx()
val centerOutlineWidth = 5.dp.toPx()
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw blur zone indicators (top and bottom)
drawRect(
color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf),
size = Size(extendedHalf * 2, extendedHalf)
)
drawRect(
color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
size = Size(extendedHalf * 2, extendedHalf)
)
// Draw focus zone boundary lines (outline first, then color)
// Top boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect
)
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = lineWidth,
pathEffect = dashEffect
)
// Bottom boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect
)
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = lineWidth,
pathEffect = dashEffect
)
// Draw center focus line (outline + color)
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerOutlineWidth
)
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerLineWidth
)
// Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx()
drawCircle(
color = outlineColor,
radius = indicatorRadius,
center = Offset(centerX, centerY),
style = Stroke(width = 4.dp.toPx())
)
drawCircle(
color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius,
center = Offset(centerX, centerY),
style = Stroke(width = 2.dp.toPx())
)
// Draw angle tick mark
val tickLength = 15.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength),
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine(
color = focusLineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength),
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 3.dp.toPx()
)
}
}
/**
* Draws the radial mode overlay (ellipse/circle).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/
private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val width = size.width
val height = size.height
val centerX = width * params.positionX
val centerY = height * params.positionY
val focusRadius = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
val focusLineColor = AppColors.Accent
val outlineColor = AppColors.OverlayOutline
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f)
// Calculate ellipse dimensions based on aspect ratio
val ellipseWidth = focusRadius * 2 * params.aspectRatio
val ellipseHeight = focusRadius * 2
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw focus ellipse outline (outline + color)
drawOval(
color = outlineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
size = Size(ellipseWidth, ellipseHeight),
style = Stroke(width = 5.dp.toPx())
)
drawOval(
color = focusLineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
size = Size(ellipseWidth, ellipseHeight),
style = Stroke(width = 3.dp.toPx())
)
// Draw outer blur boundary (with falloff)
val outerScale = 1f + params.falloff
drawOval(
color = outlineColor,
topLeft = Offset(
centerX - (ellipseWidth * outerScale) / 2,
centerY - (ellipseHeight * outerScale) / 2
),
size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale),
style = Stroke(width = 4.dp.toPx(), pathEffect = dashEffect)
)
drawOval(
color = focusLineColor.copy(alpha = 0.5f),
topLeft = Offset(
centerX - (ellipseWidth * outerScale) / 2,
centerY - (ellipseHeight * outerScale) / 2
),
size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale),
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
)
// Draw center crosshair (outline + color)
val crosshairSize = 20.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 4.dp.toPx()
)
drawLine(
color = focusLineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 2.dp.toPx()
)
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - crosshairSize),
end = Offset(centerX, centerY + crosshairSize),
strokeWidth = 4.dp.toPx()
)
drawLine(
color = focusLineColor,
start = Offset(centerX, centerY - crosshairSize),
end = Offset(centerX, centerY + crosshairSize),
strokeWidth = 2.dp.toPx()
)
// Draw rotation indicator (small line at top of ellipse, outline + color)
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine(
color = focusLineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()),
strokeWidth = 3.dp.toPx()
)
}
}