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.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope 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.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color 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.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -80,6 +73,7 @@ import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.scaleOnPress
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
@@ -246,36 +240,14 @@ fun ChatScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer( .scaleOnPress(
scaleX = titleScale, scale = 0.96f,
scaleY = titleScale, onClick = if (profileUserId != null && onTitleClick != null) {
transformOrigin = TransformOrigin.Center { onTitleClick() }
) } else null
.then(
if (profileUserId != null && onTitleClick != null) {
Modifier.clickable(
interactionSource = titleInteractionSource,
indication = null
) { onTitleClick() }
} else Modifier
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -2,6 +2,9 @@ package ru.fromchat.ui.profile
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.CalendarMonth
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -32,6 +37,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -223,15 +229,32 @@ fun ProfileScreen(
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Category(Modifier.padding(top = 0.dp)) { Row(
Row( modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(0.dp) .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( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.scaleOnPress(0.96f) { onChat(profile.id) }, .clickable(
interactionSource = chatSource,
indication = LocalIndication.current,
onClick = { onChat(profile.id) }
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
@@ -250,13 +273,30 @@ fun ProfileScreen(
) )
} }
} }
}
val linkSource = remember { MutableInteractionSource() }
Card(
modifier = Modifier
.weight(1f)
.scaleOnPress(
scale = 0.90f,
interactionSource = linkSource,
clipShape = MaterialTheme.shapes.extraLarge
),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.scaleOnPress(0.96f) { .clickable(
clipboardManager.setText(AnnotatedString(profileLink)) interactionSource = linkSource,
state = state.copy(linkStatus = "Link copied!") indication = LocalIndication.current,
}, onClick = {
clipboardManager.setText(AnnotatedString(profileLink))
state = state.copy(linkStatus = "Link copied!")
}
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
@@ -292,10 +332,12 @@ fun ProfileScreen(
headline = "Username", headline = "Username",
supportingText = profile.username, supportingText = profile.username,
leadingContent = { leadingContent = {
Icon( CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
imageVector = Icons.Filled.AlternateEmail, Icon(
contentDescription = null imageVector = Icons.Filled.AlternateEmail,
) contentDescription = null
)
}
}, },
divider = true, divider = true,
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
@@ -310,10 +352,12 @@ fun ProfileScreen(
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness, dividerThickness = CategoryDefaults.dividerThickness,
leadingContent = { leadingContent = {
Icon( CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
imageVector = Icons.Filled.Info, Icon(
contentDescription = null imageVector = Icons.Filled.Info,
) contentDescription = null
)
}
} }
) )
} }
@@ -325,10 +369,12 @@ fun ProfileScreen(
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness, dividerThickness = CategoryDefaults.dividerThickness,
leadingContent = { leadingContent = {
Icon( CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
imageVector = Icons.Filled.CalendarMonth, Icon(
contentDescription = null imageVector = Icons.Filled.CalendarMonth,
) contentDescription = null
)
}
} }
) )
} }
@@ -101,7 +101,7 @@ fun ListItem(
bodyModifier: Modifier = Modifier, bodyModifier: Modifier = Modifier,
headline: String, headline: String,
supportingText: String? = null, supportingText: String? = null,
leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
enabled: Boolean = true, enabled: Boolean = true,
divider: Boolean = false, divider: Boolean = false,
@@ -147,7 +147,7 @@ fun ListItem(
@Composable @Composable
fun leadingBody() { fun leadingBody() {
if (leadingContent != null) { if (leadingContent != null) {
ConstraintLayout( Box(
modifier = Modifier modifier = Modifier
.constrainAs(leading) { .constrainAs(leading) {
top link parent.top top link parent.top
@@ -162,8 +162,10 @@ fun ListItem(
top = 8.dp, top = 8.dp,
bottom = 8.dp bottom = 8.dp
), ),
content = leadingContent contentAlignment = Alignment.Center
) ) {
leadingContent()
}
} }
ListItem( ListItem(
headlineContent = { Text(headline) }, headlineContent = { Text(headline) },
@@ -281,7 +283,7 @@ inline fun SwitchListItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
headline: String, headline: String,
supportingText: String? = null, supportingText: String? = null,
noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, noinline leadingContent: (@Composable () -> Unit)? = null,
checked: Boolean, checked: Boolean,
noinline onCheckedChange: (Boolean) -> Unit, noinline onCheckedChange: (Boolean) -> Unit,
crossinline listItemOnClick: () -> Unit = { onCheckedChange(!checked) }, crossinline listItemOnClick: () -> Unit = { onCheckedChange(!checked) },
@@ -323,7 +325,7 @@ inline fun SeparatedSwitchListItem(
bodyModifier: Modifier = Modifier, bodyModifier: Modifier = Modifier,
headline: String, headline: String,
supportingText: String? = null, supportingText: String? = null,
noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, noinline leadingContent: (@Composable () -> Unit)? = null,
checked: Boolean, checked: Boolean,
noinline onCheckedChange: (Boolean) -> Unit, noinline onCheckedChange: (Boolean) -> Unit,
noinline bodyOnClick: () -> Unit, noinline bodyOnClick: () -> Unit,