From a0615f2cc2244fcc8554fab9f0adb1d1e992aa5f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 7 Feb 2026 20:21:01 +0300 Subject: [PATCH] Improve touch feedback Signed-off-by: denis0001-dev --- .../kotlin/ru/fromchat/ui/TouchEffects.kt | 83 ++++++++++++++++ .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 40 ++------ .../ru/fromchat/ui/profile/ProfileScreen.kt | 94 ++++++++++++++----- .../pr0gramm3r101/components/Components.kt | 14 +-- 4 files changed, 167 insertions(+), 64 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt new file mode 100644 index 0000000..77255bb --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt @@ -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 + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index c7b3ac8..ffb267f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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 ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 754175e..904df0c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -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) + Row( + 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( ) } } + } + 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( modifier = Modifier - .weight(1f) - .scaleOnPress(0.96f) { - clipboardManager.setText(AnnotatedString(profileLink)) - state = state.copy(linkStatus = "Link copied!") - }, + .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 = { - Icon( - imageVector = Icons.Filled.AlternateEmail, - contentDescription = null - ) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Icon( + imageVector = Icons.Filled.AlternateEmail, + contentDescription = null + ) + } }, divider = true, dividerColor = CategoryDefaults.dividerColor, @@ -310,10 +352,12 @@ fun ProfileScreen( dividerColor = CategoryDefaults.dividerColor, dividerThickness = CategoryDefaults.dividerThickness, leadingContent = { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null - ) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null + ) + } } ) } @@ -325,10 +369,12 @@ fun ProfileScreen( dividerColor = CategoryDefaults.dividerColor, dividerThickness = CategoryDefaults.dividerThickness, leadingContent = { - Icon( - imageVector = Icons.Filled.CalendarMonth, - contentDescription = null - ) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Icon( + imageVector = Icons.Filled.CalendarMonth, + contentDescription = null + ) + } } ) } diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt index 0be4a01..518dadf 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt @@ -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,