Refactor SwipeBackContainer for improved performance and readability
- Added lazy composition to skip setup until the screen is first opened, reducing allocations. - Cleaned up code formatting for better readability. - Enhanced comments for clarity on functionality. - Streamlined gesture handling logic for swipe detection and animation.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,15 +8,15 @@ import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
EncryptedAccountEntity::class,
|
||||
MessageEntity::class,
|
||||
DialogEntity::class,
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class
|
||||
],
|
||||
version = 10,
|
||||
exportSchema = false
|
||||
entities =
|
||||
[
|
||||
EncryptedAccountEntity::class,
|
||||
MessageEntity::class,
|
||||
DialogEntity::class,
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class],
|
||||
version = 11,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun accountDao(): AccountDao
|
||||
@@ -26,93 +26,140 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun avatarDao(): AvatarDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: RosettaDatabase? = null
|
||||
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем новые столбцы для индикаторов прочтения
|
||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем поле username в encrypted_accounts
|
||||
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем таблицу для кэша аватаров
|
||||
database.execSQL("""
|
||||
@Volatile private var INSTANCE: RosettaDatabase? = null
|
||||
|
||||
private val MIGRATION_4_5 =
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем новые столбцы для индикаторов прочтения
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_5_6 =
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем поле username в encrypted_accounts
|
||||
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_6_7 =
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем таблицу для кэша аватаров
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS avatar_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
avatar TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Удаляем таблицу avatar_delivery (больше не нужна)
|
||||
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_7_8 =
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Удаляем таблицу avatar_delivery (больше не нужна)
|
||||
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop)
|
||||
* Blob слишком большой для SQLite CursorWindow (2MB лимит)
|
||||
* Просто обнуляем attachments - изображения перескачаются с CDN
|
||||
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для
|
||||
* SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с
|
||||
* CDN
|
||||
*/
|
||||
private val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
// Они будут перескачаны с CDN при открытии
|
||||
database.execSQL("""
|
||||
private val MIGRATION_8_9 =
|
||||
object : Migration(8, 9) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
// Они будут перескачаны с CDN при открытии
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET attachments = '[]'
|
||||
WHERE length(attachments) > 10000
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments
|
||||
* Для пользователей которые уже были на версии 9
|
||||
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже
|
||||
* были на версии 9
|
||||
*/
|
||||
private val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
database.execSQL("""
|
||||
private val MIGRATION_9_10 =
|
||||
object : Migration(9, 10) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET attachments = '[]'
|
||||
WHERE length(attachments) > 10000
|
||||
""")
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 МИГРАЦИЯ 10->11: Денормализация — кэш attachments последнего сообщения в dialogs
|
||||
* Устраняет N+1 проблему: ранее для каждого диалога делался отдельный запрос к messages
|
||||
*/
|
||||
private val MIGRATION_10_11 =
|
||||
object : Migration(10, 11) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем столбец для кэша attachments последнего сообщения
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_attachments TEXT NOT NULL DEFAULT '[]'"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): RosettaDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
RosettaDatabase::class.java,
|
||||
"rosetta_secure.db"
|
||||
)
|
||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
return INSTANCE
|
||||
?: synchronized(this) {
|
||||
val instance =
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
RosettaDatabase::class.java,
|
||||
"rosetta_secure.db"
|
||||
)
|
||||
.setJournalMode(
|
||||
JournalMode.WRITE_AHEAD_LOGGING
|
||||
) // WAL mode for performance
|
||||
.addMigrations(
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
MIGRATION_9_10,
|
||||
MIGRATION_10_11
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только
|
||||
// если миграция не
|
||||
// найдена
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.*
|
||||
@@ -16,16 +18,14 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||
|
||||
// Swipe-back thresholds (Telegram-like)
|
||||
private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
|
||||
private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings
|
||||
private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
|
||||
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_EXIT = 200
|
||||
private const val EDGE_ZONE_DP = 200
|
||||
@@ -33,8 +33,7 @@ private const val EDGE_ZONE_DP = 200
|
||||
/**
|
||||
* Telegram-style swipe back container (optimized)
|
||||
*
|
||||
* Wraps content and allows swiping from the left edge to go back.
|
||||
* Features:
|
||||
* Wraps content and allows swiping from the left edge to go back. Features:
|
||||
* - Edge-only swipe detection (left 30dp)
|
||||
* - Direct state update during drag (no coroutine overhead)
|
||||
* - VelocityTracker for fling detection
|
||||
@@ -45,12 +44,18 @@ private const val EDGE_ZONE_DP = 200
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeBackContainer(
|
||||
isVisible: Boolean,
|
||||
onBack: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
swipeEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
isVisible: Boolean,
|
||||
onBack: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
swipeEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
||||
// Saves ~15 remember/Animatable allocations per unused screen at startup (×12 screens).
|
||||
var wasEverVisible by remember { mutableStateOf(false) }
|
||||
if (isVisible) wasEverVisible = true
|
||||
if (!wasEverVisible) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||
@@ -74,7 +79,8 @@ fun SwipeBackContainer(
|
||||
// Coroutine scope for animations
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через InputMethodManager)
|
||||
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через
|
||||
// InputMethodManager)
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -94,14 +100,15 @@ fun SwipeBackContainer(
|
||||
// Animate in: fade-in
|
||||
shouldShow = true
|
||||
isAnimatingIn = true
|
||||
offsetAnimatable.snapTo(0f) // No slide for entry
|
||||
offsetAnimatable.snapTo(0f) // No slide for entry
|
||||
alphaAnimatable.snapTo(0f)
|
||||
alphaAnimatable.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_ENTER,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis = ANIMATION_DURATION_ENTER,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
isAnimatingIn = false
|
||||
} else if (!isVisible && shouldShow && !isAnimatingOut) {
|
||||
@@ -109,11 +116,8 @@ fun SwipeBackContainer(
|
||||
isAnimatingOut = true
|
||||
alphaAnimatable.snapTo(1f)
|
||||
alphaAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
|
||||
)
|
||||
shouldShow = false
|
||||
isAnimatingOut = false
|
||||
@@ -128,133 +132,176 @@ fun SwipeBackContainer(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Scrim (dimming layer behind the screen) - only when swiping
|
||||
if (currentOffset > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = scrimAlpha))
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
|
||||
}
|
||||
|
||||
// Content with swipe gesture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = currentOffset
|
||||
alpha = currentAlpha
|
||||
}
|
||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||
.then(
|
||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
|
||||
// Edge-only detection
|
||||
if (down.position.x > edgeZonePx) {
|
||||
return@awaitEachGesture
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = currentOffset
|
||||
alpha = currentAlpha
|
||||
}
|
||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||
.then(
|
||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
|
||||
velocityTracker.resetTracking()
|
||||
var startedSwipe = false
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
awaitEachGesture {
|
||||
val down =
|
||||
awaitFirstDown(
|
||||
requireUnconsumed = false
|
||||
)
|
||||
|
||||
// Use Initial pass to intercept BEFORE children
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
?: break
|
||||
// Edge-only detection
|
||||
if (down.position.x > edgeZonePx) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
break
|
||||
}
|
||||
velocityTracker.resetTracking()
|
||||
var startedSwipe = false
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
|
||||
val dragDelta = change.positionChange()
|
||||
totalDragX += dragDelta.x
|
||||
totalDragY += dragDelta.y
|
||||
// Use Initial pass to intercept BEFORE children
|
||||
while (true) {
|
||||
val event =
|
||||
awaitPointerEvent(
|
||||
PointerEventPass.Initial
|
||||
)
|
||||
val change =
|
||||
event.changes.firstOrNull {
|
||||
it.id == down.id
|
||||
}
|
||||
?: break
|
||||
|
||||
if (!passedSlop) {
|
||||
val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
||||
if (totalDistance < touchSlop) continue
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
break
|
||||
}
|
||||
|
||||
// Slop exceeded — only claim rightward + mostly horizontal
|
||||
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) {
|
||||
passedSlop = true
|
||||
startedSwipe = true
|
||||
isDragging = true
|
||||
dragOffset = offsetAnimatable.value
|
||||
val dragDelta = change.positionChange()
|
||||
totalDragX += dragDelta.x
|
||||
totalDragY += dragDelta.y
|
||||
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
if (!passedSlop) {
|
||||
val totalDistance =
|
||||
kotlin.math.sqrt(
|
||||
totalDragX *
|
||||
totalDragX +
|
||||
totalDragY *
|
||||
totalDragY
|
||||
)
|
||||
if (totalDistance < touchSlop) continue
|
||||
|
||||
change.consume()
|
||||
// Slop exceeded — only claim rightward
|
||||
// + mostly horizontal
|
||||
if (totalDragX > 0 &&
|
||||
kotlin.math.abs(
|
||||
totalDragX
|
||||
) >
|
||||
kotlin.math.abs(
|
||||
totalDragY
|
||||
) * 1.5f
|
||||
) {
|
||||
passedSlop = true
|
||||
startedSwipe = true
|
||||
isDragging = true
|
||||
dragOffset = offsetAnimatable.value
|
||||
|
||||
val imm =
|
||||
context.getSystemService(
|
||||
Context.INPUT_METHOD_SERVICE
|
||||
) as
|
||||
InputMethodManager
|
||||
imm.hideSoftInputFromWindow(
|
||||
view.windowToken,
|
||||
0
|
||||
)
|
||||
focusManager.clearFocus()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
if (startedSwipe) {
|
||||
isDragging = false
|
||||
val velocity =
|
||||
velocityTracker.calculateVelocity()
|
||||
.x
|
||||
val currentProgress =
|
||||
dragOffset / screenWidthPx
|
||||
|
||||
val shouldComplete =
|
||||
currentProgress >
|
||||
0.5f || // Past 50% — always
|
||||
// complete
|
||||
velocity >
|
||||
FLING_VELOCITY_THRESHOLD || // Fast fling right
|
||||
(currentProgress >
|
||||
COMPLETION_THRESHOLD &&
|
||||
velocity >
|
||||
-FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
|
||||
|
||||
scope.launch {
|
||||
offsetAnimatable.snapTo(dragOffset)
|
||||
|
||||
if (shouldComplete) {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = screenWidthPx,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
ANIMATION_DURATION_EXIT,
|
||||
easing =
|
||||
TelegramEasing
|
||||
)
|
||||
)
|
||||
onBack()
|
||||
} else {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
ANIMATION_DURATION_EXIT,
|
||||
easing =
|
||||
TelegramEasing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Vertical or leftward — let children handle
|
||||
break
|
||||
Modifier
|
||||
}
|
||||
} else {
|
||||
// We own the gesture — update drag
|
||||
dragOffset = (dragOffset + dragDelta.x)
|
||||
.coerceIn(0f, screenWidthPx)
|
||||
velocityTracker.addPosition(
|
||||
change.uptimeMillis,
|
||||
change.position
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
if (startedSwipe) {
|
||||
isDragging = false
|
||||
val velocity = velocityTracker.calculateVelocity().x
|
||||
val currentProgress = dragOffset / screenWidthPx
|
||||
|
||||
val shouldComplete =
|
||||
currentProgress > 0.5f || // Past 50% — always complete
|
||||
velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right
|
||||
(currentProgress > COMPLETION_THRESHOLD &&
|
||||
velocity > -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
|
||||
|
||||
scope.launch {
|
||||
offsetAnimatable.snapTo(dragOffset)
|
||||
|
||||
if (shouldComplete) {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = screenWidthPx,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_EXIT,
|
||||
easing = TelegramEasing
|
||||
)
|
||||
)
|
||||
onBack()
|
||||
} else {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_EXIT,
|
||||
easing = TelegramEasing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
)
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user