• Skip to primary navigation
  • Skip to main content
  • Skip to primary sidebar

陈文管的博客

分享有价值的内容

  • Android
  • Affiliate
  • SEO
  • 前后端
  • 网站建设
  • 自动化
  • 开发资源
  • 关于

Android PDF文件浏览及目录显示交互实现

2025年3月3日发布 | 最近更新于 2025年10月11日

项目上要实现一个PDF手册文件的阅读器,除了加载PDF文件之外,还需要在页面左侧显示PDF文件的目录信息,用户可以点击切换PDF页面,滑动PDF页面的时候左侧目录页需要做同步选中处理,并且在页面右侧显示自定义滚动条,指示当前页面滑动进度。

以下是最终实现效果示例截图:

一、PDF加载开源项目

从Github的仓库来看有三个比较常用的PDF文件加载开源实现

  • https://github.com/DImuthuUpe/AndroidPdfViewer
  • https://github.com/JoanZapata/android-pdfview
  • https://github.com/TomRoush/PdfBox-Android

综合考虑评估使用AndroidPdfViewer,满足目前项目上的要求,使用这个开源项目主要的功能接口包括:列表滑动监听、页面跳转、获取PDF文件的目录信息、配置页面之间的间距、配置PDF页面显示内容撑满控件宽度。

二、AndroidPdfViewer对接实现

在build.gradle文件dependencies模块中添加开源项目的依赖

api 'com.github.barteksc:android-pdf-viewer:3.2.0-beta.1'

主页面布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/background">
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_catalog"
        android:layout_width="438dp"
        android:layout_height="match_parent"
        android:layout_marginLeft="48dp"
        android:layout_marginTop="54dp" />
    <com.github.barteksc.pdfviewer.PDFView
        android:id="@+id/pdfView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginLeft="50dp"
        android:layout_marginTop="48dp"
        android:layout_marginRight="140dp"
        android:layout_toRightOf="@+id/rv_catalog"
        android:background="@color/transparent" />
    <VerticalScrollbar
        android:id="@+id/verticalScrollbar"
        android:layout_width="4dp"
        android:layout_height="912dp"
        android:layout_alignParentRight="true"
        android:layout_alignParentBottom="true"
        android:layout_marginTop="48dp"
        android:layout_marginRight="16dp"
        android:layout_marginBottom="32dp" />
</RelativeLayout>

rv_catalog用于在左侧显示PDF目录信息,verticalScrollbar用于在右侧显示PDF文件的滚动条指示当前页面滑动进度。

1. 二级目录列表实现

目录列表的BookmarkRecyclerAdapter类实现如下:

import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.PathInterpolator
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.entity.ManualEntity
import com.shockwave.pdfium.PdfDocument

class BookmarkRecyclerAdapter(private val context: Context) :
    RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    private val ITEM_TYPE_PARENT = 0
    private val ITEM_TYPE_CHILD = 1
    private val mHandler: Handler = Handler(Looper.getMainLooper())

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    var onBookmarkClickListener: (PdfDocument.Bookmark) -> Unit? = {}

    // 将数据转换为一个包含父目录和子目录的列表
    val itemList = mutableListOf<ManualEntity.BookmarkItem>()
    private var lastClickTimeStamp: Long = System.currentTimeMillis()

    override fun getItemViewType(position: Int): Int {
        return when (itemList[position]) {
            is ManualEntity.BookmarkItem.Parent -> ITEM_TYPE_PARENT
            is ManualEntity.BookmarkItem.Child -> ITEM_TYPE_CHILD
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            ITEM_TYPE_PARENT -> ParentViewHolder(
                inflater.inflate(R.layout.item_pdf_parent_bookmark, parent, false)
            )

            ITEM_TYPE_CHILD -> ChildViewHolder(
                inflater.inflate(R.layout.item_pdf_child_bookmark, parent, false)
            )

            else -> throw IllegalArgumentException("Invalid view type")
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (val item = itemList[position]) {
            is ManualEntity.BookmarkItem.Parent -> bindParentView(
                holder as ParentViewHolder,
                item,
                position
            )

            is ManualEntity.BookmarkItem.Child -> bindChildView(holder as ChildViewHolder, item)
        }
    }

    override fun getItemCount(): Int = itemList.size

    // 绑定父目录视图
    private fun bindParentView(
        holder: ParentViewHolder,
        item: ManualEntity.BookmarkItem.Parent,
        position: Int
    ) {
        val titleTextView: TextView = holder.itemView.findViewById(R.id.catalog_title)
        val arrowImageView: ImageView = holder.itemView.findViewById(R.id.catalog_expand)
        val parentContainer: RelativeLayout = holder.itemView.findViewById(R.id.parent_container)

        titleTextView.text = item.bookmark.title
        titleTextView.isSelected = item.isSelected

        arrowImageView.isSelected = item.isSelected
        // 设置箭头图标和动画
        arrowImageView.rotation = if (item.isExpand) 90f else 0f
        // 设置箭头图标显示与否
        arrowImageView.visibility = if (item.bookmark.hasChildren()) View.VISIBLE else View.GONE

        // 设置箭头点击事件,只控制展开/折叠
        titleTextView.setOnClickListener {
            onExpandCollapseClick(item, position, arrowImageView)
        }
        arrowImageView.setOnClickListener {
            onExpandCollapseClick(item, position, arrowImageView)
        }

        // 设置父目录点击事件,更新选中状态
        holder.itemView.setOnClickListener {
            onSelectItem(item, position)
        }
        // Set parent background if it's selected
        parentContainer.setBackgroundColor(
            if (item.isSelected) context.getColor(R.color.color_catelog_selected) else context.getColor(
                R.color.color_catelog_unselected
            )
        )
    }

    private fun onSelectItem(item: ManualEntity.BookmarkItem.Parent, position: Int) {
        // 重置其他父项的选中状态
        clearOtherParentSelections(item)
        clearOtherChildSelections(null)
        // 设置当前父目录为选中状态
        item.isSelected = true
        notifyItemChanged(position)
        onBookmarkClick(item.bookmark)
    }

    private fun onExpandCollapseClick(item: ManualEntity.BookmarkItem.Parent, position: Int, arrowImageView: ImageView) {
        if (System.currentTimeMillis() - lastClickTimeStamp < 500) {
            return
        }
        lastClickTimeStamp = System.currentTimeMillis()
        // 执行旋转动画
        if (item.isExpand) {
            // 折叠时旋转回0度
            arrowImageView.animate()
                .rotation(0f)
                .setDuration(150)
                .setInterpolator(PathInterpolator(0.4f, 0f, 0.2f, 1f))
                .start()
            collapseGroup(position)
        } else {
            // 展开时旋转到90度
            arrowImageView.animate()
                .rotation(90f)
                .setDuration(150)
                .setInterpolator(PathInterpolator(0.4f, 0f, 0.2f, 1f))
                .start()
            expandGroup(position)
        }

        onSelectItem(item, position)
        mHandler?.postDelayed({ notifyDataSetChanged() }, 500)
    }

    // 绑定子目录视图
    private fun bindChildView(holder: ChildViewHolder, item: ManualEntity.BookmarkItem.Child) {
        val titleTextView: TextView = holder.itemView.findViewById(R.id.catalog_title)

        titleTextView.text = item.bookmark.title

        // 根据 isSelected 来处理加粗状态
        titleTextView.setTextColor(
            if (item.isSelected) context.getColor(R.color.text_color) else context.getColor(R.color.text_color_40)
        )

        holder.itemView.setOnClickListener {
            // 设置当前子目录为选中状态
            item.isSelected = true
            notifyItemChanged(itemList.indexOf(item)) // 仅刷新当前子项
            // 重置其他父项的选中状态
            itemList.forEach {
                if (it is ManualEntity.BookmarkItem.Parent) {
                    if (it.bookmark == item.parentMark) {
                        clearOtherParentSelections(it)
                    }
                }
            }

            // 重置其他子项的选中状态
            clearOtherChildSelections(item)

            // 回调点击事件
            onBookmarkClick(item.bookmark)
        }
    }

    // 处理展开/折叠父目录
    private fun expandGroup(position: Int) {
        if (position < 0 || position >= itemList.size) {
            return
        }
        if (itemList[position] is ManualEntity.BookmarkItem.Parent) {
            val parent = (itemList[position] as ManualEntity.BookmarkItem.Parent).bookmark
            val childItems = parent.children.map { ManualEntity.BookmarkItem.Child(it, parent) }
            val newList = itemList.toMutableList()
            newList.addAll(position + 1, childItems)
            (newList[position] as ManualEntity.BookmarkItem.Parent).isExpand = true
            updateList(newList)
        }
    }

    private fun collapseGroup(position: Int) {
        if (position < 0 || position >= itemList.size) {
            return
        }
        if (itemList[position] is ManualEntity.BookmarkItem.Parent) {
            val parent = (itemList[position] as ManualEntity.BookmarkItem.Parent).bookmark
            val newList = itemList.toMutableList()
            val childrenToRemove = parent.children.size
            for (i in 0 until childrenToRemove) {
                if (position + 1 < newList.size) {
                    newList.removeAt(position + 1)
                }
            }
            (newList[position] as ManualEntity.BookmarkItem.Parent).isExpand = false
            updateList(newList)
        }
    }

    class BookmarkDiffUtil(
        private val oldList: List<ManualEntity.BookmarkItem>,
        private val newList: List<ManualEntity.BookmarkItem>
    ) : DiffUtil.Callback() {

        override fun getOldListSize(): Int = oldList.size

        override fun getNewListSize(): Int = newList.size

        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return oldList[oldItemPosition] == newList[newItemPosition]
        }

        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return oldList[oldItemPosition] == newList[newItemPosition]
        }
    }

    // 在 Adapter 中使用 DiffUtil
    private fun updateList(newList: List<ManualEntity.BookmarkItem>) {
        val diffResult = DiffUtil.calculateDiff(BookmarkDiffUtil(itemList, newList))
        itemList.clear()
        itemList.addAll(newList)
        diffResult.dispatchUpdatesTo(this)
    }

    override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
        mHandler?.removeCallbacksAndMessages(null)
        super.onDetachedFromRecyclerView(recyclerView)
    }

    // 重置其他父项的选中状态
    fun clearOtherParentSelections(selectedItem: ManualEntity.BookmarkItem.Parent?) {
        // 遍历 itemList,找到所有父目录项,并设置未选中
        for (i in itemList.indices) {
            if (itemList[i] is ManualEntity.BookmarkItem.Parent && itemList[i] != selectedItem) {
                var childItem = itemList[i] as ManualEntity.BookmarkItem.Parent
                if (childItem.isSelected) {
                    childItem.isSelected = false
                    notifyItemChanged(i) // 只刷新其他子项
                }
            }
        }
    }

    // 重置其他子项的选中状态
    fun clearOtherChildSelections(selectedItem: ManualEntity.BookmarkItem.Child?) {
        for (i in itemList.indices) {
            if (itemList[i] is ManualEntity.BookmarkItem.Child && itemList[i] != selectedItem) {
                var childItem = itemList[i] as ManualEntity.BookmarkItem.Child
                if (childItem.isSelected) {
                    childItem.isSelected = false
                    notifyItemChanged(i) // 只刷新其他子项
                }
            }
        }
    }

    // 点击事件回调
    private fun onBookmarkClick(bookmark: PdfDocument.Bookmark) {
        // Call the listener callback
        onBookmarkClickListener(bookmark)
    }

    // ViewHolder for Parent
    class ParentViewHolder(view: View) : RecyclerView.ViewHolder(view)

    // ViewHolder for Child
    class ChildViewHolder(view: View) : RecyclerView.ViewHolder(view)
}

