Infinity pager

This commit is contained in:
Avently
2022-10-12 13:03:04 +03:00
parent 94a5702011
commit b94cdc2a62
5 changed files with 156 additions and 98 deletions

View File

@@ -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,6 +30,8 @@ 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.*
@@ -42,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
@Composable
fun ChatView(chatModel: ChatModel) {
@@ -497,21 +503,15 @@ 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 ->
val indexInReversed = reversedChatItems.indexOfFirst { it.id == itemsWithImages[dismissedIndex].id }
// Do not scroll to this item, just to different items
if (indexInReversed == i) return@from
scope.launch {
listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
-maxHeightRounded / 2
)
}
val provider = {
providerForGallery(i, chatItems, cItem.id) { indexInReversed ->
scope.launch {
listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
-maxHeightRounded
)
}
} else null
}
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
@@ -777,6 +777,69 @@ private fun bottomEndFloatingButton(
}
}
private fun providerForGallery(
listStateIndex: Int,
chatItems: List<ChatItem>,
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<Int, ChatItem>? {
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<Bitmap, Uri>? {
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() =

View File

@@ -40,7 +40,7 @@ import java.io.File
fun CIImageView(
image: String,
file: CIFile?,
provider: ImageGalleryProvider,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
) {
@@ -157,7 +157,7 @@ fun CIImageView(
imageView(imagePainter, onClick = {
if (getLoadedFilePath(context, file) != null) {
ModalManager.shared.showCustomModal(animated = false) { close ->
ImageFullScreenView(provider, close)
ImageFullScreenView(imageProvider, close)
}
}
})

View File

@@ -34,7 +34,7 @@ fun ChatItemView(
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
imageProvider: ImageGalleryProvider? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean,

View File

@@ -37,7 +37,7 @@ fun FramedItemView(
chatInfo: ChatInfo,
ci: ChatItem,
uriHandler: UriHandler? = null,
imageProvider: ImageGalleryProvider? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,

View File

@@ -6,8 +6,7 @@ import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
@@ -16,10 +15,7 @@ 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
@@ -28,101 +24,100 @@ import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import coil.size.Size
import com.google.accompanist.pager.*
import java.io.File
import kotlinx.coroutines.launch
interface ImageGalleryProvider {
val currentItem: Int
val totalImagesSize: Int
fun uniqueKey(index: Int): Long
val initialIndex: Int
val totalImagesSize: MutableState<Int>
fun getImage(index: Int): Pair<Bitmap, Uri>?
fun currentPageChanged(index: Int)
fun scrollToStart()
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
}
override fun onDismiss(index: Int) { onDismiss(index) }
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ImageFullScreenView(provider: ImageGalleryProvider, close: () -> Unit) {
val pagerState = rememberPagerState(provider.currentItem)
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)
HorizontalPager(count = provider.totalImagesSize, state = pagerState, key = { provider.uniqueKey(it) }) { index ->
val (imageBitmap: Bitmap, uri: Uri) = provider.getImage(index) ?: return@HorizontalPager
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 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
LaunchedEffect(currentPage) {
// Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time
if (currentPage != provider.initialIndex)
provider.currentPageChanged(index)
}
// 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())
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 {
if (currentPage == index - 1) provider.totalImagesSize.value = currentPage + 1
else if (currentPage == index + 1) {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
}
.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
}
}
)
} 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(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())
}
}
.fillMaxSize(),
)
.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(),
)
}
}
}
}