From 5310596cf6c9a09988222fca8dfdb07ab8f7c856 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sat, 6 Dec 2025 09:00:01 +0300 Subject: [PATCH] Refactor: Improve swipe-to-dismiss and image sharing This commit refactors the swipe-to-dismiss gesture and the image sharing logic in the photo viewer. The swipe-to-dismiss animation is now smoother and more reliable, using `Animatable` instead of `animateFloatAsState`. The background dimming effect has also been improved to be more responsive to the drag gesture. Additionally, the responsibility for creating the share `Intent` has been moved from the composable screen into the `PhotoViewViewModel`, improving the separation of concerns. Key changes: - Replaced `animateFloatAsState` with `Animatable` for smoother swipe-to-dismiss animations. - Improved the alpha calculation for the background during the drag gesture. - Moved the creation of the share `Intent` into the `PhotoViewViewModel`. - Simplified the drag-handling logic by removing local state management from the composable. --- .../fast/photoviewer/PhotoViewViewModel.kt | 16 ++- .../presentation/PhotoViewScreen.kt | 112 ++++++++---------- 2 files changed, 61 insertions(+), 67 deletions(-) diff --git a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt index 9f0fb13d..dd97ab40 100644 --- a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt +++ b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt @@ -35,7 +35,7 @@ import dev.meloda.fast.ui.R interface PhotoViewViewModel { val screenState: StateFlow - val shareRequest: StateFlow + val shareRequest: StateFlow fun onPageChanged(newPage: Int) @@ -61,7 +61,7 @@ class PhotoViewViewModelImpl( ) override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY) - override val shareRequest = MutableStateFlow(null) + override val shareRequest = MutableStateFlow(null) init { screenState.setValue { old -> @@ -94,7 +94,17 @@ class PhotoViewViewModelImpl( imageFile ) - shareRequest.setValue { uri } + shareRequest.setValue { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/png" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_STREAM, uri) + } + + val chooserIntent = Intent.createChooser(intent, null) + chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + chooserIntent + } } } diff --git a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt index c4840d54..007f76be 100644 --- a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt +++ b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt @@ -3,9 +3,7 @@ package dev.meloda.fast.photoviewer.presentation import android.content.Intent import android.widget.Toast import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -32,7 +30,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -64,6 +61,7 @@ import dev.meloda.fast.ui.components.FullScreenDialog import dev.meloda.fast.ui.components.Loader import dev.meloda.fast.ui.util.getImage import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import java.net.URLEncoder @@ -108,26 +106,17 @@ private fun PhotoViewRoute( viewModel: PhotoViewViewModel = koinViewModel() ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() - val shareRequest by viewModel.shareRequest.collectAsStateWithLifecycle() + val shareRequestIntent by viewModel.shareRequest.collectAsStateWithLifecycle() val context = LocalContext.current val scope = rememberCoroutineScope() - LaunchedEffect(shareRequest) { - if (shareRequest != null) { + LaunchedEffect(shareRequestIntent) { + if (shareRequestIntent!= null) { viewModel.onImageShared() - val intent = Intent(Intent.ACTION_SEND).apply { - type = "image/png" - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - putExtra(Intent.EXTRA_STREAM, shareRequest) - } - - val chooserIntent = Intent.createChooser(intent, null) - chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - try { - context.startActivity(chooserIntent) + context.startActivity(shareRequestIntent) } catch (e: Exception) { e.printStackTrace() @@ -168,6 +157,8 @@ private fun PhotoViewScreen( initialPage = screenState.selectedPage ) + val windowInfo = LocalWindowInfo.current + LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage } .collect(onPageChanged) @@ -175,15 +166,14 @@ private fun PhotoViewScreen( var offsetY by remember { mutableFloatStateOf(0f) } - val calculatedAlpha by remember(offsetY) { - derivedStateOf { - val absoluteOffset = abs(offsetY) - - 1 - if (absoluteOffset >= 1700) { - 0.85f - } else absoluteOffset / 2000 + val alpha by snapshotFlow { + if (offsetY == 0f) { + 1f + } else { + (windowInfo.containerSize.width.toFloat() / (abs(offsetY) * 4)) + .coerceIn(0f, 1f) } - } + }.collectAsStateWithLifecycle(1f) Scaffold( topBar = { @@ -195,7 +185,7 @@ private fun PhotoViewScreen( onCopyLinkClicked = onCopyLinkClicked, ) }, - containerColor = Color.Black.copy(alpha = calculatedAlpha) + containerColor = Color.Black.copy(alpha = alpha) ) { padding -> Box(modifier = Modifier.fillMaxSize()) { Pager( @@ -329,6 +319,36 @@ private fun Pager( ) { val windowInfo = LocalWindowInfo.current + val scope = rememberCoroutineScope() + + val offsetY = remember { Animatable(0f) } + LaunchedEffect(offsetY.value) { + onVerticalDrag(offsetY.value) + } + + val imageModifier = Modifier + .fillMaxSize() + .graphicsLayer { + this.translationY = offsetY.value + } + .draggable( + state = rememberDraggableState { delta -> + scope.launch { + offsetY.snapTo(offsetY.value + delta) + } + }, + orientation = Orientation.Vertical, + onDragStopped = { + if (abs(offsetY.value) / windowInfo.containerSize.height >= 0.25) { + onBack() + } else { + scope.launch { + offsetY.animateTo(0f) + } + } + } + ) + HorizontalPager( state = pagerState, modifier = modifier.fillMaxSize() @@ -344,49 +364,13 @@ private fun Pager( Image( painter = model, contentDescription = "Image", - modifier = Modifier.fillMaxSize() + modifier = imageModifier ) } else { - var offsetY by remember { mutableFloatStateOf(0f) } - - var useAnimatedOffset by remember { - mutableStateOf(false) - } - - val animatedOffset by animateFloatAsState( - targetValue = offsetY, - label = "animatedOffset", - animationSpec = tween( - durationMillis = if (useAnimatedOffset) 150 else 0, - easing = LinearEasing - ) - ) - AsyncImage( model = model, contentDescription = "Image", - modifier = Modifier - .graphicsLayer { - this.translationY = animatedOffset - } - .draggable( - state = rememberDraggableState { delta -> - useAnimatedOffset = false - offsetY += delta - onVerticalDrag(offsetY) - }, - orientation = Orientation.Vertical, - onDragStopped = { - if (abs(offsetY) / windowInfo.containerSize.height >= 0.25) { - onBack() - } else { - useAnimatedOffset = true - offsetY = 0f - onVerticalDrag(0f) - } - } - ) - .fillMaxSize(), + modifier = imageModifier, placeholder = ColorPainter(Color.DarkGray), error = ColorPainter(Color.Red) )