From c14c289ba77c53554621713388e04fc74ed1bb09 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 12 Oct 2022 16:59:59 +0300 Subject: [PATCH] android: Image gallery (#1198) * android: Image gallery * Do not scroll to active item * Infinity pager * Fixed small issue with the first page * Limited image height * Revert "Limited image height" This reverts commit d5733da6a3b9f16faa73bce1debbf8d2a2e3367b. * Scroll at top position after click on quote * Better scrolling --- apps/android/app/build.gradle | 1 + .../chat/simplex/app/views/chat/ChatView.kt | 91 +++++++++- .../app/views/chat/item/CIImageView.kt | 8 +- .../app/views/chat/item/ChatItemView.kt | 3 +- .../app/views/chat/item/FramedItemView.kt | 3 +- .../views/chat/item/ImageFullScreenView.kt | 167 ++++++++++++------ .../app/views/helpers/GestureDetector.kt | 81 +++++++-- .../simplex/app/views/helpers/ModalView.kt | 17 +- 8 files changed, 287 insertions(+), 84 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 78fd7e7ce6..d2c02be251 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -112,6 +112,7 @@ dependencies { //Camera Permission implementation "com.google.accompanist:accompanist-permissions:0.23.0" + implementation "com.google.accompanist:accompanist-pager:0.25.1" // Link Previews implementation 'org.jsoup:jsoup:1.13.1' diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index b9089ea7d3..2849218d8e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -1,6 +1,8 @@ package chat.simplex.app.views.chat import android.content.res.Configuration +import android.graphics.Bitmap +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* @@ -28,13 +30,14 @@ import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* +import androidx.core.content.FileProvider +import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.chat.group.* -import chat.simplex.app.views.chat.item.ChatItemView -import chat.simplex.app.views.chat.item.ItemAction +import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.chatlist.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.AppBarHeight @@ -43,6 +46,8 @@ import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock +import java.io.File +import kotlin.math.sign import kotlin.math.roundToInt @Composable @@ -475,7 +480,7 @@ fun BoxWithConstraintsScope.ChatItemsList( val scrollToItem: (Long) -> Unit = { itemId: Long -> val index = reversedChatItems.indexOfFirst { it.id == itemId } if (index != -1) { - scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded / 2) } + scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) } } } LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { @@ -505,7 +510,16 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } - + val provider = { + providerForGallery(i, chatItems, cItem.id) { indexInReversed -> + scope.launch { + listState.scrollToItem( + kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1), + -maxHeightRounded + ) + } + } + } if (chat.chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null @@ -531,11 +545,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) } } } else { // direct message @@ -546,7 +560,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem) } } @@ -770,6 +784,69 @@ private fun bottomEndFloatingButton( } } +private fun providerForGallery( + listStateIndex: Int, + chatItems: List, + cItemId: Long, + scrollTo: (Int) -> Unit +): ImageGalleryProvider { + fun canShowImage(item: ChatItem): Boolean = + item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null + + fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { + var processedInternalIndex = -skipInternalIndex.sign + val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId } + for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) { + val item = chatItems[chatItemsIndex] + if (canShowImage(item)) { + processedInternalIndex += skipInternalIndex.sign + } + if (processedInternalIndex == skipInternalIndex) { + return chatItemsIndex to item + } + } + return null + } + + var initialIndex = Int.MAX_VALUE / 2 + var initialChatId = cItemId + return object: ImageGalleryProvider { + override val initialIndex: Int = initialIndex + override val totalImagesSize = mutableStateOf(Int.MAX_VALUE) + override fun getImage(index: Int): Pair? { + val internalIndex = initialIndex - index + val file = item(internalIndex, initialChatId)?.second?.file + val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file) + val filePath = getLoadedFilePath(SimplexApp.context, file) + return if (imageBitmap != null && filePath != null) { + val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + imageBitmap to uri + } else null + } + + override fun currentPageChanged(index: Int) { + val internalIndex = initialIndex - index + val item = item(internalIndex, initialChatId) ?: return + initialIndex = index + initialChatId = item.second.id + } + + override fun scrollToStart() { + initialIndex = 0 + initialChatId = chatItems.first { canShowImage(it) }.id + } + + override fun onDismiss(index: Int) { + val internalIndex = initialIndex - index + val indexInChatItems = item(internalIndex, initialChatId)?.first ?: return + val indexInReversed = chatItems.lastIndex - indexInChatItems + // Do not scroll to active item, just to different items + if (indexInReversed == listStateIndex) return + scrollTo(indexInReversed) + } + } +} + private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { override val longPressTimeoutMillis get() = diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index 5a63805049..f30719be87 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -7,7 +7,6 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.MoreHoriz import androidx.compose.runtime.Composable @@ -27,6 +26,8 @@ import chat.simplex.app.BuildConfig import chat.simplex.app.R import chat.simplex.app.model.CIFile import chat.simplex.app.model.CIFileStatus +import chat.simplex.app.views.chat.item.ImageFullScreenView +import chat.simplex.app.views.chat.item.ImageGalleryProvider import chat.simplex.app.views.helpers.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter @@ -39,6 +40,7 @@ import java.io.File fun CIImageView( image: String, file: CIFile?, + imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, receiveFile: (Long) -> Unit ) { @@ -154,7 +156,9 @@ fun CIImageView( ) imageView(imagePainter, onClick = { if (getLoadedFilePath(context, file) != null) { - ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, uri, close) } + ModalManager.shared.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } } }) } else { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 86518473ed..e4db88208a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -34,6 +34,7 @@ fun ChatItemView( composeState: MutableState, cxt: Context, uriHandler: UriHandler? = null, + imageProvider: (() -> ImageGalleryProvider)? = null, showMember: Boolean = false, chatModelIncognito: Boolean, useLinkPreviews: Boolean, @@ -64,7 +65,7 @@ fun ChatItemView( EmojiItemView(cItem) } else { val onLinkLongClick = { _: String -> showMenu.value = true } - FramedItemView(cInfo, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem) + FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem) } DropdownMenu( expanded = showMenu.value, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 737cf4550a..8c64a182ee 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -37,6 +37,7 @@ fun FramedItemView( chatInfo: ChatInfo, ci: ChatItem, uriHandler: UriHandler? = null, + imageProvider: (() -> ImageGalleryProvider)? = null, showMember: Boolean = false, showMenu: MutableState, receiveFile: (Long) -> Unit, @@ -128,7 +129,7 @@ fun FramedItemView( Column(Modifier.fillMaxWidth()) { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@Box, showMenu, receiveFile) if (mc.text == "") { metaColor = Color.White } else { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt index 13608278b9..8c9f812e7a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -1,80 +1,141 @@ +package chat.simplex.app.views.chat.item + import android.graphics.Bitmap import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import chat.simplex.app.R +import chat.simplex.app.views.helpers.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest import coil.size.Size +import com.google.accompanist.pager.* +import kotlinx.coroutines.launch +interface ImageGalleryProvider { + val initialIndex: Int + val totalImagesSize: MutableState + fun getImage(index: Int): Pair? + fun currentPageChanged(index: Int) + fun scrollToStart() + fun onDismiss(index: Int) +} + +@OptIn(ExperimentalPagerApi::class) @Composable -fun ImageFullScreenView(imageBitmap: Bitmap, uri: Uri, close: () -> Unit) { - BackHandler(onBack = close) - Column( - Modifier - .fillMaxSize() - .background(Color.Black) - .clickable(onClick = close) - ) { - var scale by remember { mutableStateOf(1f) } - var translationX by remember { mutableStateOf(0f) } - var translationY by remember { mutableStateOf(0f) } - // I'm making a new instance of imageLoader here because if I use one instance in multiple places - // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want - val imageLoader = ImageLoader.Builder(LocalContext.current) - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) +fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { + val provider = remember { imageProvider() } + val pagerState = rememberPagerState(provider.initialIndex) + val goBack = { provider.onDismiss(pagerState.currentPage); close() } + BackHandler(onBack = goBack) + // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, + // which makes this blank page visible for a moment. Prevent it by doing the check ourselves + LaunchedEffect(Unit) { + if (provider.getImage(provider.initialIndex - 1) == null) { + provider.scrollToStart() + pagerState.scrollToPage(0) + } + } + val scope = rememberCoroutineScope() + HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index -> + Column( + Modifier + .fillMaxSize() + .background(Color.Black) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack) + ) { + var settledCurrentPage by remember { mutableStateOf(pagerState.currentPage) } + LaunchedEffect(pagerState) { + snapshotFlow { + if (!pagerState.isScrollInProgress) pagerState.currentPage else settledCurrentPage + }.collect { + settledCurrentPage = it } } - .build() - Image( - rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), - placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil - imageLoader = imageLoader - ), - contentDescription = stringResource(R.string.image_descr), - contentScale = ContentScale.Fit, - modifier = Modifier - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = translationX, - translationY = translationY, - ) - .pointerInput(Unit) { - detectTransformGestures( - onGesture = { _, pan, gestureZoom, _ -> - scale = (scale * gestureZoom).coerceIn(1f, 20f) - if (scale > 1) { - translationX += pan.x * scale - translationY += pan.y * scale - } else { - translationX = 0f - translationY = 0f - } + LaunchedEffect(settledCurrentPage) { + // Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time + if (settledCurrentPage != provider.initialIndex) + provider.currentPageChanged(index) + } + val image = provider.getImage(index) + if (image == null) { + // No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically + scope.launch { + when (settledCurrentPage) { + index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1 + index + 1 -> { + provider.scrollToStart() + pagerState.scrollToPage(0) } - ) + } } - .fillMaxSize(), - ) + } else { + val (imageBitmap: Bitmap, uri: Uri) = image + var scale by remember { mutableStateOf(1f) } + var translationX by remember { mutableStateOf(0f) } + var translationY by remember { mutableStateOf(0f) } + LaunchedEffect(settledCurrentPage) { + scale = 1f + translationX = 0f + translationY = 0f + } + // I'm making a new instance of imageLoader here because if I use one instance in multiple places + // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want + val imageLoader = ImageLoader.Builder(LocalContext.current) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } + .build() + Image( + rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil + imageLoader = imageLoader + ), + contentDescription = stringResource(R.string.image_descr), + contentScale = ContentScale.Fit, + modifier = Modifier + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = translationX, + translationY = translationY, + ) + .pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, gestureZoom, _ -> + scale = (scale * gestureZoom).coerceIn(1f, 20f) + if (scale > 1) { + translationX += pan.x * scale + translationY += pan.y * scale + } else { + translationX = 0f + translationY = 0f + } + } + ) + } + .fillMaxSize(), + ) + } + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt index e19f00f971..bb81d1974e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt @@ -19,24 +19,13 @@ package chat.simplex.app.views.helpers import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.interaction.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.AwaitPointerEventScope -import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException -import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.PointerInputScope -import androidx.compose.ui.input.pointer.changedToDown -import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed -import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.consumeAllChanges -import androidx.compose.ui.input.pointer.consumeDownChange -import androidx.compose.ui.input.pointer.isOutOfBounds -import androidx.compose.ui.input.pointer.positionChangeConsumed +import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastAll @@ -45,6 +34,8 @@ import androidx.compose.ui.util.fastForEach import chat.simplex.app.TAG import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex +import kotlin.math.PI +import kotlin.math.abs /** * See original code here: [androidx.compose.foundation.gestures.detectTapGestures] @@ -221,3 +212,65 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit) } return interactionSource } + +suspend fun PointerInputScope.detectTransformGestures( + panZoomLock: Boolean = false, + onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit +) { + forEachGesture { + awaitPointerEventScope { + var rotation = 0f + var zoom = 1f + var pan = Offset.Zero + var pastTouchSlop = false + val touchSlop = viewConfiguration.touchSlop + var lockedToPanZoom = false + + awaitFirstDown(requireUnconsumed = false) + do { + val event = awaitPointerEvent() + val canceled = event.changes.fastAny { it.isConsumed } + if (!canceled) { + val zoomChange = event.calculateZoom() + val rotationChange = event.calculateRotation() + val panChange = event.calculatePan() + + if (!pastTouchSlop) { + zoom *= zoomChange + rotation += rotationChange + pan += panChange + + val centroidSize = event.calculateCentroidSize(useCurrent = false) + val zoomMotion = abs(1 - zoom) * centroidSize + val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f) + val panMotion = pan.getDistance() + + if (zoomMotion > touchSlop || + rotationMotion > touchSlop || + panMotion > touchSlop + ) { + pastTouchSlop = true + lockedToPanZoom = panZoomLock && rotationMotion < touchSlop + } + } + + if (pastTouchSlop) { + val centroid = event.calculateCentroid(useCurrent = false) + val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange + if (effectiveRotation != 0f || + zoomChange != 1f || + panChange != Offset.Zero + ) { + onGesture(centroid, panChange, zoomChange, effectiveRotation) + } + event.changes.fastForEach { + if (it.positionChanged() && zoomChange != 1f) { + it.consume() + } + } + } + } + } while (!canceled && event.changes.fastAny { it.pressed }) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt index 7350b4339a..9fc15c843d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt @@ -32,7 +32,7 @@ fun ModalView( } class ModalManager { - private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>() + private val modalViews = arrayListOf Unit) -> Unit)>>() private val modalCount = mutableStateOf(0) private val toRemove = mutableSetOf() private var oldViewChanging = AtomicBoolean(false) @@ -49,21 +49,21 @@ class ModalManager { } } - fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) { + fun showCustomModal(animated: Boolean = true, modal: @Composable (close: () -> Unit) -> Unit) { Log.d(TAG, "ModalManager.showModal") // Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen. // This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view if (toRemove.isNotEmpty()) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } } - modalViews.add(modal) + modalViews.add(animated to modal) modalCount.value = modalViews.size - toRemove.size } fun closeModal() { if (modalViews.isNotEmpty()) { - //modalViews.removeAt(modalViews.lastIndex) - runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) } + if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) + else runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) } } modalCount.value = modalViews.size - toRemove.size } @@ -75,6 +75,11 @@ class ModalManager { @OptIn(ExperimentalAnimationApi::class) @Composable fun showInView() { + // Without animation + if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) { + modalViews.lastOrNull()?.second?.invoke(::closeModal) + return + } AnimatedContent(targetState = modalCount.value, transitionSpec = { if (targetState > initialState) { @@ -84,7 +89,7 @@ class ModalManager { }.using(SizeTransform(clip = false)) } ) { - modalViews.getOrNull(it - 1)?.invoke(::closeModal) + modalViews.getOrNull(it - 1)?.second?.invoke(::closeModal) // This is needed because if we delete from modalViews immediately on request, animation will be bad if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }