特点:
问题: 手势检测系统不像谷歌的 PDF 应用程序那么流畅。在激活变焦之前,手指的触摸延迟必须很小。有时它与滚动不匹配。有什么想法可以改进吗?
我一直在寻找类似的东西,但向后兼容且轻等待。
https://developer.android.com/jetpack/androidx/releases/pdf
欢迎任何其他改进/建议。
PS:谢谢
implementation("io.legere:pdfiumandroid:1.0.24")
package com.example
import android.graphics.Bitmap
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
@Composable
fun PDFViewer(
stream: InputStream,
modifier: Modifier = Modifier
.fillMaxSize()
.padding(4.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
loader: @Composable () -> Unit = { },
pageBox: @Composable (page: @Composable () -> Unit) -> Unit = { page ->
Box {
page()
}
}
){
val scope = rememberCoroutineScope()
var file by remember { mutableStateOf<File?>(null) }
val context = LocalContext.current
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
try {
file = withContext(Dispatchers.IO) {
val tempFile = File.createTempFile("pdfTempFile", "pdf", context.cacheDir)
tempFile.mkdirs()
tempFile.deleteOnExit()
tempFile.outputStream().use {
it.write(stream.readBytes())
}
tempFile
}
} catch (e: Throwable) {
}
}
}
PDFViewer(file, modifier, verticalArrangement, loader, pageBox)
}
@Composable
fun PDFViewer(
file: File?,
modifier: Modifier = Modifier
.fillMaxSize()
.padding(4.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
loader: @Composable () -> Unit = { },
pageBox: @Composable (page: @Composable () -> Unit) -> Unit = { page ->
Box {
page()
}
}
) {
val density = LocalDensity.current
val listState = rememberLazyListState()
var scale by remember { mutableFloatStateOf(1F) }
var offsetX by remember { mutableFloatStateOf(0f) }
val minZoom = 1F
val maxZoom = 3F
val scope = rememberCoroutineScope()
val viewConfiguration = LocalViewConfiguration.current
CompositionLocalProvider(LocalViewConfiguration provides object : ViewConfiguration by viewConfiguration {
override val touchSlop: Float
get() = viewConfiguration.touchSlop * 5f // Help to detect zoom gesture
}) {
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier
) {
if (file == null) {
loader()
} else {
val pdfRender = remember {
PdfRender(
screenWidth = constraints.maxWidth,
fileDescriptor = ParcelFileDescriptor.open(
file,
ParcelFileDescriptor.MODE_READ_ONLY
)
)
}
LaunchedEffect(Unit) {
pdfRender.loadPDF()
}
DisposableEffect(key1 = Unit) {
onDispose {
pdfRender.close()
}
}
val pages by pdfRender.pages.collectAsState(listOf())
LazyColumn(
state = listState,
verticalArrangement = verticalArrangement,
modifier = Modifier
.pointerInput(Unit) {
detectTransformGestures(true) { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(minZoom, maxZoom)
val maxT = maxOf(constraints.maxWidth * scale - constraints.maxWidth, 0f)
offsetX = (offsetX + pan.x).coerceIn(
minimumValue = -maxT / 2,
maximumValue = maxT / 2
)
scope.launch {
listState.scrollBy(-(pan.y) * (1 / scale))
}
}
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { tapCenter ->
if (scale > 1.0f) {
scale = minZoom
offsetX = 0f
} else {
scale = maxZoom
val center = Pair(
first = constraints.maxWidth / 2,
second = constraints.maxHeight / 2
)
offsetX = (tapCenter.x - center.first) * scale
val yDiff = ((tapCenter.y - center.second) * scale).coerceIn(
minimumValue = -(center.second * 2f),
maximumValue = (center.second * 2f)
)
scope.launch {
listState.scrollBy(-yDiff)
}
}
}
)
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
},
) {
item {
ZoomSpacer(scale, constraints.maxHeight)
}
items(pages) { page ->
val height = with(density) {
page.size.height.toDp()
}
pageBox {
// Help Lazy Lazy Layout render only visible pages
Box(modifier = Modifier.height(height)) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(page) {
bitmap = page.load()
}
DisposableEffect(key1 = Unit) {
//Help with garbage collection, GB runs less often and memory graph is not bumpy.
onDispose {
bitmap?.recycle()
bitmap = null
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Pdf page number: ${page.index}",
)
}
}
}
}
item {
ZoomSpacer(scale, constraints.maxHeight)
}
}
}
}
}
}
/**
* Wee need to overscroll in zoom to zoom last / first page
*/
@Composable
private fun ZoomSpacer(scale: Float, maxHeight: Int) {
val height by remember(scale, maxHeight) {
derivedStateOf {
-(((1 / scale) * maxHeight - maxHeight) / 2.0f)
}
}
val density = LocalDensity.current
val heightDp = with(density) {
height.toDp()
}
Spacer(Modifier.height(height = heightDp))
}
private class PdfRender(
private val fileDescriptor: ParcelFileDescriptor,
val screenWidth: Int
) {
private val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
lateinit var document: PdfDocumentKt
val pages = MutableStateFlow<List<Page>>(listOf())
suspend fun loadPDF() {
document = pdfiumCore.newDocument(fileDescriptor)
pages.value = List(document.getPageCount()) {
Page(
index = it,
pdfRenderer = document,
size = document.openPage(it).use { page ->
Size(
width = screenWidth.toFloat(),
height = ((screenWidth.toFloat() / page.getPageWidthPoint()) * page.getPageHeightPoint())
)
}
)
}
}
fun close() {
document.close()
fileDescriptor.close()
}
class Page(
val index: Int,
val pdfRenderer: PdfDocumentKt,
val size: Size
) {
suspend fun load(): Bitmap {
pdfRenderer.openPage(index).use { currentPage ->
val newBitmap = Bitmap.createBitmap(
size.width.toInt(),
size.height.toInt(),
Bitmap.Config.ARGB_8888
)
currentPage.renderPageBitmap(
newBitmap,
0,
0,
size.width.toInt(),
size.height.toInt(),
renderAnnot = true
)
return newBitmap
}
}
}
}
@Composable
@Preview
private fun CebPdfViewPreview() {
val stream = LocalContext.current.assets.open("apollo.pdf")
PDFViewer(stream = stream)
}
已修复: 我必须实现自己的:
detectTransformGesturesPreferZoom
诀窍是检查 event.changes.size == 1 && !isZoomed()
- 手指数量,因此单指滚动会传播到 LazyLayout。
package com.example
import android.graphics.Bitmap
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateCentroidSize
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import kotlin.math.abs
@Composable
@Preview
private fun CebPdfViewPreview() {
PDFViewer(stream = LocalContext.current.assets.open("apollo.pdf"))
}
@Composable
fun PDFViewer(
stream: InputStream,
modifier: Modifier = Modifier
.fillMaxSize()
.padding(4.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
loader: @Composable () -> Unit = { },
pageBox: @Composable (page: @Composable () -> Unit) -> Unit = { page ->
Box {
page()
}
}
) {
val scope = rememberCoroutineScope()
var file by remember { mutableStateOf<File?>(null) }
val context = LocalContext.current
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
try {
file = withContext(Dispatchers.IO) {
val tempFile = File.createTempFile("pdfTempFile", "pdf", context.cacheDir)
tempFile.mkdirs()
tempFile.deleteOnExit()
tempFile.outputStream().use {
it.write(stream.readBytes())
}
tempFile
}
} catch (e: Throwable) {
}
}
}
PDFViewer(file, modifier, verticalArrangement, loader, pageBox)
}
@Composable
fun PDFViewer(
file: File?,
modifier: Modifier = Modifier
.fillMaxSize()
.padding(4.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
loader: @Composable () -> Unit = { },
pageBox: @Composable (page: @Composable () -> Unit) -> Unit = { page ->
Box {
page()
}
}
) {
val density = LocalDensity.current
val listState = rememberLazyListState()
var scale by remember { mutableFloatStateOf(1F) }
var offsetX by remember { mutableFloatStateOf(0f) }
val minZoom = 1F
val maxZoom = 3F
val scope = rememberCoroutineScope()
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier
) {
if (file == null) {
loader()
} else {
val pdfRender = remember {
PdfRender(
screenWidth = constraints.maxWidth,
fileDescriptor = ParcelFileDescriptor.open(
file,
ParcelFileDescriptor.MODE_READ_ONLY
)
)
}
LaunchedEffect(Unit) {
pdfRender.loadPDF()
}
DisposableEffect(key1 = Unit) {
onDispose {
pdfRender.close()
}
}
val pages by pdfRender.pages.collectAsState(listOf())
LazyColumn(
state = listState,
verticalArrangement = verticalArrangement,
modifier = Modifier
.pointerInput(Unit) {
detectTransformGesturesPreferZoom(
isZoomed = { scale > 1.0f }
) { _, pan, zoom ->
scale = (scale * zoom).coerceIn(minZoom, maxZoom)
val maxT =
maxOf(constraints.maxWidth * scale - constraints.maxWidth, 0f)
offsetX = (offsetX + pan.x).coerceIn(
minimumValue = -maxT / 2,
maximumValue = maxT / 2
)
scope.launch {
listState.scrollBy(-(pan.y) * (1 / scale))
}
}
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { tapCenter ->
if (scale > 1.0f) {
scale = minZoom
offsetX = 0f
} else {
scale = maxZoom
val center = Pair(
first = constraints.maxWidth / 2,
second = constraints.maxHeight / 2
)
offsetX = (tapCenter.x - center.first) * scale
val yDiff = ((tapCenter.y - center.second) * scale).coerceIn(
minimumValue = -(center.second * 2f),
maximumValue = (center.second * 2f)
)
scope.launch {
listState.scrollBy(-yDiff)
}
}
}
)
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
},
) {
item {
ZoomSpacer(scale, constraints.maxHeight)
}
items(pages) { page ->
val height = with(density) {
page.size.height.toDp()
}
pageBox {
// Help Lazy Lazy Layout render only visible pages
Box(modifier = Modifier.height(height)) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(page) {
bitmap = page.load()
}
DisposableEffect(key1 = Unit) {
//Help with garbage collection, GB runs less often and memory graph is not bumpy.
onDispose {
bitmap?.recycle()
bitmap = null
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Pdf page number: ${page.index}",
)
}
}
}
}
item {
ZoomSpacer(scale, constraints.maxHeight)
}
}
}
}
}
/**
* Wee need to overscroll in zoom to zoom last / first page
*/
@Composable
private fun ZoomSpacer(scale: Float, maxHeight: Int) {
val height by remember(scale, maxHeight) {
derivedStateOf {
-(((1 / scale) * maxHeight - maxHeight) / 2.0f)
}
}
val density = LocalDensity.current
val heightDp = with(density) {
height.toDp()
}
Spacer(Modifier.height(height = heightDp))
}
private class PdfRender(
private val fileDescriptor: ParcelFileDescriptor,
val screenWidth: Int
) {
private val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
lateinit var document: PdfDocumentKt
val pages = MutableStateFlow<List<Page>>(listOf())
suspend fun loadPDF() {
document = pdfiumCore.newDocument(fileDescriptor)
pages.value = List(document.getPageCount()) {
Page(
index = it,
pdfRenderer = document,
size = document.openPage(it).use { page ->
Size(
width = screenWidth.toFloat(),
height = ((screenWidth.toFloat() / page.getPageWidthPoint()) * page.getPageHeightPoint())
)
}
)
}
}
fun close() {
document.close()
fileDescriptor.close()
}
class Page(
val index: Int,
val pdfRenderer: PdfDocumentKt,
val size: Size
) {
suspend fun load(): Bitmap {
pdfRenderer.openPage(index).use { currentPage ->
val newBitmap = Bitmap.createBitmap(
size.width.toInt(),
size.height.toInt(),
Bitmap.Config.ARGB_8888
)
currentPage.renderPageBitmap(
newBitmap,
0,
0,
size.width.toInt(),
size.height.toInt(),
renderAnnot = true
)
return newBitmap
}
}
}
}
suspend fun PointerInputScope.detectTransformGesturesPreferZoom(
isZoomed: () -> Boolean,
onGesture: (centroid: Offset, pan: Offset, zoom: Float) -> Unit
) {
awaitEachGesture {
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled =
event.changes.fastAny { it.isConsumed } || (event.changes.size == 1 && !isZoomed())
if (!canceled) {
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
pan += panChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop || panMotion > touchSlop) {
pastTouchSlop = true
}
}
val centroid = event.calculateCentroid(useCurrent = false)
if (zoomChange != 1f || panChange != Offset.Zero) {
onGesture(centroid, panChange, zoomChange)
}
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
} while (!canceled && event.changes.fastAny { it.pressed })
}
}