项目上要实现一个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.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.flight.manual.core.common.entity.ManualEntity
import com.shockwave.pdfium.PdfDocument
import com.xiaopeng.lib.utils.LogUtils
import com.xiaopeng.xui.widget.XRelativeLayout
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: XRelativeLayout = holder.itemView.findViewById(R.id.parent_container)
titleTextView.text = item.bookmark.title
// 设置箭头图标和动画
arrowImageView.rotation = if (item.isExpand) 90f else 0f
// 设置箭头点击事件,只控制展开/折叠
arrowImageView.setOnClickListener {
if (System.currentTimeMillis() - lastClickTimeStamp < 500) {
LogUtils.e(MainActivity.TAG, "bindParentView arrowImageView frequent click, return")
return@setOnClickListener
}
lastClickTimeStamp = System.currentTimeMillis()
if (item.isExpand) {
collapseGroup(position)
} else {
expandGroup(position)
}
}
// 设置父目录点击事件,更新选中状态
holder.itemView.setOnClickListener {
// 重置其他父项的选中状态
clearOtherParentSelections(item)
clearOtherChildSelections(null)
// 设置当前父目录为选中状态
item.isSelected = true
notifyItemChanged(position) // 只刷新当前父目录
onBookmarkClick(item.bookmark)
}
// 设置箭头图标显示与否
arrowImageView.visibility = if (item.bookmark.hasChildren()) View.VISIBLE else View.GONE
// Set parent background if it's selected
parentContainer.setBackgroundResource(
if (item.isSelected) R.mipmap.ic_parent_catelog_selected else 0
)
}
// 绑定子目录视图
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) {
LogUtils.e(MainActivity.TAG, "expandGroup return:${position}, itemList.size:${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) }
itemList.addAll(position + 1, childItems)
(itemList[position] as ManualEntity.BookmarkItem.Parent).isExpand = true
notifyItemRangeInserted(position + 1, parent.children.size) // 最小化刷新,仅插入子项
// 更新当前父目录的箭头状态
notifyItemChanged(position)
mHandler?.postDelayed({notifyDataSetChanged()}, 500)
}
}
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 childrenToRemove = parent.children.size
for (i in 0 until childrenToRemove) {
if(position + 1 < itemList.size) {
itemList.removeAt(position + 1)
}
}
(itemList[position] as ManualEntity.BookmarkItem.Parent).isExpand = false
notifyItemRangeRemoved(position + 1, childrenToRemove) // 最小化刷新,仅移除子项
// 更新当前父目录的箭头状态
notifyItemChanged(position)
mHandler?.postDelayed({notifyDataSetChanged()}, 500)
}
}
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"?>
<com.xiaopeng.xui.widget.XLinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:orientation="vertical"
android:layout_height="wrap_content">
<com.xiaopeng.xui.widget.XRelativeLayout
android:id="@+id/parent_container"
android:layout_width="match_parent"
android:layout_height="96dp"
android:orientation="horizontal">
<com.xiaopeng.xui.widget.XImageView
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="@mipmap/ic_expand_more" />
<com.xiaopeng.xui.widget.XTextView
android:id="@+id/catalog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_toRightOf="@+id/catalog_expand"
android:ellipsize="end"
android:singleLine="true"
android:textColor="@color/text_color"
android:textSize="28dp" />
</com.xiaopeng.xui.widget.XRelativeLayout>
<!-- 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" />
</com.xiaopeng.xui.widget.XLinearLayout>
item_pdf_child_bookmark.xml 子目录的布局如下:
<?xml version="1.0" encoding="utf-8"?>
<com.xiaopeng.xui.widget.XRelativeLayout 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="66dp"
android:gravity="center_vertical">
<com.xiaopeng.xui.widget.XTextView
android:id="@+id/catalog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:layout_marginLeft="78dp"
android:layout_marginRight="16dp"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/text_color_40"
android:textSize="28dp"
tools:text="3.2测试标题测试标题测试标题测试标题测试标题"/>
</com.xiaopeng.xui.widget.XRelativeLayout>
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 -> {
// 在父目录下方添加子目录时,子目录之间的间距为12dp
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 自定义棱形样式进度条控件
- Android 弧形 RecyclerView 实现(Kotlin)
- 美图手机音乐Widget动画实现
- Android 心率动画自定义控件实现
- Android 卡片旋转切换动效实现详解
- Android 残影数字动画实现详解
- Android 自定义菱形横向滑动指示器控件
微信公众号
转载请注明出处:陈文管的博客 – Android PDF文件浏览及目录显示交互实现
发表回复