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の利用を検討してみてください。