Improve touch feedback

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-07 20:21:01 +03:00
Unverified
parent 9a86bbd82f
commit a0615f2cc2
4 changed files with 167 additions and 64 deletions
@@ -0,0 +1,83 @@
package ru.fromchat.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Indication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import com.pr0gramm3r101.utils.conditional
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Scale-down touch effect: scales to [scale] on press and back to 1f on release/cancel.
* When [interactionSource] is null: optionally wraps with [clickable] when [onClick] is non-null.
* When [interactionSource] is non-null: uses it for scale only (caller adds clickable on child so ripple scales with content).
* [indication] when non-null is used for the clickable (e.g. ripple); null = no indication.
* [clipShape] when non-null clips the scaled result.
*/
fun Modifier.scaleOnPress(
scale: Float = 0.96f,
onClick: (() -> Unit)? = null,
indication: Indication? = null,
clipShape: Shape? = null,
interactionSource: MutableInteractionSource? = null
): Modifier = composed {
val source = interactionSource ?: remember { MutableInteractionSource() }
var pressed by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(source) {
var releaseJob: Job? = null
source.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
releaseJob?.cancel()
releaseJob = null
pressed = true
}
is PressInteraction.Release, is PressInteraction.Cancel -> {
releaseJob = scope.launch {
delay(80)
pressed = false
}
}
}
}
}
val scaleValue by animateFloatAsState(
targetValue = if (pressed) scale else 1f,
animationSpec = spring(),
label = "scaleOnPress"
)
Modifier
.conditional(onClick != null && interactionSource == null) {
clickable(
interactionSource = source,
indication = indication,
onClick = onClick!!
)
}
.conditional(clipShape != null) { clip(clipShape!!) }
.graphicsLayer(
scaleX = scaleValue,
scaleY = scaleValue,
transformOrigin = TransformOrigin.Center
)
}
@@ -3,15 +3,10 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -54,8 +49,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
@@ -80,6 +73,7 @@ import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.scaleOnPress
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
@@ -246,36 +240,14 @@ fun ChatScreen(
topBar = {
TopAppBar(
title = {
val titleInteractionSource = remember { MutableInteractionSource() }
var titlePressed by remember { mutableStateOf(false) }
LaunchedEffect(titleInteractionSource) {
titleInteractionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> titlePressed = true
is PressInteraction.Release -> titlePressed = false
}
}
}
val titleScale by animateFloatAsState(
targetValue = if (titlePressed) 0.96f else 1f,
animationSpec = tween(durationMillis = 100),
label = "title_scale"
)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(
scaleX = titleScale,
scaleY = titleScale,
transformOrigin = TransformOrigin.Center
)
.then(
if (profileUserId != null && onTitleClick != null) {
Modifier.clickable(
interactionSource = titleInteractionSource,
indication = null
) { onTitleClick() }
} else Modifier
.scaleOnPress(
scale = 0.96f,
onClick = if (profileUserId != null && onTitleClick != null) {
{ onTitleClick() }
} else null
),
verticalAlignment = Alignment.CenterVertically
) {
@@ -2,6 +2,9 @@ package ru.fromchat.ui.profile
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -21,10 +24,12 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
@@ -32,6 +37,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -223,15 +229,32 @@ fun ProfileScreen(
)
Spacer(modifier = Modifier.height(16.dp))
Category(Modifier.padding(top = 0.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(0.dp)
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
val chatSource = remember { MutableInteractionSource() }
Card(
modifier = Modifier
.weight(1f)
.scaleOnPress(
scale = 0.90f,
interactionSource = chatSource,
clipShape = MaterialTheme.shapes.extraLarge
),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Box(
modifier = Modifier
.weight(1f)
.scaleOnPress(0.96f) { onChat(profile.id) },
.fillMaxWidth()
.clickable(
interactionSource = chatSource,
indication = LocalIndication.current,
onClick = { onChat(profile.id) }
),
contentAlignment = Alignment.Center
) {
Column(
@@ -250,13 +273,30 @@ fun ProfileScreen(
)
}
}
Box(
}
val linkSource = remember { MutableInteractionSource() }
Card(
modifier = Modifier
.weight(1f)
.scaleOnPress(0.96f) {
.scaleOnPress(
scale = 0.90f,
interactionSource = linkSource,
clipShape = MaterialTheme.shapes.extraLarge
),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = linkSource,
indication = LocalIndication.current,
onClick = {
clipboardManager.setText(AnnotatedString(profileLink))
state = state.copy(linkStatus = "Link copied!")
},
}
),
contentAlignment = Alignment.Center
) {
Column(
@@ -292,10 +332,12 @@ fun ProfileScreen(
headline = "Username",
supportingText = profile.username,
leadingContent = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Icon(
imageVector = Icons.Filled.AlternateEmail,
contentDescription = null
)
}
},
divider = true,
dividerColor = CategoryDefaults.dividerColor,
@@ -310,11 +352,13 @@ fun ProfileScreen(
dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness,
leadingContent = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null
)
}
}
)
}
@@ -325,11 +369,13 @@ fun ProfileScreen(
dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness,
leadingContent = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Icon(
imageVector = Icons.Filled.CalendarMonth,
contentDescription = null
)
}
}
)
}
}
@@ -101,7 +101,7 @@ fun ListItem(
bodyModifier: Modifier = Modifier,
headline: String,
supportingText: String? = null,
leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
enabled: Boolean = true,
divider: Boolean = false,
@@ -147,7 +147,7 @@ fun ListItem(
@Composable
fun leadingBody() {
if (leadingContent != null) {
ConstraintLayout(
Box(
modifier = Modifier
.constrainAs(leading) {
top link parent.top
@@ -162,8 +162,10 @@ fun ListItem(
top = 8.dp,
bottom = 8.dp
),
content = leadingContent
)
contentAlignment = Alignment.Center
) {
leadingContent()
}
}
ListItem(
headlineContent = { Text(headline) },
@@ -281,7 +283,7 @@ inline fun SwitchListItem(
modifier: Modifier = Modifier,
headline: String,
supportingText: String? = null,
noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
noinline leadingContent: (@Composable () -> Unit)? = null,
checked: Boolean,
noinline onCheckedChange: (Boolean) -> Unit,
crossinline listItemOnClick: () -> Unit = { onCheckedChange(!checked) },
@@ -323,7 +325,7 @@ inline fun SeparatedSwitchListItem(
bodyModifier: Modifier = Modifier,
headline: String,
supportingText: String? = null,
noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
noinline leadingContent: (@Composable () -> Unit)? = null,
checked: Boolean,
noinline onCheckedChange: (Boolean) -> Unit,
noinline bodyOnClick: () -> Unit,