【Jetpack Compose】Assetsフォルダに入れたPDFを表示する
はじめに
宣言的UIしか知らない人間が、ふとAndroidでPDFビューアーをつくろうと思い立ち実装しようとしたら、結構しんどかったので、記憶がフレッシュなうちに備忘録として残しておきたいと思います。
結論
こんな感じの実装になりました。
1. PDFを表示するComposable
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
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
@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
<!-- 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で表示する。
- Assetsからキャッシュに保存し、そこからUriを作成
Discussion