android: Image gallery

This commit is contained in:
Avently
2022-10-11 19:56:10 +03:00
parent 563687f9e4
commit 1f7e64d9dc
8 changed files with 210 additions and 85 deletions
+1
View File
@@ -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'
@@ -33,8 +33,7 @@ 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
@@ -470,6 +469,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
@@ -497,7 +497,19 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
val provider = remember(chatItems) {
if (cItem.content.msgContent is MsgContent.MCImage) {
val itemsWithImages by lazy { chatItems.filter { it.content.msgContent is MsgContent.MCImage && it.file?.loaded == true } }
ImageGalleryProvider.from(cItem.id, { itemsWithImages }) { dismissedIndex ->
scope.launch {
listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, reversedChatItems.indexOfFirst { it.id == itemsWithImages[dismissedIndex].id } + 1),
-maxHeightRounded / 2
)
}
}
} else null
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
@@ -523,11 +535,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)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
} 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)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
}
} else { // direct message
@@ -538,7 +550,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)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
}
}
@@ -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?,
provider: ImageGalleryProvider,
showMenu: MutableState<Boolean>,
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(provider, close)
}
}
})
} else {
@@ -34,6 +34,7 @@ fun ChatItemView(
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
imageProvider: ImageGalleryProvider? = null,
showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean,
@@ -63,7 +64,7 @@ fun ChatItemView(
EmojiItemView(cItem)
} else {
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(cInfo, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
}
DropdownMenu(
expanded = showMenu.value,
@@ -37,6 +37,7 @@ fun FramedItemView(
chatInfo: ChatInfo,
ci: ChatItem,
uriHandler: UriHandler? = null,
imageProvider: ImageGalleryProvider? = null,
showMember: Boolean = false,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
@@ -126,7 +127,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 {
@@ -1,80 +1,128 @@
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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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 androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
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 java.io.File
@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())
}
interface ImageGalleryProvider {
val currentItem: Int
val totalImagesSize: Int
fun uniqueKey(index: Int): Long
fun getImage(index: Int): Pair<Bitmap, Uri>?
fun onDismiss(index: Int)
companion object {
fun from(chatItemId: Long, items: () -> List<ChatItem>, onDismiss: (Int) -> Unit): ImageGalleryProvider = object: ImageGalleryProvider {
override val currentItem: Int
get() = items().indexOfFirst { it.id == chatItemId }
override val totalImagesSize: Int
get() = items().size
override fun uniqueKey(index: Int): Long = items()[index].id
override fun getImage(index: Int): Pair<Bitmap, Uri>? {
val file = items().getOrNull(index)?.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
}
.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(),
)
override fun onDismiss(index: Int) { onDismiss(index) }
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ImageFullScreenView(provider: ImageGalleryProvider, close: () -> Unit) {
val pagerState = rememberPagerState(provider.currentItem)
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
BackHandler(onBack = goBack)
HorizontalPager(count = provider.totalImagesSize, state = pagerState, key = { provider.uniqueKey(it) }) { index ->
val (imageBitmap: Bitmap, uri: Uri) = provider.getImage(index) ?: return@HorizontalPager
Column(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack)
) {
var scale by remember { mutableStateOf(1f) }
var translationX by remember { mutableStateOf(0f) }
var translationY by remember { mutableStateOf(0f) }
LaunchedEffect(pagerState.currentPage) {
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(),
)
}
}
}
@@ -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 })
}
}
}
@@ -32,7 +32,7 @@ fun ModalView(
}
class ModalManager {
private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>()
private val modalViews = arrayListOf<Pair<Boolean, (@Composable (close: () -> Unit) -> Unit)>>()
private val modalCount = mutableStateOf(0)
private val toRemove = mutableSetOf<Int>()
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 } }