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.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.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
.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(
|
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(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
}
|
||||||
|
val linkSource = remember { MutableInteractionSource() }
|
||||||
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.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))
|
clipboardManager.setText(AnnotatedString(profileLink))
|
||||||
state = state.copy(linkStatus = "Link copied!")
|
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 = {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.AlternateEmail,
|
imageVector = Icons.Filled.AlternateEmail,
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
divider = true,
|
divider = true,
|
||||||
dividerColor = CategoryDefaults.dividerColor,
|
dividerColor = CategoryDefaults.dividerColor,
|
||||||
@@ -310,11 +352,13 @@ fun ProfileScreen(
|
|||||||
dividerColor = CategoryDefaults.dividerColor,
|
dividerColor = CategoryDefaults.dividerColor,
|
||||||
dividerThickness = CategoryDefaults.dividerThickness,
|
dividerThickness = CategoryDefaults.dividerThickness,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Info,
|
imageVector = Icons.Filled.Info,
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,11 +369,13 @@ fun ProfileScreen(
|
|||||||
dividerColor = CategoryDefaults.dividerColor,
|
dividerColor = CategoryDefaults.dividerColor,
|
||||||
dividerThickness = CategoryDefaults.dividerThickness,
|
dividerThickness = CategoryDefaults.dividerThickness,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.CalendarMonth,
|
imageVector = Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user