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
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities =
|
||||||
EncryptedAccountEntity::class,
|
[
|
||||||
MessageEntity::class,
|
EncryptedAccountEntity::class,
|
||||||
DialogEntity::class,
|
MessageEntity::class,
|
||||||
BlacklistEntity::class,
|
DialogEntity::class,
|
||||||
AvatarCacheEntity::class
|
BlacklistEntity::class,
|
||||||
],
|
AvatarCacheEntity::class],
|
||||||
version = 10,
|
version = 11,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class RosettaDatabase : RoomDatabase() {
|
abstract class RosettaDatabase : RoomDatabase() {
|
||||||
abstract fun accountDao(): AccountDao
|
abstract fun accountDao(): AccountDao
|
||||||
@@ -26,93 +26,140 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
abstract fun avatarDao(): AvatarDao
|
abstract fun avatarDao(): AvatarDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile
|
@Volatile private var INSTANCE: RosettaDatabase? = null
|
||||||
private var INSTANCE: RosettaDatabase? = null
|
|
||||||
|
private val MIGRATION_4_5 =
|
||||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
object : Migration(4, 5) {
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
// Добавляем новые столбцы для индикаторов прочтения
|
// Добавляем новые столбцы для индикаторов прочтения
|
||||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0")
|
database.execSQL(
|
||||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0")
|
"ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0"
|
||||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0")
|
)
|
||||||
}
|
database.execSQL(
|
||||||
}
|
"ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
private val MIGRATION_5_6 = object : Migration(5, 6) {
|
database.execSQL(
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
"ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0"
|
||||||
// Добавляем поле username в encrypted_accounts
|
)
|
||||||
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private val MIGRATION_5_6 =
|
||||||
private val MIGRATION_6_7 = object : Migration(6, 7) {
|
object : Migration(5, 6) {
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
// Создаем таблицу для кэша аватаров
|
// Добавляем поле username в encrypted_accounts
|
||||||
database.execSQL("""
|
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 (
|
CREATE TABLE IF NOT EXISTS avatar_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
avatar TEXT NOT NULL,
|
avatar TEXT NOT NULL,
|
||||||
timestamp INTEGER 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)")
|
)
|
||||||
}
|
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")
|
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)
|
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для
|
||||||
* Blob слишком большой для SQLite CursorWindow (2MB лимит)
|
* SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с
|
||||||
* Просто обнуляем attachments - изображения перескачаются с CDN
|
* CDN
|
||||||
*/
|
*/
|
||||||
private val MIGRATION_8_9 = object : Migration(8, 9) {
|
private val MIGRATION_8_9 =
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
object : Migration(8, 9) {
|
||||||
// Очищаем все attachments с большими blob'ами
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
// Они будут перескачаны с CDN при открытии
|
// Очищаем все attachments с большими blob'ами
|
||||||
database.execSQL("""
|
// Они будут перескачаны с CDN при открытии
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET attachments = '[]'
|
SET attachments = '[]'
|
||||||
WHERE length(attachments) > 10000
|
WHERE length(attachments) > 10000
|
||||||
""")
|
"""
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments
|
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже
|
||||||
* Для пользователей которые уже были на версии 9
|
* были на версии 9
|
||||||
*/
|
*/
|
||||||
private val MIGRATION_9_10 = object : Migration(9, 10) {
|
private val MIGRATION_9_10 =
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
object : Migration(9, 10) {
|
||||||
// Очищаем все attachments с большими blob'ами
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
database.execSQL("""
|
// Очищаем все attachments с большими blob'ами
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET attachments = '[]'
|
SET attachments = '[]'
|
||||||
WHERE length(attachments) > 10000
|
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 {
|
fun getDatabase(context: Context): RosettaDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE
|
||||||
val instance = Room.databaseBuilder(
|
?: synchronized(this) {
|
||||||
context.applicationContext,
|
val instance =
|
||||||
RosettaDatabase::class.java,
|
Room.databaseBuilder(
|
||||||
"rosetta_secure.db"
|
context.applicationContext,
|
||||||
)
|
RosettaDatabase::class.java,
|
||||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
"rosetta_secure.db"
|
||||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
|
)
|
||||||
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
|
.setJournalMode(
|
||||||
.build()
|
JournalMode.WRITE_AHEAD_LOGGING
|
||||||
INSTANCE = instance
|
) // WAL mode for performance
|
||||||
instance
|
.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
|
package com.rosetta.messenger.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.*
|
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.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import android.content.Context
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import kotlinx.coroutines.launch
|
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 (Telegram-like)
|
// Swipe-back thresholds (Telegram-like)
|
||||||
private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
|
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 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 = 200
|
private const val ANIMATION_DURATION_EXIT = 200
|
||||||
private const val EDGE_ZONE_DP = 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)
|
* Telegram-style swipe back container (optimized)
|
||||||
*
|
*
|
||||||
* Wraps content and allows swiping from the left edge to go back.
|
* Wraps content and allows swiping from the left edge to go back. Features:
|
||||||
* Features:
|
|
||||||
* - Edge-only swipe detection (left 30dp)
|
* - Edge-only swipe detection (left 30dp)
|
||||||
* - Direct state update during drag (no coroutine overhead)
|
* - Direct state update during drag (no coroutine overhead)
|
||||||
* - VelocityTracker for fling detection
|
* - VelocityTracker for fling detection
|
||||||
@@ -45,12 +44,18 @@ private const val EDGE_ZONE_DP = 200
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SwipeBackContainer(
|
fun SwipeBackContainer(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
swipeEnabled: Boolean = true,
|
swipeEnabled: Boolean = true,
|
||||||
content: @Composable () -> Unit
|
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 density = LocalDensity.current
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||||
@@ -74,7 +79,8 @@ fun SwipeBackContainer(
|
|||||||
// Coroutine scope for animations
|
// Coroutine scope for animations
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через InputMethodManager)
|
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через
|
||||||
|
// InputMethodManager)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
@@ -94,14 +100,15 @@ fun SwipeBackContainer(
|
|||||||
// Animate in: fade-in
|
// Animate in: fade-in
|
||||||
shouldShow = true
|
shouldShow = true
|
||||||
isAnimatingIn = true
|
isAnimatingIn = true
|
||||||
offsetAnimatable.snapTo(0f) // No slide for entry
|
offsetAnimatable.snapTo(0f) // No slide for entry
|
||||||
alphaAnimatable.snapTo(0f)
|
alphaAnimatable.snapTo(0f)
|
||||||
alphaAnimatable.animateTo(
|
alphaAnimatable.animateTo(
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = tween(
|
animationSpec =
|
||||||
durationMillis = ANIMATION_DURATION_ENTER,
|
tween(
|
||||||
easing = FastOutSlowInEasing
|
durationMillis = ANIMATION_DURATION_ENTER,
|
||||||
)
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
)
|
)
|
||||||
isAnimatingIn = false
|
isAnimatingIn = false
|
||||||
} else if (!isVisible && shouldShow && !isAnimatingOut) {
|
} else if (!isVisible && shouldShow && !isAnimatingOut) {
|
||||||
@@ -109,11 +116,8 @@ fun SwipeBackContainer(
|
|||||||
isAnimatingOut = true
|
isAnimatingOut = true
|
||||||
alphaAnimatable.snapTo(1f)
|
alphaAnimatable.snapTo(1f)
|
||||||
alphaAnimatable.animateTo(
|
alphaAnimatable.animateTo(
|
||||||
targetValue = 0f,
|
targetValue = 0f,
|
||||||
animationSpec = tween(
|
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
|
||||||
durationMillis = 200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
shouldShow = false
|
shouldShow = false
|
||||||
isAnimatingOut = false
|
isAnimatingOut = false
|
||||||
@@ -128,133 +132,176 @@ fun SwipeBackContainer(
|
|||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Scrim (dimming layer behind the screen) - only when swiping
|
// Scrim (dimming layer behind the screen) - only when swiping
|
||||||
if (currentOffset > 0f) {
|
if (currentOffset > 0f) {
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = scrimAlpha))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content with swipe gesture
|
// Content with swipe gesture
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
translationX = currentOffset
|
translationX = currentOffset
|
||||||
alpha = currentAlpha
|
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
|
|
||||||
}
|
}
|
||||||
|
.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()
|
awaitEachGesture {
|
||||||
var startedSwipe = false
|
val down =
|
||||||
var totalDragX = 0f
|
awaitFirstDown(
|
||||||
var totalDragY = 0f
|
requireUnconsumed = false
|
||||||
var passedSlop = false
|
)
|
||||||
|
|
||||||
// Use Initial pass to intercept BEFORE children
|
// Edge-only detection
|
||||||
while (true) {
|
if (down.position.x > edgeZonePx) {
|
||||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
return@awaitEachGesture
|
||||||
val change = event.changes.firstOrNull { it.id == down.id }
|
}
|
||||||
?: break
|
|
||||||
|
|
||||||
if (change.changedToUpIgnoreConsumed()) {
|
velocityTracker.resetTracking()
|
||||||
break
|
var startedSwipe = false
|
||||||
}
|
var totalDragX = 0f
|
||||||
|
var totalDragY = 0f
|
||||||
|
var passedSlop = false
|
||||||
|
|
||||||
val dragDelta = change.positionChange()
|
// Use Initial pass to intercept BEFORE children
|
||||||
totalDragX += dragDelta.x
|
while (true) {
|
||||||
totalDragY += dragDelta.y
|
val event =
|
||||||
|
awaitPointerEvent(
|
||||||
|
PointerEventPass.Initial
|
||||||
|
)
|
||||||
|
val change =
|
||||||
|
event.changes.firstOrNull {
|
||||||
|
it.id == down.id
|
||||||
|
}
|
||||||
|
?: break
|
||||||
|
|
||||||
if (!passedSlop) {
|
if (change.changedToUpIgnoreConsumed()) {
|
||||||
val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
break
|
||||||
if (totalDistance < touchSlop) continue
|
}
|
||||||
|
|
||||||
// Slop exceeded — only claim rightward + mostly horizontal
|
val dragDelta = change.positionChange()
|
||||||
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) {
|
totalDragX += dragDelta.x
|
||||||
passedSlop = true
|
totalDragY += dragDelta.y
|
||||||
startedSwipe = true
|
|
||||||
isDragging = true
|
|
||||||
dragOffset = offsetAnimatable.value
|
|
||||||
|
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
if (!passedSlop) {
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
val totalDistance =
|
||||||
focusManager.clearFocus()
|
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 {
|
} else {
|
||||||
// Vertical or leftward — let children handle
|
Modifier
|
||||||
break
|
|
||||||
}
|
}
|
||||||
} else {
|
)
|
||||||
// We own the gesture — update drag
|
) { content() }
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user