item_pdf_parent_bookmark.xml 父目录的布局如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <RelativeLayout
        android:id="@+id/parent_container"
        android:layout_width="match_parent"
        android:layout_height="96dp"
        android:orientation="horizontal">

        <ImageView
            android:id="@+id/catalog_expand"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_centerVertical="true"
            android:layout_marginLeft="28dp"
            android:rotation="0"
            android:src="@drawable/selector_catalog_arrow" />

        <TextView
            android:id="@+id/catalog_title"
            style="@style/XText.Body"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_marginLeft="12dp"
            android:layout_marginRight="12dp"
            android:layout_toRightOf="@+id/catalog_expand"
            android:ellipsize="end"
            android:paddingLeft="0dp"
            android:gravity="left"
            android:paddingRight="0dp"
            android:singleLine="true"
            android:textColor="@drawable/selector_catalog_text_color"
            android:textSize="28dp" />
    </RelativeLayout>

    <!-- Child RecyclerView to display sub-items -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/childRV"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:visibility="gone" />
</LinearLayout>

item_pdf_child_bookmark.xml 子目录的布局如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/child_container"
    android:layout_width="match_parent"
    android:layout_height="96dp"
    android:gravity="center_vertical">

    <TextView
        android:id="@+id/catalog_title"
        style="@style/XText.Body"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="left"
        android:layout_marginLeft="78dp"
        android:gravity="left"
        android:layout_marginRight="12dp"
        android:ellipsize="end"
        android:paddingLeft="0dp"
        android:paddingRight="0dp"
        android:singleLine="true"
        android:textColor="@color/text_color_40"
        android:textSize="28dp"
        tools:text="3.2测试标题测试标题测试标题测试标题测试标题" />
