ZAICOでは、Android・iOS・Rubyエンジニアを絶賛募集中です! 詳しくは、採用ページをご覧ください。
こんにちは!モバイルアプリエンジニア兼宴会副部長のいくえもんです。ご無沙汰しています。
先日Fedex Weekが開催されました。Fedex Weekでは、普段の業務から離れ、自由なコンセプトで開発をすることができます。
今回はzaico for Emergencyをテーマに、「災害などの緊急事態が発生した時に、zaicoで管理している在庫情報を誰とでも簡単に共有できる」仕組みとして、在庫データをAndroidアプリでPDFダウンロードできる仕組みを作りました。これにより、ネットに接続しなくても、不足品などの情報を画像やバーコード付きで出力することができるようになりました。
本ブログでは、上記の取り組みの中から、AndroidアプリでPDFファイルを作成する方法についてご紹介します。
最終的に出来上がるPDFはこんな感じです。
AndroidでPDFを作成する基礎
AndroidにはPdfDocumentというPDFファイルを作成・編集するためのクラスが用意されています。このクラスを使うと、下のようにPDFファイルを作成したり、文字や画像をPDFファイルに書き込んだりすることができます。
fun generatePdf(context: Context, uri: Uri) { // PDFドキュメント生成用のクラスを作成 val pdfDocument = PdfDocument() // 指定の縦横幅(ピクセル)でページを作る val pageInfo = PdfDocument.PageInfo.Builder(WIDTH, HEIGHT, 1).create() // 作ったページに書き込みを始める val page = pdfDocument.startPage(pageInfo) // ページに書き込むためのキャンバスを取得する val canvas = page.canvas // 画像を書き込む val paint = Paint() canvas.drawBitmap(bitmap, /*開始位置X(ピクセル)*/, /*開始位置Y(ピクセル)*/, paint) // え??X,Y座標指定するの?? // 文字を書き込む val text = Paint() text.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) text.textSize = 14f text.color = ContextCompat.getColor(context, R.color.black) canvas.drawText("サンプルの文字", /*開始位置X(ピクセル)*/, /*開始位置Y(ピクセル)*/, text) // え??こっちもX,Y座標指定するの??? // ページを閉じる pdfDocument.finishPage(page) try { // ファイルを開いてPDFを書き込む context.contentResolver.openFileDescriptor(uri, "w")?.use { pdfDocument.writeTo(FileOutputStream(it.fileDescriptor)) Toast.makeText(context, "PDF file generated successfully", Toast.LENGTH_SHORT).show() } } catch (e: IOException) { e.printStackTrace() } pdfDocument.close() }
このように、画像を書き込むにも、文字を書き込むにも、ピクセルで開始位置のX, Y座標を指定する必要があります。
Androidで自由なフォーマットでPDFを作成する方法
サンプルのPDFを見ていただくとわかりますが、今回はタイトルが中央寄せになっていたり、説明文が任意だったり、画像やQRコードの出力有無が選べたり、そもそも一覧に表示する在庫データの数が変わったり、在庫データごとの1行の高さが変わったりします。
毎回開始位置のX, Y座標計算するとか地獄やん!!!
ということで、地獄の計算をしなくても、自由度の高いデザインのPDFを作成する方法をご紹介します。
その方法とは、
- アプリ画面に表示しないViewをPDFファイルと同じ大きさで作成
- ViewをBitmapに変換
- BitmapをPDFの座標(0, 0)に貼り付ける
という裏技です!最初にViewを作ることで、要素の追加・削除・表示場所の調整がとっても簡単にできます。
まずは、PDFの元となるpdf_template.xmlを作成します。こんな感じです。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="@dimen/pdf_width" android:layout_height="@dimen/pdf_height" android:paddingHorizontal="@dimen/pdf_margin_horizontal" android:paddingVertical="@dimen/pdf_margin_vertical"> <androidx.appcompat.widget.LinearLayoutCompat android:id="@+id/linearLayout" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginBottom="36dp" android:orientation="vertical"> <!-- 中央寄せのテキスト --> <TextView android:id="@+id/text" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textSize="20sp" android:textStyle="bold" android:textColor="@color/black" /> <!-- 左寄せのテキスト --> <TextView android:id="@+id/description" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_vertical_normal" android:gravity="start" style="@style/PdfTextStyle" android:maxLines="5" /> <!-- テーブル上部の掛け線 --> <View android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="@dimen/screen_vertical_margin" android:background="@color/black" /> </androidx.appcompat.widget.LinearLayoutCompat> <TextView android:id="@+id/pageNumber" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" style="@style/PdfTextStyle" android:text="1" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" style="@style/SmallTextStyle" android:text="created by zaico - https://www.zaico.co.jp" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
次に、在庫データの1行になるlayoutファイルpdf_item.xmlを作成します。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <androidx.appcompat.widget.LinearLayoutCompat android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <androidx.appcompat.widget.LinearLayoutCompat android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <View android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/black" /> <androidx.appcompat.widget.AppCompatImageView android:id="@+id/thumbnailImage" android:layout_width="@dimen/thumbnail_size" android:layout_height="@dimen/thumbnail_size" android:layout_marginVertical="@dimen/margin_vertical_small" android:layout_gravity="top" android:background="@drawable/no_image" /> <View android:id="@+id/thumbnailDivider" android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/black" /> <TextView android:id="@+id/itemText" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_horizontal_normal" android:layout_marginVertical="@dimen/margin_vertical_normal" style="@style/PdfTextStyle" /> <View android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/black" /> <androidx.appcompat.widget.AppCompatImageView android:id="@+id/qrCodeImage" android:layout_width="@dimen/pdf_qrcode_size" android:layout_height="@dimen/pdf_qrcode_size" android:layout_marginHorizontal="@dimen/margin_horizontal_normal" android:layout_marginVertical="@dimen/margin_vertical_normal" android:layout_gravity="center" android:visibility="visible"/> <View android:id="@+id/codeDivider" android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/black" android:visibility="visible"/> </androidx.appcompat.widget.LinearLayoutCompat> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/black" /> </androidx.appcompat.widget.LinearLayoutCompat> </layout>
あとは、ユーザーの入力内容に従って、pdf_template.xmlから作ったViewにデータを追加し、pdf_item.xmlを在庫データ分pdf_template.xmlのlinearLayout
の最下部に追加していきます。
ここで、何も考えずに在庫データをどんどん追加していくと、表の高さがPDFの縦幅を超えて途中で表が切れてしまいます。
このため、在庫データを追加するたびにViewの高さを計算し、必要に応じて次のページのViewを作成します。
fun generatePdfViews(page: Int, fromStockIndex: Int, viewList: MutableList<View>) { val inflater = LayoutInflater.from(this) // pdf_template.xmlから作ったBinding val pdfBinding = PdfTemplateBinding.inflate(inflater) // 複数ページにまたがる事があるので、1ページずつViewをviewListに追加する viewList.add(pdfBinding.root) // こんな感じでpdf_template.xmlの要素を設定 // タイトルの設定 if (page == 1 && !binding.title.editText?.text.isNullOrBlank()) { pdfBinding.title.text = binding.title.editText?.text } else { pdfBinding.title.visibility = View.GONE } // 在庫データの設定 val stocks = viewModel.selectedStocks.value ?: listOf() for(index in fromStockIndex until stocks.size) { val stock = stocks[index] // pdf_item.xmlから作った在庫データ1件分のBinding val itemBinding = PdfItemBinding.inflate(inflater) // pdf_template.xmlと同様に在庫データ情報を設定(省略) // 在庫データの行をpdf_templateのlinearLayoutの最下部に追加 pdfBinding.linearLayout.addView(itemBinding.root) // Viewのサイズを確認。横幅をPDFの幅にしたときに、縦幅が幾つになるのかを確認するため、Y側はUNSPECIFIEDを指定する。 pdfBinding.root.measure( View.MeasureSpec.makeMeasureSpec(pdfA4Width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) ) // A4サイズの高さを超えていた場合、次のページにする if (pdfBinding.root.measuredHeight > pdfA4Height) { itemBinding.root.visibility = View.GONE // 今のページでは最後に追加した在庫情報を非表示に generatePdfViews(page + 1, index, viewList) // 次のページを作成するため、次のページ番号、最後の在庫データのindexを渡して再度呼び出し break } } }
ここまで来れば、あとは出来上がったviewListを渡してPDFを1ページずつ作っていけばOKです。
ただし、viewListのViewは一度も描画されたことがないので、Bitmapに変換する前に描画サイズを計算してレイアウトください。
viewListを使って複数ページのPDFを作成する最終メソッドは次のようになります。
fun generatePdf(views: List<View>, context: Context, uri: Uri) { val pdfDocument = PdfDocument() val width = context.resources.getDimensionPixelSize(R.dimen.pdf_width) val height = context.resources.getDimensionPixelSize(R.dimen.pdf_height) val pageInfo = PdfDocument.PageInfo.Builder(width, height, 1).create() views.forEach { view -> // 1ページずつしか書き込めないので、View1つずつstartPageする val page = pdfDocument.startPage(pageInfo) val canvas = page.canvas // 画像を書き込む val paint = Paint() // Viewのサイズを計測して、描画する。今回はheightにもEXACTLYを指定 view.measure( View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) ) view.layout(0, 0, width, height) // Viewから作ったBitmapを書き込む canvas.drawBitmap(view.drawToBitmap(), 0f, 0f, paint) // 次のページを書き込むために今のページは閉じる pdfDocument.finishPage(page) } // ファイルに出力するところは同じ(省略) }
最後までお付き合いいただきありがとうございました。
こうやってみると、AndroidのView周りは本当に便利関数が用意されていて助かるな〜と実感しました。
みなさんもAndroidでPDFを作る際は、Viewの利用を検討してみてください。