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.
This commit is contained in:
2025-12-06 09:00:01 +03:00
parent 65ff74622a
commit 5310596cf6
2 changed files with 61 additions and 67 deletions
@@ -35,7 +35,7 @@ import dev.meloda.fast.ui.R
interface PhotoViewViewModel { interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState> val screenState: StateFlow<PhotoViewScreenState>
val shareRequest: StateFlow<Uri?> val shareRequest: StateFlow<Intent?>
fun onPageChanged(newPage: Int) fun onPageChanged(newPage: Int)
@@ -61,7 +61,7 @@ class PhotoViewViewModelImpl(
) )
override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY) override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY)
override val shareRequest = MutableStateFlow<Uri?>(null) override val shareRequest = MutableStateFlow<Intent?>(null)
init { init {
screenState.setValue { old -> screenState.setValue { old ->
@@ -94,7 +94,17 @@ class PhotoViewViewModelImpl(
imageFile 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
}
} }
} }
@@ -3,9 +3,7 @@ package dev.meloda.fast.photoviewer.presentation
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -32,7 +30,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf 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.components.Loader
import dev.meloda.fast.ui.util.getImage import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.net.URLEncoder import java.net.URLEncoder
@@ -108,26 +106,17 @@ private fun PhotoViewRoute(
viewModel: PhotoViewViewModel = koinViewModel<PhotoViewViewModelImpl>() viewModel: PhotoViewViewModel = koinViewModel<PhotoViewViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val shareRequest by viewModel.shareRequest.collectAsStateWithLifecycle() val shareRequestIntent by viewModel.shareRequest.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(shareRequest) { LaunchedEffect(shareRequestIntent) {
if (shareRequest != null) { if (shareRequestIntent!= null) {
viewModel.onImageShared() 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 { try {
context.startActivity(chooserIntent) context.startActivity(shareRequestIntent)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -168,6 +157,8 @@ private fun PhotoViewScreen(
initialPage = screenState.selectedPage initialPage = screenState.selectedPage
) )
val windowInfo = LocalWindowInfo.current
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage } snapshotFlow { pagerState.currentPage }
.collect(onPageChanged) .collect(onPageChanged)
@@ -175,15 +166,14 @@ private fun PhotoViewScreen(
var offsetY by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) }
val calculatedAlpha by remember(offsetY) { val alpha by snapshotFlow {
derivedStateOf { if (offsetY == 0f) {
val absoluteOffset = abs(offsetY) 1f
} else {
1 - if (absoluteOffset >= 1700) { (windowInfo.containerSize.width.toFloat() / (abs(offsetY) * 4))
0.85f .coerceIn(0f, 1f)
} else absoluteOffset / 2000
}
} }
}.collectAsStateWithLifecycle(1f)
Scaffold( Scaffold(
topBar = { topBar = {
@@ -195,7 +185,7 @@ private fun PhotoViewScreen(
onCopyLinkClicked = onCopyLinkClicked, onCopyLinkClicked = onCopyLinkClicked,
) )
}, },
containerColor = Color.Black.copy(alpha = calculatedAlpha) containerColor = Color.Black.copy(alpha = alpha)
) { padding -> ) { padding ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Pager( Pager(
@@ -329,6 +319,36 @@ private fun Pager(
) { ) {
val windowInfo = LocalWindowInfo.current 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( HorizontalPager(
state = pagerState, state = pagerState,
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize()
@@ -344,49 +364,13 @@ private fun Pager(
Image( Image(
painter = model, painter = model,
contentDescription = "Image", contentDescription = "Image",
modifier = Modifier.fillMaxSize() modifier = imageModifier
) )
} else { } 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( AsyncImage(
model = model, model = model,
contentDescription = "Image", contentDescription = "Image",
modifier = Modifier modifier = imageModifier,
.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(),
placeholder = ColorPainter(Color.DarkGray), placeholder = ColorPainter(Color.DarkGray),
error = ColorPainter(Color.Red) error = ColorPainter(Color.Red)
) )