使用 Jetpack Compose 构建可缩放 PDF 查看器

问题描述 投票:0回答:1

特点:

  • 支持PDF注释
  • 捏合缩放,点击放大/缩小
  • 向后兼容

问题: 手势检测系统不像谷歌的 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)
}
android pdf android-jetpack-compose android-jetpack
1个回答
0
投票

已修复: 我必须实现自己的:

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 })
    }
}
© www.soinside.com 2019 - 2024. All rights reserved.