Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,33 @@ internal class AdjustmentBarState(
if (isNewTick) {
reportTick(tick = target)
}
super.release()
finishDrag()
animateTo(tick = target)
if (isNewTick) {
performTickFeedback(tick = target)
}
}

override suspend fun cancel() {
finishDrag()
animateTo(tick = reportedTick)
}

override suspend fun syncTo(value: Float) {
animateTo(tick = currentScale.value.tickOf(value = value))
}

fun stepWithFeedback(ticks: Int): Boolean {
val target = (valueTick() + ticks).coerceIn(0, currentScale.value.lastTick)
val moves = moveToTick(tick = target)

if (moves) {
performTickFeedback(tick = target)
}

return moves
}

override fun onDragged(
from: Float,
to: Float,
Expand All @@ -75,17 +91,6 @@ internal class AdjustmentBarState(
}
}

fun stepWithFeedback(ticks: Int): Boolean {
val target = (valueTick() + ticks).coerceIn(0, currentScale.value.lastTick)
val moves = moveToTick(tick = target)

if (moves) {
performTickFeedback(tick = target)
}

return moves
}

private fun moveToTick(tick: Int): Boolean {
val target = tick.coerceIn(0, currentScale.value.lastTick)
val moves = target != valueTick()
Expand Down Expand Up @@ -146,7 +151,7 @@ internal fun rememberAdjustmentBarState(
bindings: RulerBindings,
): AdjustmentBarState {
val currentScale = rememberUpdatedState(scale)
val state = remember {
val state = remember(scale) {
AdjustmentBarState(
initialTick = scale.tickOf(value = value),
currentScale = currentScale,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package app.grapheneos.camera.ui.components.modeselector

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import app.grapheneos.camera.ui.components.modeselector.gesture.ModeSelectorEffects
import app.grapheneos.camera.ui.components.modeselector.gesture.ModeSelectorState
import app.grapheneos.camera.ui.components.modeselector.gesture.modeSelectorInput
import app.grapheneos.camera.ui.components.modeselector.gesture.rememberModeSelectorState
import app.grapheneos.camera.ui.components.motion.animateEnabledAlpha
import app.grapheneos.camera.ui.core.CameraPreviewColumn

/**
* [onModeSelected] is called once the strip has settled on another mode after a release or a tap;
* the caller sets [selectedIndex] to it at once, since the strip animates to any other value.
* [ModeSelectorState.settleNow] finishes a settle at once, for a capture, and so does leaving
* composition. [labels] and [selectedIndex] change together: new [labels] drop a pending settle and
* show [selectedIndex] at once. While [switching], touches are ignored without the dimming of
* [enabled].
*/
@Composable
internal fun ModeSelector(
labels: List<String>,
selectedIndex: Int,
onModeSelected: (index: Int) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
switching: Boolean = false,
state: ModeSelectorState = rememberModeSelectorState(initialIndex = selectedIndex),
colors: ModeSelectorColors = ModeSelectorColors.fromTheme(),
) {
require(selectedIndex in labels.indices) {
"selectedIndex must be in ${labels.indices}, was $selectedIndex"
}

val acceptsInput = enabled && !switching
val alpha by animateEnabledAlpha(enabled = enabled)

ModeSelectorEffects(
state = state,
labels = labels,
selectedIndex = selectedIndex,
onModeSelected = onModeSelected,
)
ModeSelectorLayers(
labels = labels,
selectedIndex = selectedIndex,
state = state,
enabled = acceptsInput,
colors = colors,
modifier = modifier
.graphicsLayer { this.alpha = alpha }
.modeSelectorInput(
state = state,
enabled = acceptsInput,
layoutDirection = LocalLayoutDirection.current,
),
)
}

@Composable
private fun ModeSelectorLayers(
labels: List<String>,
selectedIndex: Int,
state: ModeSelectorState,
enabled: Boolean,
colors: ModeSelectorColors,
modifier: Modifier = Modifier,
) {
val style = MaterialTheme.typography.labelLarge.copy(color = colors.contentColor)
val selectActions = remember(labels, state) {
labels.mapIndexed { index, label ->
CustomAccessibilityAction(label = label) {
state.select(index = index)
true
}
}
}

Layout(
content = {
labels.forEachIndexed { index, label ->
ModeSelectorItem(
label = label,
index = index,
selected = index == selectedIndex,
enabled = enabled,
state = state,
style = style,
highlightContentColor = colors.selectedContentColor,
customActions = when {
enabled -> selectActions.filterIndexed { other, _ -> other != index }
else -> emptyList()
},
)
}
},
modifier = modifier
.clipToBounds()
.semantics {
collectionInfo = CollectionInfo(
rowCount = 1,
columnCount = labels.size,
)
}
.drawBehind {
drawModeSelectorHighlight(
state = state,
color = colors.selectedContainerColor,
)
},
measurePolicy = rememberModeSelectorMeasurePolicy(state = state),
)
}

@PreviewLightDark
@Composable
private fun ModeSelectorPreview() {
CameraPreviewColumn {
Column(
verticalArrangement = Arrangement.spacedBy(space = 16.dp),
) {
PreviewModeSelector(selectedIndex = 0)
PreviewModeSelector(selectedIndex = 2)
PreviewModeSelector(
selectedIndex = 2,
enabled = false,
)
}
}
}

@Composable
private fun PreviewModeSelector(
selectedIndex: Int,
labels: List<String> = listOf("Long Exposure", "Portrait", "Photo", "Night Sight", "Panorama"),
enabled: Boolean = true,
) {
var currentIndex by remember { mutableIntStateOf(selectedIndex) }

ModeSelector(
labels = labels,
selectedIndex = currentIndex,
onModeSelected = { index -> currentIndex = index },
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package app.grapheneos.camera.ui.components.modeselector

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import app.grapheneos.camera.ui.core.cameraColors

@Immutable
internal data class ModeSelectorColors(
val contentColor: Color,
val selectedContainerColor: Color,
val selectedContentColor: Color,
) {

companion object {
@Composable
@ReadOnlyComposable
fun fromTheme(): ModeSelectorColors {
val colorScheme = MaterialTheme.colorScheme

return ModeSelectorColors(
contentColor = MaterialTheme.cameraColors.overlay,
selectedContainerColor = colorScheme.primaryFixed,
selectedContentColor = colorScheme.onPrimaryFixed,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package app.grapheneos.camera.ui.components.modeselector

import androidx.compose.ui.util.lerp
import kotlin.math.roundToInt

internal class ModeSelectorGeometry(
itemWidths: List<Float>,
private val containerWidth: Float,
private val highlightInset: Float,
) {

private val widths = itemWidths.toFloatArray()
private val starts = itemWidths.runningFold(0f) { start, width -> start + width }.toFloatArray()
private val centers = FloatArray(widths.size) { index -> starts[index] + widths[index] / 2 }

fun itemStart(
index: Int,
position: Float,
): Int {
return (containerWidth / 2 - center(position = position) + starts[index]).roundToInt()
}

fun width(position: Float): Float {
return interpolate(
values = widths,
position = position,
)
}

fun center(position: Float): Float {
return interpolate(
values = centers,
position = position,
)
}

fun position(center: Float): Float {
if (centers.size < 2) {
return 0f
}

val clamped = center.coerceIn(centers.first(), centers.last())
val index = centers
.indexOfLast { itemCenter -> itemCenter <= clamped }
.coerceAtMost(centers.lastIndex - 1)

return index + (clamped - centers[index]) / (centers[index + 1] - centers[index])
}

fun nearestIndex(position: Float): Int {
return position.roundToInt().coerceAtMost(widths.lastIndex).coerceAtLeast(0)
}

fun highlightWidth(position: Float): Float {
return width(position = position) - highlightInset * 2
}

fun highlightStartIn(
index: Int,
position: Float,
isRtl: Boolean,
): Float {
val highlight = highlightWidth(position = position)
val itemStart = itemStart(
index = index,
position = position,
).toFloat()
val itemLeft = when {
isRtl -> containerWidth - itemStart - widths[index]
else -> itemStart
}

return (containerWidth - highlight) / 2 - itemLeft
}

private fun interpolate(
values: FloatArray,
position: Float,
): Float {
val from = position.toInt().coerceIn(0, values.lastIndex)
val to = (from + 1).coerceAtMost(values.lastIndex)

return lerp(
start = values[from],
stop = values[to],
fraction = position - from,
)
}
}
Loading
Loading