improved image views

This commit is contained in:
Sergey Chernov 2026-05-24 08:53:23 +03:00
parent 1c6a80c43b
commit 44a83a17dd
3 changed files with 62 additions and 19 deletions

View File

@ -6,8 +6,11 @@ import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -35,7 +38,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@ -66,6 +68,8 @@ internal fun ImageViewer(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val imageBackgroundColor = readerImageBackgroundColor()
val imageAspectRatio = image.bitmap.width.toFloat() / image.bitmap.height.toFloat()
fun setScale(next: Float) { fun setScale(next: Float) {
scale = next.coerceIn(MinImageScale, MaxImageScale) scale = next.coerceIn(MinImageScale, MaxImageScale)
@ -133,11 +137,11 @@ internal fun ImageViewer(
} }
}, },
) { padding -> ) { padding ->
Box( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.background(Color.Black) .background(readerBackground())
.focusRequester(focusRequester) .focusRequester(focusRequester)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
@ -171,19 +175,28 @@ internal fun ImageViewer(
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( val imageModifier = if (maxWidth / maxHeight > imageAspectRatio) {
bitmap = image.bitmap, Modifier.fillMaxHeight().aspectRatio(imageAspectRatio)
contentDescription = image.title, } else {
modifier = Modifier Modifier.fillMaxWidth().aspectRatio(imageAspectRatio)
.fillMaxSize() }
Box(
modifier = imageModifier
.graphicsLayer { .graphicsLayer {
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
translationX = offset.x translationX = offset.x
translationY = offset.y translationY = offset.y
}, }
.background(imageBackgroundColor),
) {
Image(
bitmap = image.bitmap,
contentDescription = image.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} }
} }
}
} }

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -47,6 +49,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -368,6 +371,7 @@ internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit
image = book.coverImages.firstOrNull() ?: book.bodyImages.firstOrNull(), image = book.coverImages.firstOrNull() ?: book.bodyImages.firstOrNull(),
modifier = Modifier.width(112.dp).aspectRatio(0.68f), modifier = Modifier.width(112.dp).aspectRatio(0.68f),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
fitBackgroundToBitmapBounds = false,
onOpen = onImageOpen, onOpen = onImageOpen,
) )
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
@ -564,6 +568,7 @@ private fun BookImage(
image: Fb2ImageRef?, image: Fb2ImageRef?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
fitBackgroundToBitmapBounds: Boolean = true,
onOpen: (ViewedBookImage) -> Unit = {}, onOpen: (ViewedBookImage) -> Unit = {},
) { ) {
val binary = remember(book, image) { val binary = remember(book, image) {
@ -573,10 +578,12 @@ private fun BookImage(
binary?.let { decodeBookImage(it) } binary?.let { decodeBookImage(it) }
} }
val imageTitle = image?.alt?.ifBlank { null } ?: book.title val imageTitle = image?.alt?.ifBlank { null } ?: book.title
Box( val imageBackgroundColor = readerImageBackgroundColor()
val density = LocalDensity.current
BoxWithConstraints(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surface)
.then( .then(
if (bitmap != null && binary != null) { if (bitmap != null && binary != null) {
Modifier.clickable { Modifier.clickable {
@ -596,12 +603,23 @@ private fun BookImage(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (bitmap != null) { if (bitmap != null) {
val imageAspectRatio = bitmap.width.toFloat() / bitmap.height.coerceAtLeast(1).toFloat()
val imageModifier = if (fitBackgroundToBitmapBounds) {
val bitmapWidth = with(density) { bitmap.width.toDp() }
Modifier
.width(if (bitmapWidth < maxWidth) bitmapWidth else maxWidth)
.aspectRatio(imageAspectRatio)
} else {
Modifier.fillMaxSize()
}
Box(imageModifier.background(imageBackgroundColor)) {
Image( Image(
bitmap = bitmap, bitmap = bitmap,
contentDescription = imageTitle, contentDescription = imageTitle,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxSize(),
contentScale = contentScale, contentScale = contentScale,
) )
}
} else { } else {
Text(strings.noImage, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline) Text(strings.noImage, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
} }

View File

@ -22,7 +22,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -107,6 +109,16 @@ internal fun themedTopAppBarColors(): TopAppBarColors =
@Composable @Composable
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background) internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background)
@Composable
internal fun readerImageBackgroundColor(): Color {
val surface = MaterialTheme.colorScheme.surface
return if (surface.isVisuallyDark()) lerp(surface, Color.White, 0.5f) else surface
}
private fun Color.isVisuallyDark(): Boolean {
val luminance = 0.2126f * red + 0.7152f * green + 0.0722f * blue
return luminance < 0.5f
}
internal fun Int.formatCompact(): String = internal fun Int.formatCompact(): String =
if (this >= 10_000) "${(this / 1000.0).roundToInt()}k" else toString() if (this >= 10_000) "${(this / 1000.0).roundToInt()}k" else toString()