feat: implement Telegram-style swipe functionality and animation enhancements
This commit is contained in:
@@ -507,65 +507,9 @@ fun ChatDetailScreen(
|
|||||||
isDarkTheme
|
isDarkTheme
|
||||||
)
|
)
|
||||||
|
|
||||||
// <EFBFBD> Edge swipe to go back (iOS/Telegram style)
|
// 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer)
|
||||||
var edgeSwipeOffset by remember { mutableStateOf(0f) }
|
|
||||||
val edgeSwipeThreshold = 100f // px threshold для активации
|
|
||||||
val edgeZoneWidth = 30f // px зона от левого края для начала свайпа
|
|
||||||
var isEdgeSwiping by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Анимация возврата
|
|
||||||
val animatedEdgeOffset by
|
|
||||||
animateFloatAsState(
|
|
||||||
targetValue = edgeSwipeOffset,
|
|
||||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
|
|
||||||
label = "edgeSwipe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 🚀 Весь контент без дополнительной анимации (анимация в MainActivity)
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier.fillMaxSize()
|
||||||
Modifier.fillMaxSize()
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { offset ->
|
|
||||||
// Начинаем свайп только если палец у левого
|
|
||||||
// края
|
|
||||||
isEdgeSwiping = offset.x < edgeZoneWidth
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
if (isEdgeSwiping &&
|
|
||||||
edgeSwipeOffset >
|
|
||||||
edgeSwipeThreshold
|
|
||||||
) {
|
|
||||||
// Свайп достаточный - переходим
|
|
||||||
// назад
|
|
||||||
hideKeyboardAndBack()
|
|
||||||
}
|
|
||||||
edgeSwipeOffset = 0f
|
|
||||||
isEdgeSwiping = false
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
edgeSwipeOffset = 0f
|
|
||||||
isEdgeSwiping = false
|
|
||||||
},
|
|
||||||
onHorizontalDrag = { _, dragAmount ->
|
|
||||||
if (isEdgeSwiping) {
|
|
||||||
// Только вправо (положительный
|
|
||||||
// dragAmount)
|
|
||||||
val newOffset =
|
|
||||||
edgeSwipeOffset + dragAmount
|
|
||||||
edgeSwipeOffset =
|
|
||||||
newOffset.coerceIn(0f, 300f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.graphicsLayer {
|
|
||||||
// Сдвигаем контент при свайпе
|
|
||||||
translationX = animatedEdgeOffset
|
|
||||||
// Легкое затемнение при свайпе
|
|
||||||
alpha = 1f - (animatedEdgeOffset / 600f).coerceIn(0f, 0.3f)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
// Telegram-style solid header background (без blur)
|
// Telegram-style solid header background (без blur)
|
||||||
val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
|
val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
|
||||||
@@ -2176,9 +2120,9 @@ fun ChatDetailScreen(
|
|||||||
InAppCameraScreen(
|
InAppCameraScreen(
|
||||||
onDismiss = { showInAppCamera = false },
|
onDismiss = { showInAppCamera = false },
|
||||||
onPhotoTaken = { photoUri ->
|
onPhotoTaken = { photoUri ->
|
||||||
// Сразу открываем редактор!
|
// Сначала редактор (skipEnterAnimation=1f), потом убираем камеру
|
||||||
showInAppCamera = false
|
|
||||||
pendingCameraPhotoUri = photoUri
|
pendingCameraPhotoUri = photoUri
|
||||||
|
showInAppCamera = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2200,7 +2144,8 @@ fun ChatDetailScreen(
|
|||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
showCaptionInput = true,
|
showCaptionInput = true,
|
||||||
recipientName = user.title
|
recipientName = user.title,
|
||||||
|
skipEnterAnimation = true // Из камеры — мгновенно, без fade
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clipToBounds
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.positionChange
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -976,6 +977,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
// консистентности
|
// консистентности
|
||||||
val currentDialogs = chatsState.dialogs
|
val currentDialogs = chatsState.dialogs
|
||||||
|
|
||||||
|
// Telegram-style: only one item can be swiped open at a time
|
||||||
|
var swipedItemKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Close swiped item when drawer opens
|
||||||
|
LaunchedEffect(drawerState.isOpen) {
|
||||||
|
if (drawerState.isOpen) {
|
||||||
|
swipedItemKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -1046,7 +1057,17 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
isSavedMessages,
|
isSavedMessages,
|
||||||
avatarRepository =
|
avatarRepository =
|
||||||
avatarRepository,
|
avatarRepository,
|
||||||
|
isDrawerOpen = drawerState.isOpen || drawerState.isAnimationRunning,
|
||||||
|
isSwipedOpen =
|
||||||
|
swipedItemKey == dialog.opponentKey,
|
||||||
|
onSwipeStarted = {
|
||||||
|
swipedItemKey = dialog.opponentKey
|
||||||
|
},
|
||||||
|
onSwipeClosed = {
|
||||||
|
if (swipedItemKey == dialog.opponentKey) swipedItemKey = null
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
|
swipedItemKey = null
|
||||||
val user =
|
val user =
|
||||||
chatsViewModel
|
chatsViewModel
|
||||||
.dialogToSearchUser(
|
.dialogToSearchUser(
|
||||||
@@ -1502,6 +1523,10 @@ fun SwipeableDialogItem(
|
|||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
|
isDrawerOpen: Boolean = false,
|
||||||
|
isSwipedOpen: Boolean = false,
|
||||||
|
onSwipeStarted: () -> Unit = {},
|
||||||
|
onSwipeClosed: () -> Unit = {},
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onBlock: () -> Unit = {},
|
onBlock: () -> Unit = {},
|
||||||
@@ -1516,11 +1541,19 @@ fun SwipeableDialogItem(
|
|||||||
// Фиксированная высота элемента (как в DialogItem)
|
// Фиксированная высота элемента (как в DialogItem)
|
||||||
val itemHeight = 80.dp
|
val itemHeight = 80.dp
|
||||||
|
|
||||||
// Анимация возврата
|
// Close when another item starts swiping (like Telegram)
|
||||||
|
LaunchedEffect(isSwipedOpen) {
|
||||||
|
if (!isSwipedOpen && offsetX != 0f) {
|
||||||
|
offsetX = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram-style easing
|
||||||
|
val telegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||||
val animatedOffsetX by
|
val animatedOffsetX by
|
||||||
animateFloatAsState(
|
animateFloatAsState(
|
||||||
targetValue = offsetX,
|
targetValue = offsetX,
|
||||||
animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f),
|
animationSpec = tween(durationMillis = 200, easing = telegramEasing),
|
||||||
label = "swipeOffset"
|
label = "swipeOffset"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1546,6 +1579,7 @@ fun SwipeableDialogItem(
|
|||||||
if (isBlocked) onUnblock()
|
if (isBlocked) onUnblock()
|
||||||
else onBlock()
|
else onBlock()
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
|
onSwipeClosed()
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -1586,6 +1620,7 @@ fun SwipeableDialogItem(
|
|||||||
.clickable {
|
.clickable {
|
||||||
// Закрываем свайп мгновенно перед удалением
|
// Закрываем свайп мгновенно перед удалением
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
|
onSwipeClosed()
|
||||||
onDelete()
|
onDelete()
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -1618,35 +1653,52 @@ fun SwipeableDialogItem(
|
|||||||
.offset { IntOffset(animatedOffsetX.toInt(), 0) }
|
.offset { IntOffset(animatedOffsetX.toInt(), 0) }
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
val edgeZone = 50.dp.toPx() // Зона для drawer
|
val velocityTracker = VelocityTracker()
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
val startX = down.position.x
|
|
||||||
|
// Don't handle swipes when drawer is open
|
||||||
// 🔥 Если касание в левой зоне - НЕ захватываем pointer
|
if (isDrawerOpen) return@awaitEachGesture
|
||||||
// Пусть ModalNavigationDrawer обработает
|
|
||||||
if (startX < edgeZone) {
|
velocityTracker.resetTracking()
|
||||||
return@awaitEachGesture
|
var started = false
|
||||||
}
|
|
||||||
|
|
||||||
// Обрабатываем swipe-to-delete только для остальной части
|
|
||||||
try {
|
try {
|
||||||
horizontalDrag(down.id) { change ->
|
horizontalDrag(down.id) { change ->
|
||||||
val dragAmount = change.positionChange().x
|
val dragAmount = change.positionChange().x
|
||||||
// Только свайп влево
|
|
||||||
|
// First movement determines direction
|
||||||
|
if (!started) {
|
||||||
|
// Swipe right with actions closed — let drawer handle it
|
||||||
|
if (dragAmount > 0 && offsetX == 0f) {
|
||||||
|
return@horizontalDrag
|
||||||
|
}
|
||||||
|
started = true
|
||||||
|
onSwipeStarted()
|
||||||
|
}
|
||||||
|
|
||||||
val newOffset = offsetX + dragAmount
|
val newOffset = offsetX + dragAmount
|
||||||
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
|
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
|
||||||
|
velocityTracker.addPosition(
|
||||||
|
change.uptimeMillis,
|
||||||
|
change.position
|
||||||
|
)
|
||||||
change.consume()
|
change.consume()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
// onDragEnd
|
if (started) {
|
||||||
if (kotlin.math.abs(offsetX) > swipeWidthPx / 2) {
|
val velocity = velocityTracker.calculateVelocity().x
|
||||||
offsetX = -swipeWidthPx
|
// Telegram-like: fling left (-velocity) OR dragged past 1/3
|
||||||
} else {
|
val shouldOpen = velocity < -300f ||
|
||||||
offsetX = 0f
|
kotlin.math.abs(offsetX) > swipeWidthPx / 3
|
||||||
|
if (shouldOpen) {
|
||||||
|
offsetX = -swipeWidthPx
|
||||||
|
} else {
|
||||||
|
offsetX = 0f
|
||||||
|
onSwipeClosed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ fun ImageEditorScreen(
|
|||||||
isDarkTheme: Boolean = true,
|
isDarkTheme: Boolean = true,
|
||||||
showCaptionInput: Boolean = false,
|
showCaptionInput: Boolean = false,
|
||||||
recipientName: String? = null,
|
recipientName: String? = null,
|
||||||
thumbnailPosition: ThumbnailPosition? = null // Позиция для Telegram-style анимации
|
thumbnailPosition: ThumbnailPosition? = null, // Позиция для Telegram-style анимации
|
||||||
|
skipEnterAnimation: Boolean = false // Из камеры — без fade, мгновенно
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -151,20 +152,22 @@ fun ImageEditorScreen(
|
|||||||
// 🎬 TELEGRAM-STYLE ENTER/EXIT ANIMATION
|
// 🎬 TELEGRAM-STYLE ENTER/EXIT ANIMATION
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
var isClosing by remember { mutableStateOf(false) }
|
var isClosing by remember { mutableStateOf(false) }
|
||||||
val animationProgress = remember { Animatable(0f) }
|
val animationProgress = remember { Animatable(if (skipEnterAnimation) 1f else 0f) }
|
||||||
|
|
||||||
// 🎬 Плавный easing для fade-out (как в Telegram)
|
// 🎬 Плавный easing для fade-out (как в Telegram)
|
||||||
val smoothEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
|
val smoothEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
|
||||||
|
|
||||||
// Запуск enter анимации
|
// Запуск enter анимации (пропускаем если из камеры — уже alpha=1)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
animationProgress.animateTo(
|
if (!skipEnterAnimation) {
|
||||||
targetValue = 1f,
|
animationProgress.animateTo(
|
||||||
animationSpec = tween(
|
targetValue = 1f,
|
||||||
durationMillis = 250,
|
animationSpec = tween(
|
||||||
easing = FastOutSlowInEasing
|
durationMillis = 250,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎨 Плавная анимация status bar синхронно с fade
|
// 🎨 Плавная анимация status bar синхронно с fade
|
||||||
|
|||||||
@@ -370,13 +370,19 @@ fun ImageViewerScreen(
|
|||||||
),
|
),
|
||||||
pageSpacing = 30.dp, // Telegram: dp(30) между фото
|
pageSpacing = 30.dp, // Telegram: dp(30) между фото
|
||||||
key = { images[it].attachmentId },
|
key = { images[it].attachmentId },
|
||||||
userScrollEnabled = !isAnimating // Отключаем скролл во время анимации
|
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
|
||||||
|
beyondBoundsPageCount = 0,
|
||||||
|
flingBehavior = PagerDefaults.flingBehavior(
|
||||||
|
state = pagerState,
|
||||||
|
snapPositionalThreshold = 0.5f
|
||||||
|
)
|
||||||
) { page ->
|
) { page ->
|
||||||
val image = images[page]
|
val image = images[page]
|
||||||
|
|
||||||
// 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется
|
// 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется
|
||||||
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||||
val isRightPage = pageOffset < 0
|
// Эффект только для соседней правой страницы, не для текущей на границе
|
||||||
|
val isRightPage = pageOffset < 0 && page != pagerState.currentPage
|
||||||
|
|
||||||
// Scale эффект только для правой страницы (1.0 → 0.7)
|
// Scale эффект только для правой страницы (1.0 → 0.7)
|
||||||
val scaleFactor = if (isRightPage) {
|
val scaleFactor = if (isRightPage) {
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import kotlinx.coroutines.launch
|
|||||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||||
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||||
|
|
||||||
// Swipe-back thresholds
|
// Swipe-back thresholds (Telegram-like)
|
||||||
private const val COMPLETION_THRESHOLD = 0.3f // 30% of screen width (was 50%)
|
private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
|
||||||
private const val FLING_VELOCITY_THRESHOLD = 400f // px/s (was 600)
|
private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings
|
||||||
private const val ANIMATION_DURATION_ENTER = 300
|
private const val ANIMATION_DURATION_ENTER = 300
|
||||||
private const val ANIMATION_DURATION_EXIT = 250
|
private const val ANIMATION_DURATION_EXIT = 200
|
||||||
private const val EDGE_ZONE_DP = 200
|
private const val EDGE_ZONE_DP = 200
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +148,7 @@ fun SwipeBackContainer(
|
|||||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||||
Modifier.pointerInput(Unit) {
|
Modifier.pointerInput(Unit) {
|
||||||
val velocityTracker = VelocityTracker()
|
val velocityTracker = VelocityTracker()
|
||||||
|
val touchSlop = viewConfiguration.touchSlop
|
||||||
|
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
@@ -159,35 +160,54 @@ fun SwipeBackContainer(
|
|||||||
|
|
||||||
velocityTracker.resetTracking()
|
velocityTracker.resetTracking()
|
||||||
var startedSwipe = false
|
var startedSwipe = false
|
||||||
|
var totalDragX = 0f
|
||||||
|
var totalDragY = 0f
|
||||||
|
var passedSlop = false
|
||||||
|
|
||||||
try {
|
// Use Initial pass to intercept BEFORE children
|
||||||
horizontalDrag(down.id) { change ->
|
while (true) {
|
||||||
val dragAmount = change.positionChange().x
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id }
|
||||||
|
?: break
|
||||||
|
|
||||||
// Only start swipe if moving right
|
if (change.changedToUpIgnoreConsumed()) {
|
||||||
if (!startedSwipe && dragAmount > 0) {
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val dragDelta = change.positionChange()
|
||||||
|
totalDragX += dragDelta.x
|
||||||
|
totalDragY += dragDelta.y
|
||||||
|
|
||||||
|
if (!passedSlop) {
|
||||||
|
val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
||||||
|
if (totalDistance < touchSlop) continue
|
||||||
|
|
||||||
|
// Slop exceeded — only claim rightward + mostly horizontal
|
||||||
|
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) {
|
||||||
|
passedSlop = true
|
||||||
startedSwipe = true
|
startedSwipe = true
|
||||||
isDragging = true
|
isDragging = true
|
||||||
dragOffset = offsetAnimatable.value
|
dragOffset = offsetAnimatable.value
|
||||||
// 🔥 Скрываем клавиатуру при начале свайпа (надёжный метод)
|
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
}
|
|
||||||
|
|
||||||
if (startedSwipe) {
|
|
||||||
// Direct state update - NO coroutines!
|
|
||||||
dragOffset = (dragOffset + dragAmount)
|
|
||||||
.coerceIn(0f, screenWidthPx)
|
|
||||||
velocityTracker.addPosition(
|
|
||||||
change.uptimeMillis,
|
|
||||||
change.position
|
|
||||||
)
|
|
||||||
change.consume()
|
change.consume()
|
||||||
|
} else {
|
||||||
|
// Vertical or leftward — let children handle
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// We own the gesture — update drag
|
||||||
|
dragOffset = (dragOffset + dragDelta.x)
|
||||||
|
.coerceIn(0f, screenWidthPx)
|
||||||
|
velocityTracker.addPosition(
|
||||||
|
change.uptimeMillis,
|
||||||
|
change.position
|
||||||
|
)
|
||||||
|
change.consume()
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
|
||||||
// Gesture was cancelled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle drag end
|
// Handle drag end
|
||||||
@@ -196,13 +216,12 @@ fun SwipeBackContainer(
|
|||||||
val velocity = velocityTracker.calculateVelocity().x
|
val velocity = velocityTracker.calculateVelocity().x
|
||||||
val currentProgress = dragOffset / screenWidthPx
|
val currentProgress = dragOffset / screenWidthPx
|
||||||
|
|
||||||
// Telegram logic: fling OR 50% threshold
|
|
||||||
val shouldComplete =
|
val shouldComplete =
|
||||||
velocity > FLING_VELOCITY_THRESHOLD ||
|
currentProgress > 0.5f || // Past 50% — always complete
|
||||||
|
velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right
|
||||||
(currentProgress > COMPLETION_THRESHOLD &&
|
(currentProgress > COMPLETION_THRESHOLD &&
|
||||||
velocity > -FLING_VELOCITY_THRESHOLD)
|
velocity > -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
|
||||||
|
|
||||||
// Sync animatable with current drag position and animate
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
offsetAnimatable.snapTo(dragOffset)
|
offsetAnimatable.snapTo(dragOffset)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user