</RelativeLayout>

ManualEntity 类中的BookmarkItem类的实现如下:

sealed class BookmarkItem {    
    var isSelected: Boolean = false    
    data class Parent(val bookmark: PdfDocument.Bookmark, var isExpand: Boolean = false) : BookmarkItem()    
    data class Child(val bookmark: PdfDocument.Bookmark, val parentMark: PdfDocument.Bookmark) : BookmarkItem()
}

二级目录列表页可以使用Android原生的ExpandableListView,这边使用RecyclerView来实现,这边需要注意的是在做展开/折叠父目录操作的时候,notifyItemRangeInserted、notifyItemRangeRemoved和notifyItemChanged都是为了最小范围更新列表,保证RecyclerView呈现展开和折叠列表的动画。

而mHandler?.postDelayed({notifyDataSetChanged()}, 500) 的调用是为了刷新整个列表,保证在展开/折叠操作列表之后,每一个列表项的position位置都更新到最新。

2. PDF文件的初始化加载

加载PDF文件的初始化代码实现如下,这边使用的方式是在应用内assets目录下集成一个初始的PDF文件,在应用启动的时候先检测SD卡指定目录下是否存在PDF文件,没有就先解压到对应的目录去加载:

private fun initPdf() {
        mPdfView?.fromFile(File(PdfFileManager.FLYING_MANUAL_PDF_PATH))
            ?.enableSwipe(true) // allows to block changing pages using swipe
            ?.swipeHorizontal(false)
            ?.enableDoubletap(true)
            ?.defaultPage(0)
            ?.onLoad({ nbPages ->
                LogUtils.i(TAG, "initPdf onLoad nbPages:$nbPages")
                onPdfLoaded(nbPages)
            }) // called after document is loaded and starts to be rendered
            ?.onPageChange({ page, pageCount ->
                // 保证在点击目录列表切换页面的时候,不和PDF页面滑动冲突
                if (mIsJumpToPage) {
                    mIsJumpToPage = false
                    LogUtils.i(TAG, "mPdfView onPageChange mIsJumpToPage return")
                    return@onPageChange
                }
                onPdfPageChange(page)
            })
            ?.onPageScroll({ page, positionOffset ->
                mScrollBar?.updatePosition(positionOffset)
            })
            ?.onError({ throwable ->
                LogUtils.e(TAG, " mPdfView onError:${throwable.message}")
            })
            ?.onPageError({ page, throwable ->
                LogUtils.e(TAG, " mPdfView onPageError page:$page, error:${throwable.message}")
            })
            ?.enableAnnotationRendering(false) // render annotations (such as comments, colors or forms)
            ?.password(null)
            ?.scrollHandle(null)
            ?.enableAntialiasing(true) // improve rendering a little bit on low-res screens
            ?.spacing(0)
            ?.pageFitPolicy(FitPolicy.WIDTH)
            ?.fitEachPage(true)
            ?.load();
    }

3. 目录列表的初始化

private fun initAdapter() {
    val llm = LinearLayoutManager(this)
    llm.orientation = LinearLayoutManager.VERTICAL
    mRecyclerView?.layoutManager = llm
    mRecyclerView?.addItemDecoration(CatalogItemDecoration(this));
    mBookmarkAdapter = BookmarkRecyclerAdapter(this)
    mRecyclerView?.adapter = mBookmarkAdapter
    mBookmarkAdapter?.onBookmarkClickListener = { bookmark ->
        val pageIndex = bookmark.pageIdx.toInt()
        LogUtils.i(TAG, "mBookmarkAdapter onBookmarkClickListener pageIndex:$pageIndex")
        mIsJumpToPage = true
        mPdfView?.jumpTo(pageIndex)
    }
}

CatalogItemDecoration列表间距配置实现如下:

