📜

【Jetpack Compose】Assetsフォルダに入れたPDFを表示する

2023/01/17に公開

はじめに

宣言的UIしか知らない人間が、ふとAndroidでPDFビューアーをつくろうと思い立ち実装しようとしたら、結構しんどかったので、記憶がフレッシュなうちに備忘録として残しておきたいと思います。

結論

こんな感じの実装になりました。

1. PDFを表示するComposable

PDFScreen
PDFScreen
@Composable
fun PDFScreen(
    modifier: Modifier = Modifier,
    uri: Uri,
    verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp)
) {
    val rendererScope = rememberCoroutineScope()
    val mutex = remember { Mutex() }
    val renderer by produceState<PdfRenderer?>(null, uri) {
        rendererScope.launch(Dispatchers.IO) {
            val input = ParcelFileDescriptor.open(uri.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
            value = PdfRenderer(input)
        }
        awaitDispose {
            val currentRenderer = value
            rendererScope.launch(Dispatchers.IO) {
                mutex.withLock {
                    currentRenderer?.close()
                }
            }
        }
    }
    val context = LocalContext.current
    val imageLoader = LocalContext.current.imageLoader
    val imageLoadingScope = rememberCoroutineScope()
    BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
        val width = with(LocalDensity.current) { maxWidth.toPx() }.toInt()
        val height = (width * sqrt(2f)).toInt()
        val pageCount by remember(renderer) { derivedStateOf { renderer?.pageCount ?: 0 } }
        LazyColumn(
            verticalArrangement = verticalArrangement
        ) {
            items(
                count = pageCount,
                key = { index -> "$uri-$index" }
            ) { index ->
                val cacheKey = MemoryCache.Key("$uri-$index")
                var bitmap by remember { mutableStateOf(imageLoader.memoryCache?.get(cacheKey) as? Bitmap?) }
                if (bitmap == null) {
                    DisposableEffect(uri, index) {
                        val job = imageLoadingScope.launch(Dispatchers.IO) {
                            val destinationBitmap =
                                Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
                            mutex.withLock {
                                if (!coroutineContext.isActive) return@launch
                                try {
                                    renderer?.let {
                                        it.openPage(index).use { page ->
                                            page.render(
                                                destinationBitmap,
                                                null,
                                                null,
                                                PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
                                            )
                                        }
                                    }
                                } catch (e: Exception) {
                                    //Just catch and return in case the renderer is being closed
                                    return@launch
                                }
                            }
                            bitmap = destinationBitmap
                        }
                        onDispose {
                            job.cancel()
                        }
                    }
                    Box(
                        modifier = Modifier
                            .background(Color.White)
                            .aspectRatio(1f / sqrt(2f))
                            .fillMaxWidth()
                    )
                } else {
                    val request = ImageRequest.Builder(context)
                        .size(width, height)
                        .memoryCacheKey(cacheKey)
                        .data(bitmap)
                        .build()

                    Image(
                        modifier = Modifier
                            .background(Color.White)
                            .aspectRatio(1f / sqrt(2f))
                            .fillMaxWidth(),
                        contentScale = ContentScale.Fit,
                        painter = rememberAsyncImagePainter(request),
                        contentDescription = "Page ${index + 1} of $pageCount"
                    )
                }
            }
        }
    }
}

2. AssetsフォルダにあるPDFをUriに変換するクラス

PDFScreen
PDFConverter
class PDFConverter {
    fun assetsToUri(context: Context, fileName: String): Uri {
        val input: InputStream = context.assets.open(fileName)
        val file = File(context.cacheDir, fileName)

        input.use { inputStream ->
            FileOutputStream(file).use { output ->
                val buffer = ByteArray(4 * 1024) // or other buffer size
                var read: Int
                while (inputStream.read(buffer).also { read = it } != -1) {
                    output.write(buffer, 0, read)
                }
                output.flush()
            }
        }

        return Uri.fromFile(file)
    }
}

3.メイン画面のComposable

MainScreen
MainScreen
@Composable
fun MainScreen(
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val fileName = "Sample.pdf" // ここにAssetsフォルダ内のファイル名を入れる
    val pdfConverter = PDFConverter()
    val uri: Uri? by remember { mutableStateOf(pdfConverter.assetsToUri(context, fileName)) }

    ExampleTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colors.background
        ) {
            uri?.let {
	        PDFScreen(uri = it)
            }
        }
    }
}

4.Manifestでのパーミッション追加

AndroidManifest.xml
AndroidManifest.xml
<!-- Assetsからの読み書き用-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

引っかかりポイント

その1. PDFをJetpack Composeで表示させるサンプルが少ない

有料のこちらこちらがヒットしたり、そもそもそんなことできてる人いるんかいな、という記事まであり不安になりましたが、ちゃんとできている方がいらっしゃいました。
こちらの記事がメシアでした。

その2. AssetsフォルダからUriを作成するのが地味にトリッキー

セキュリティの関係で、毎回Pathが変わるようになったとかで、一度キャッシュに保存してからそのFileからUriを作成する必要がある、ということを理解するのにかなり時間を要しました...。(普通にやろうとするとFileNotFoundErrorで落ちる)
WebView向けのやや紛らわしい記事もあったり...。
最終的にこちらの記事に命を救ってもらいました。
Uriのfile://スキームとcontent://スキームの違いも知らず...。こちらでお勉強させていただきました。この辺はもっと勉強してきちっと理解したいです。
Uri.parse(Assetsまでのパス)->FileNotFound
Uri.getUriForFile(コンテキスト, オーソライズ, ファイル名)->content://スキーム(Providerの勉強にはなりました)
Uri.fromFile(ファイル)->file://スキーム(ただし共有はできないそう)

これからの課題

ただ表示させているだけなので、線も引けないですし、ズームもできません。ファイルから選んで開けるようにしたり、再起動時に位置を保存したりといったことの実装もこれからということで、PDFビューアーって結構奥が深いんだなと感じます。
StackOverflowに支えてもらってる胡散臭いエンジニアになりつつあるので、公式ドキュメントをちゃんと見る習慣をつけねばと思います。

詳細について理解が深まり次第追記していきたいと思います。

参考URL(上記のうち特に参考にしたものを抜粋)

  • PDFをJetpack composeで表示する。

https://stackoverflow.com/questions/69943176/create-a-pdf-viewer-in-jetpack-compose-using-pdfrenderer

  • Assetsからキャッシュに保存し、そこからUriを作成

https://stackoverflow.com/questions/51250986/how-to-open-pdf-file-from-assets-folder

Discussion