实现 PDF 查看器

PdfViewerFragment 是一种专用 Fragment,可用于在 Android 应用中显示 PDF 文档。PdfViewerFragment 简化了 PDF 渲染,让您可以专注于应用功能的其他方面。

结果

使用 PdfViewerFragment 在 Android 应用中呈现的 PDF 文档。
应用中显示的 PDF 文档。

版本兼容性

如需使用 PdfViewerFragment,您的应用必须以 Android S(API 级别 31)和 SDK 扩展级别 13 为最低目标平台。如果不满足这些兼容性要求,该库会抛出 UnsupportedOperationException

您可以在运行时使用 SdkExtensions 模块检查 SDK 扩展版本。这样一来,您就可以仅在设备满足必要要求时有条件地加载 fragment 和 PDF 文档。

if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
    // Load the fragment and document.
}

依赖项

如需将 PDF 查看器纳入您的应用中,请在应用的模块 build.gradle 文件中声明 androidx.pdf 依赖项。您可以从 Google Maven 制品库访问 PDF 库。

dependencies {
    val pdfVersion = "1.0.0-alpha0X"
    implementation("androidx.pdf:pdf:pdf-viewer-fragment:$pdfVersion")
}

PdfViewerFragment 功能

PdfViewerFragment 以分页格式呈现 PDF 文档,方便您浏览。为了实现高效加载,该 fragment 采用双遍渲染策略,可逐步加载页面维度。

为了优化内存使用情况,PdfViewerFragment 仅渲染当前可见的页面,并释放屏幕外页面的位图。此外,PdfViewerFragment 还包含一个悬浮操作按钮 (FAB),该按钮通过触发包含文档 URI 的隐式 android.intent.action.ANNOTATE intent 来支持注释。

实现

向 Android 应用添加 PDF 查看器是一个多步骤的过程。

创建 activity 布局

首先,为托管 PDF 查看器的 activity 定义布局 XML。布局应包含一个 FrameLayout 来容纳 PdfViewerFragment 和供用户互动的按钮,例如在文档中进行搜索。

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/pdf_container_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <FrameLayout
        android:id="@+id/fragment_container_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <com.google.android.material.button.MaterialButton
        android:id="@+id/search_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/search_string"
        app:strokeWidth="1dp"
        android:layout_marginStart="8dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

设置活动

托管 PdfViewerFragment 的 activity 必须扩展 AppCompatActivity。在 activity 的 onCreate() 方法中,将内容视图设置为您创建的布局,并初始化所有必需的界面元素。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val getContentButton: MaterialButton = findViewById(R.id.launch_button)
        val searchButton: MaterialButton = findViewById(R.id.search_button)
    }
}

初始化 PdfViewerFragment

使用从 getSupportFragmentManager() 获取的 fragment 管理器创建 PdfViewerFragment 的实例。在创建新的 fragment 实例之前,请检查该 fragment 的实例是否已存在,尤其是在配置更改期间。

在以下示例中,initializePdfViewerFragment() 函数负责处理 fragment 事务的创建和提交。该函数会将容器中现有的 fragment 替换为 PdfViewerFragment 的实例。

class MainActivity : AppCompatActivity() {
    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
    private var pdfViewerFragment: PdfViewerFragment? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        if (pdfViewerFragment == null) {
            pdfViewerFragment =
                supportFragmentManager
                    .findFragmentByTag(PDF_VIEWER_FRAGMENT_TAG) as PdfViewerFragment?
        }

    }

    // Used to instantiate and commit the fragment.
    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
    private fun initializePdfViewerFragment() {
        // This condition can be skipped if you want to create a new fragment every time.
        if (pdfViewerFragment == null) {
            val fragmentManager: FragmentManager = supportFragmentManager

          // Fragment initialization.
          pdfViewerFragment = PdfViewerFragmentExtended()
          val transaction: FragmentTransaction = fragmentManager.beginTransaction()

          // Replace an existing fragment in a container with an instance of a new fragment.
          transaction.replace(
              R.id.fragment,4_container_view,
              pdfViewerFragment!!,
              PDF_VIEWER_FRAGMENT_TAG
          )
          transaction.commitAllowingStateLoss()
          fragmentManager.executePendingTransactions()
        }
    }

    companion object {
        private const val MIME_TYPE_PDF = "application/pdf"
        private const val PDF_VIEWER_FRAGMENT_TAG = "pdf_viewer_fragment_tag"
    }
}