class CatalogItemDecoration(context: Context) : ItemDecoration() {
    private val parentMargin = dpToPx(40, context) // 父目录的间距 40dp
    private val childMargin = dpToPx(12, context)  // 子目录和父目录之间的间距 12dp
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val position = parent.getChildAdapterPosition(view)
        val adapter = parent.adapter as BookmarkRecyclerAdapter
        if (position == RecyclerView.NO_POSITION) {
            return
        }
        if(position < 0 || position >= adapter.itemList.size) {
            return
        }
        when (adapter.itemList[position]) {
            is ManualEntity.BookmarkItem.Parent -> {
                // 在父目录下方添加子目录时,子目录之间的间距为12d
                if (position < parent.adapter?.itemCount?.minus(1) ?: 0) {
                    val nextItem = (parent.adapter as BookmarkRecyclerAdapter).itemList[position + 1]
                    if (nextItem is ManualEntity.BookmarkItem.Child) {
                        outRect.bottom = 0
                    } else {
                        outRect.bottom = parentMargin
                    }
                } else {
                    outRect.bottom = parentMargin
                }
            }
            is ManualEntity.BookmarkItem.Child -> {
                // 子目录和父目录之间的间距 12dp
                if (position > 0) {
                    val preItem = (parent.adapter as BookmarkRecyclerAdapter).itemList[position - 1]
                    // 如果下一个是子目录,保持 12dp 间距
                    if (preItem is ManualEntity.BookmarkItem.Parent) {
                        outRect.top = childMargin
                    } else {
                        outRect.top = 0
                    }
                }
                if (position < parent.adapter?.itemCount?.minus(1) ?: 0) {
                    val nextItem = (parent.adapter as BookmarkRecyclerAdapter).itemList[position + 1]
                    if (nextItem is ManualEntity.BookmarkItem.Parent) {
                        outRect.bottom = parentMargin
                    } else {
                        outRect.bottom = 0
                    }
                }
                // 如果是最后一个子目录,底部间距为 40dp
                if (position == parent.adapter?.itemCount?.minus(1)) {
                    outRect.bottom = parentMargin
                }
            }
        }
    }
    // 将 dp 转换为 px
    private fun dpToPx(dp: Int, context: Context): Int {
        return (dp * context.resources.displayMetrics.density).toInt()
    }
}

4. 目录信息的加载

这边只保留二级目录,不展示超过二级以上的目录信息。

private fun onPdfLoaded(totalPages: Int) {
    var catalog: MutableList<Bookmark>? = mPdfView?.tableOfContents
    if (!catalog.isNullOrEmpty()) {
        LogUtils.i(TAG, "tableOfContents size:" + catalog.size)
        catalog.forEach { childBookmark ->
            // 移除二级子目录的子目录,即三级及以上的目录
            childBookmark.children?.forEach { child ->
                child.children?.clear()
            }
        }
        for (bookmark in catalog) {
            mBookmarkAdapter?.itemList?.add(ManualEntity.BookmarkItem.Parent(bookmark))
        }
        mBookmarkAdapter?.notifyDataSetChanged()
    } else {
        LogUtils.i(TAG, "initPdf tableOfContents isNullOrEmpty")
    }
    mScrollBar?.updatePdfPage(totalPages)
}

5. PDF页面滑动目录跟随处理

在PDF页面滑动的时候,左侧目录要做跟随选中处理,类似飞书文档的目录操作。

private fun onPdfPageChange(page: Int) {
        LogUtils.i(TAG, "onPdfPageChange page:$page")
        // 遍历列表,找到匹配的 Bookmark
        val matchedItem = mBookmarkAdapter?.itemList?.firstOrNull {
            (it is ManualEntity.BookmarkItem.Parent && it.bookmark.pageIdx == page.toLong()) ||
                    (it is ManualEntity.BookmarkItem.Child && it.bookmark.pageIdx == page.toLong())
        }
        matchedItem?.let { item ->
            // 标记当前匹配的 Bookmark 为选中状态
            if (item is ManualEntity.BookmarkItem.Parent) {
                // 如果匹配的是父目录,首先检查已选中的子目录
                mBookmarkAdapter?.itemList?.forEach { childItem ->
                    if (childItem is ManualEntity.BookmarkItem.Child && childItem.isSelected) {
                        // 如果子目录已选中,但它的父目录不是当前匹配的父目录,则取消选中
                        if (childItem.parentMark != item.bookmark) {
                            childItem.isSelected = false
                            val childPosition = mBookmarkAdapter?.itemList?.indexOf(childItem) ?: return@forEach
                            mBookmarkAdapter?.notifyItemChanged(childPosition)
                        }
                    }
                }
                item.isSelected = true
                mBookmarkAdapter?.clearOtherParentSelections(item)
            } else if (item is ManualEntity.BookmarkItem.Child) {
                // 如果匹配的是子目录,首先检查已选中的父目录
                mBookmarkAdapter?.itemList?.forEach { parentItem ->
                    if (parentItem is ManualEntity.BookmarkItem.Parent && parentItem.isSelected) {
                        // 已选中子目录的父目录不是当前选中的父目录,则取消选中
                        if (parentItem.bookmark != item.parentMark) {
                            parentItem.isSelected = false
                            val parentPosition = mBookmarkAdapter?.itemList?.indexOf(parentItem) ?: return@forEach
                            mBookmarkAdapter?.notifyItemChanged(parentPosition)
                        }
                    }
                }
                item.isSelected = true
                mBookmarkAdapter?.clearOtherChildSelections(item)
            }
            // 通知刷新
            val position = mBookmarkAdapter?.itemList?.indexOf(item) ?: return
            mBookmarkAdapter?.notifyItemChanged(position)
            // 判断是否需要滚动到该项
            val layoutManager = mRecyclerView?.layoutManager as LinearLayoutManager
            val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
            val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
            if (position < firstVisiblePosition || position > lastVisiblePosition) {
                mRecyclerView?.scrollToPosition(position)
            }
        } ?: run {
            // 如果没有找到匹配项,可以记录日志,或者执行其他逻辑
            LogUtils.i(TAG, "No matching bookmark found for page: $page")
        }
    }

