mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Improve touch feedback
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user