扩展 PdfViewerFragment 功能

PdfViewerFragment 会公开一些函数,您可以替换这些函数来扩展其功能。创建一个继承自 PdfViewerFragment 的新类。在子类中,替换 onLoadDocumentSuccess()onLoadDocumentError() 等方法以添加自定义逻辑,例如记录指标。

@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
class PdfViewerFragmentExtended : PdfViewerFragment() {
          private val someLogger : SomeLogger = // ... used to log metrics

          override fun onLoadDocumentSuccess() {
                someLogger.log(/** log document success */)
          }

          override fun onLoadDocumentError(error: Throwable) {
                someLogger.log(/** log document error */, error)
          }
}

虽然 PdfViewerFragment 不包含内置的搜索菜单,但它支持搜索栏。您可以使用 isTextSearchActive API 控制搜索栏的可见性。如需启用文档搜索,请设置 PdfViewerFragment 实例的 isTextSearchActive 属性。

使用 WindowCompat.setDecorFitsSystemWindows() 可确保 WindowInsetsCompat 正确传递给内容视图,这对于正确定位搜索视图是必需的。

class MainActivity : AppCompatActivity() {
    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
        searchButton.setOnClickListener {
            pdfViewerFragment?.isTextSearchActive =
                pdfViewerFragment?.isTextSearchActive == false
        }

        // Ensure WindowInsetsCompat are passed to content views without being
        // consumed by the decor view. These insets are used to calculate the
        // position of the search view.
        WindowCompat.setDecorFitsSystemWindows(window, false)
    }
}

与文件选择器集成

如需允许用户从其设备中选择 PDF 文件,请将 PdfViewerFragment 与 Android 文件选择器集成。首先,更新 activity 的布局 XML,以添加一个用于启动文件选择器的按钮。

<...>
    <FrameLayout
        ...
        app:layout_constraintBottom_toTopOf="@+id/launch_button"/>
    // Adding a button to open file picker.
    <com.google.android.material.button.MaterialButton
        android:id="@+id/launch_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/launch_string"
        app:strokeWidth="1dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/search_button"/>

    <com.google.android.material.button.MaterialButton
        ...
        app:layout_constraintStart_toEndOf="@id/launch_button" />

</androidx.constraintlayout.widget.ConstraintLayout>

接下来,在您的 activity 中,使用 registerForActivityResult(GetContent()) 启动文件选择器。当用户选择文件时,回调会提供一个 URI。然后,您可以使用此 URI 设置 PdfViewerFragment 实例的 documentUri 属性,以加载并显示所选 PDF。

class MainActivity : AppCompatActivity() {
    // ...

    private var filePicker: ActivityResultLauncher<String> =
        registerForActivityResult(GetContent()) { uri: Uri? ->
            uri?.let {
                initializePdfViewerFragment()
                pdfViewerFragment?.documentUri = uri
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
        getContentButton.setOnClickListener { filePicker.launch(MIME_TYPE_PDF) }
    }

    private fun initializePdfViewerFragment() {
        // ...
    }

    companion object {
        private const val MIME_TYPE_PDF = "application/pdf"
        // ...
    }
}

自定义界面

您可以替换库公开的 XML 属性,从而自定义 PdfViewerFragment 的界面。这样一来,您就可以根据应用的设计来调整滚动条和页面指示器等元素的外观。

可自定义的属性包括:

  • fastScrollVerticalThumbDrawable - 设置滚动条滑块的可绘制对象。
  • fastScrollPageIndicatorBackgroundDrawable - 为页面指示器设置背景可绘制对象。
  • fastScrollPageIndicatorMarginEnd - 设置页面指示器的右边距。确保边际值是正数。
  • fastScrollVerticalThumbMarginEnd - 设置垂直滚动条滑块的右边距。确保边际值是正数。

如需应用这些自定义设置,请在 XML 资源中定义自定义样式。

<resources>
    <style name="pdfContainerStyle">
        <item name="fastScrollVerticalThumbDrawable">@drawable/custom_thumb_drawable</item>
        <item name="fastScrollPageIndicatorBackgroundDrawable">@drawable/custom_page_indicator_background</item>
        <item name="fastScrollVerticalThumbMarginEnd">8dp</item>
    </style>
</resources>

然后,在创建具有 PdfViewerFragment.newInstance(stylingOptions) 的 fragment 实例时,使用 PdfStylingOptions 将自定义样式资源提供给 PdfViewerFragment

private fun initializePdfViewerFragment() {
    // This condition can be skipped if you want to create a new fragment every time.
    if (pdfViewerFragment == null) {
      val fragmentManager: FragmentManager = supportFragmentManager

      // Create styling options.
      val stylingOptions = PdfStylingOptions(R.style.pdfContainerStyle)

      // Fragment initialization.
      pdfViewerFragment = PdfViewerFragment.newInstance(stylingOptions)

      // Execute fragment transaction.
    }
}

如果您已对 PdfViewerFragment 进行子类化,请使用受保护的构造函数来提供样式选项。这样可确保您的自定义样式正确应用于扩展 fragment。

class StyledPdfViewerFragment: PdfViewerFragment {

    constructor() : super()

    private constructor(pdfStylingOptions: PdfStylingOptions) : super(pdfStylingOptions)

    companion object {
        fun newInstance(): StyledPdfViewerFragment {
            val stylingOptions = PdfStylingOptions(R.style.pdfContainerStyle)
            return StyledPdfViewerFragment(stylingOptions)
        }
    }
}

完成实现

以下代码提供了一个完整示例,展示了如何在 activity 中实现 PdfViewerFragment,包括初始化、文件选择器集成、搜索功能和界面自定义。

class MainActivity : AppCompatActivity() {

    private var pdfViewerFragment: PdfViewerFragment? = null
    private var filePicker: ActivityResultLauncher<String> =
        registerForActivityResult(GetContent()) { uri: Uri? ->
            uri?.let {
                initializePdfViewerFragment()
                pdfViewerFragment?.documentUri = uri
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (pdfViewerFragment == null) {
            pdfViewerFragment =
                supportFragmentManager
                   .findFragmentByTag(PDF_VIEWER_FRAGMENT_TAG) as PdfViewerFragment?
        }

        val getContentButton: MaterialButton = findViewById(R.id.launch_button)
        val searchButton: MaterialButton = findViewById(R.id.search_button)

        getContentButton.setOnClickListener { filePicker.launch(MIME_TYPE_PDF) }
        searchButton.setOnClickListener {
            pdfViewerFragment?.isTextSearchActive = pdfViewerFragment?.isTextSearchActive == false
        }
    }

    private fun initializePdfViewerFragment() {
        // This condition can be skipped if you want to create a new fragment every time.
        if (pdfViewerFragment == null) {
            val fragmentManager: FragmentManager = supportFragmentManager

          // Create styling options.
          // val stylingOptions = PdfStylingOptions(R.style.pdfContainerStyle)

          // Fragment initialization.
          // For customization:
          // pdfViewerFragment = PdfViewerFragment.newInstance(stylingOptions)
          pdfViewerFragment = PdfViewerFragmentExtended()
          val transaction: FragmentTransaction = fragmentManager.beginTransaction()

          // Replace an existing fragment in a container with an instance of a new fragment.
          transaction.replace(
              R.id.fragment_container_view,
              pdfViewerFragment!!,
              PDF_VIEWER_FRAGMENT_TAG
          )
          transaction.commitAllowingStateLoss()
          fragmentManager.executePendingTransactions()
        }
    }

    companion object {
        private const val MIME_TYPE_PDF = "application/pdf"
        private const val PDF_VIEWER_FRAGMENT_TAG = "pdf_viewer_fragment_tag"
    }
}

代码要点

  • 确保您的项目满足最低 API 级别和 SDK 扩展要求。
  • 托管 PdfViewerFragment 的 activity 必须扩展 AppCompatActivity
  • 您可以扩展 PdfViewerFragment 以添加自定义行为。
  • 通过替换 XML 属性来自定义 PdfViewerFragment 的界面。