6. 自定义PDF页面滚动条控件

滚动条的宽度固定,高度根据PDF页面的总高度来计算一个页面的百分比,而 PDFView 控件 onPageScroll 回调方法的 positionOffset 刚好是当前页面滑动位置相对总页面的百分比。

class VerticalScrollbar(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    companion object {
        private const val TAG = "VerticalScrollbar"
    }
    private var scrollHeight = 0f
    private var scrollPosition = 0f
    private val handler = Handler(Looper.getMainLooper()) // To handle delayed actions
    private val paint = Paint()
    private var mTotalPages = 0
    // Update the scrollbar size
    fun updatePdfPage(totalPages: Int) {
        if(totalPages <= 0) {
            visibility = GONE // Hide after 2 seconds
            LogUtils.i(TAG, "updatePdfPage totalPages <= 0 gone")
            return
        }
        visibility = VISIBLE // Hide after 2 seconds
        this.mTotalPages = totalPages
        this.scrollHeight = measuredHeight * (1f / totalPages)
        paint.color = context.resources.getColor(R.color.color_scrollbar)
        invalidate()
    }
    // Update the scrollbar position
    fun updatePosition(position: Float) {
        if(mTotalPages <= 0) {
            return
        }
        this.scrollPosition = position * measuredHeight
        // Ensure the scrollbar doesn't get cut off at the bottom
        if (scrollPosition + scrollHeight > measuredHeight) {
            scrollPosition = height - scrollHeight // Position it at the bottom if it exceeds
        }
        // Show scrollbar and delay hiding for 2 seconds
        visibility = VISIBLE
        invalidate()
        handler.removeCallbacksAndMessages(null)  // Clear any pending hide actions
        handler.postDelayed({
            visibility = GONE // Hide after 2 seconds
        }, 2000)
    }
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas?.let {
            // Draw the scrollbar as a filled rectangle
            it.drawRect(0f, scrollPosition, measuredWidth.toFloat(), scrollPosition + scrollHeight, paint)
        }
    }
    override fun onDetachedFromWindow() {
        handler.removeCallbacksAndMessages(null)
        super.onDetachedFromWindow()
    }
}

这边需要注意的是,要获取目录信息,必须原始的PDF文件就具备目录信息,如果原始的PDF文件不含有目录信息则 mPdfView?.tableOfContents 获取的信息就是空的。

扩展阅读:

  • Android 航线剖面图自定义控件绘制实现
  • Android 自定义棱形样式进度条控件
  • Android 弧形 RecyclerView 实现(Kotlin)
  • 美图手机音乐Widget动画实现
  • Android 心率动画自定义控件实现
  • Android 卡片旋转切换动效实现详解
  • Android 残影数字动画实现详解
  • Android 自定义菱形横向滑动指示器控件
  • Android 航线缩略图简易绘制实现
博客公众号

微信公众号

转载请注明出处:陈文管的博客 – Android PDF文件浏览及目录显示交互实现

文章目录

  • 一、PDF加载开源项目
  • 二、AndroidPdfViewer对接实现
    • 1. 二级目录列表实现
    • 2. PDF文件的初始化加载
    • 3. 目录列表的初始化
    • CatalogItemDecoration列表间距配置实现如下:
    • 4. 目录信息的加载
    • 5. PDF页面滑动目录跟随处理
    • 6. 自定义PDF页面滚动条控件
博客公众号

闽ICP备18001825号-1 · Copyright © 2025 · Powered by chenwenguan.com