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 {
val screenState: StateFlow<PhotoViewScreenState>
val shareRequest: StateFlow<Uri?>
val shareRequest: StateFlow<Intent?>
fun onPageChanged(newPage: Int)
@@ -61,7 +61,7 @@ class PhotoViewViewModelImpl(
)
override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY)
override val shareRequest = MutableStateFlow<Uri?>(null)
override val shareRequest = MutableStateFlow<Intent?>(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
}
}
}
@@ -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<PhotoViewViewModelImpl>()
) {
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)
)