文章目录
一 ViewPager2介绍
ViewPager2(以下简称VP2)
是 ViewPager(以下简称VP)
库的改进版本,内部使用RecyclerView
实现,可以把VP2
理解为每个ItemView
都充满全屏的RecyclerView
,VP2
可提供增强型功能并解决使用 VP
时遇到的一些常见问题。
1.1 ViewPager2优势
- 横向、垂直方向布局支持
设置VP2布局的android:orientation="vertical"
即可轻松完成垂直方向滑动。 - RTL(right-to-left)从右到左布局支持
设置VP2布局的android:layoutDirection="rtl"
即可。 - 一键禁止用户滑动支持
通过setUserInputEnabled()
设置是否禁止用户滑动。 - 可修改的Fragment集合
VP2
支持对可修改的Fragment
集合进行分页浏览,在底层集合发生更改时调用notifyDatasetChanged()
来更新界面。这意味着,您的应用可以在运行时动态修改Fragment
集合,而VP2
会正确显示修改后的集合。 - 支持DiffUtil
VP2
在RecyclerView
的基础上构建而成,这意味着它可以访问DiffUtil
实用程序类。所以VP2
支持当数据变化时进行局部更新,而不用通过notifyDatasetChanged()
全量更新。 - 支持模拟拖拽
fakeDragBy
二 ViewPager2使用
2.1 基于ViewPager2实现的Banner效果图
功能 | 示例 |
---|---|
基本使用 | |
仿淘宝搜索栏上下轮播 |
上述示例效果源码参见:lib_viewpager2,会在下篇中进行详细介绍。
2.2 基本使用
VP2不同于VP,需要单独引入:
dependencies {
implementation "androidx.viewpager2:viewpager2:1.0.0"
}
声明XML布局:
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager2"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="2:3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
设置Adapter:
因为VP2
内部是RecyclerView
实现的,所以简单的界面直接继承RecyclerView.Adapter
:
class VpAdapter : RecyclerView.Adapter<VpAdapter.VpViewHolder>() {
// adapter的数据源
private var data: MutableList<HouseItem> = mutableListOf()
fun setData(list: MutableList<HouseItem>) {
data.clear()
data.addAll(list)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VpViewHolder {
//......
}
override fun onBindViewHolder(holder: VpViewHolder, position: Int) {
}
override fun getItemCount() = data.size
class VpViewHolder(_itemView: View) : RecyclerView.ViewHolder(_itemView) {
//......
}
}
如果需要使用到Fragment
,那么需要继承FragmentStateAdapter
:
const val PAGES_NUM = 4
class ViewPager2Adapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
private val mItems: ArrayList<VP2Model> = arrayListOf()
override fun getItemCount(): Int = PAGES_NUM
override fun createFragment(position: Int): Fragment {
log("pos:$position: createFragment()")
return VP2Fragment(position)
}
override fun onBindViewHolder(
holder: FragmentViewHolder,
position: Int,
payloads: MutableList<Any>
) {
super.onBindViewHolder(holder, position, payloads)
log("pos:$position: onBindViewHolder()")
}
override fun getItemId(position: Int): Long {
return super.getItemId(position)
}
override fun containsItem(itemId: Long): Boolean {
return super.containsItem(itemId)
}
fun setModels(newItems: List<VP2Model>) {
//不借助DiffUtil更新数据
//mItems.clear()
//mItems.addAll(newItems)
//notifyDataSetChanged()
//借助DiffUtil更新数据
val callback = PageDiffUtil(mItems, newItems)
val difResult = DiffUtil.calculateDiff(callback)
mItems.clear()
mItems.addAll(newItems)
difResult.dispatchUpdatesTo(this)
}
}
在Activity/Fragment中调用:
//mVP2Adapter = VpAdapter() //RecyclerView.Adapter
mVP2Adapter = ViewPager2Adapter(this) //FragmentStateAdapter
VP2.adapter = mVP2Adapter
2.3 进阶使用
2.3.1 Fragment懒加载
2.3.2 一屏多页
设置一屏多页的关键代码如下:
VP2.apply {
//下面是关键代码
val recyclerView = getChildAt(0) as RecyclerView
recyclerView.apply {
val padding = 50
// setting padding on inner RecyclerView puts overscroll effect in the right place
setPadding(padding, 0, padding, 0)
clipToPadding = false
}
adapter = Adapter()
}
在VP2
源码内部第254行,RecyclerView
固定索引为0:
attachViewToParent(mRecyclerView, 0, mRecyclerView.getLayoutParams());
所以可以通过VP2.getChildAt(0)
直接获取VP2
内部的RecyclerView
,进而通过设置padding
来实现一屏多页,运行效果如下:
2.3.3 ViewPager2嵌套滑动冲突
因为VP2
内部是通过RecyclerView
实现的,所以滑动相关处理主要在RecyclerView
中进行,其内部实现:
private class RecyclerViewImpl extends RecyclerView {
RecyclerViewImpl(@NonNull Context context) {
super(context);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return isUserInputEnabled() && super.onInterceptTouchEvent(ev);
}
}
onInterceptTouchEvent
中会进行事件拦截,可以看到源码中的onInterceptTouchEvent
只是多了isUserInputEnabled
的判断,其他的都没有处理,
所以官方并没有对VP2
的嵌套滑动进行处理,需要开发者进行自行处理,这里可以通过事件传递中的内部拦截法(requestDisallowInterceptTouchEvent()
)
进行处理,如果嵌套滑动中的内部控件需要滑动时,就控制外部父控件不拦截事件,设置为requestDisallowInterceptTouchEvent(true)
;反之则让外部父控件拦截事件,设置为requestDisallowInterceptTouchEvent(false)
。官方Demo
中也给出了对应例子:NestedScrollableHost:
class NestedScrollableHost : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
private var touchSlop = 0
private var initialX = 0f
private var initialY = 0f
private val parentViewPager: ViewPager2?
get() {
var v: View? = parent as? View
while (v != null && v !is ViewPager2) {
v = v.parent as? View
}
return v as? ViewPager2
}
private val child: View? get() = if (childCount > 0) getChildAt(0) else null
init {
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}
private fun canChildScroll(orientation: Int, delta: Float): Boolean {
val direction = -delta.sign.toInt()
return when (orientation) {
0 -> child?.canScrollHorizontally(direction) ?: false
1 -> child?.canScrollVertically(direction) ?: false
else -> throw IllegalArgumentException()
}
}
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
handleInterceptTouchEvent(e)
return super.onInterceptTouchEvent(e)
}
private fun handleInterceptTouchEvent(e: MotionEvent) {
val orientation = parentViewPager?.orientation ?: return
// Early return if child can't scroll in same direction as parent
if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
return
}
if (e.action == MotionEvent.ACTION_DOWN) {
initialX = e.x
initialY = e.y
parent.requestDisallowInterceptTouchEvent(true)
} else if (e.action == MotionEvent.ACTION_MOVE) {
val dx = e.x - initialX
val dy = e.y - initialY
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
// assuming ViewPager2 touch-slop is 2x touch-slop of child
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
if (scaledDx > touchSlop || scaledDy > touchSlop) {
if (isVpHorizontal == (scaledDy > scaledDx)) {
// Gesture is perpendicular, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
} else {
// Gesture is parallel, query child if movement in that direction is possible
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
// Child can scroll, disallow all parents to intercept
parent.requestDisallowInterceptTouchEvent(true)
} else {
// Child cannot scroll, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
}
}
}
2.3.4 支持DiffUtil增量更新
class PageDiffUtil(private val oldModels: List<Any>, private val newModels: List<Any>) :
DiffUtil.Callback() {
/**
* 旧数据
*/
override fun getOldListSize(): Int = oldModels.size
/**
* 新数据
*/
override fun getNewListSize(): Int = newModels.size
/**
* DiffUtil调用来决定两个对象是否代表相同的Item。true表示两个Item相同(表示View可以复用),false表示不相同(View不可以复用)
* 例如,如果你的项目有唯一的id,这个方法应该检查它们的id是否相等。
*/
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldModels[oldItemPosition]::class.java == newModels[newItemPosition]::class.java
}
/**
* 比较两个Item是否有相同的内容(用于判断Item的内容是否发生了改变),
* 该方法只有当areItemsTheSame (int, int)返回true时才会被调用。
*/
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldModels[oldItemPosition] == newModels[newItemPosition]
}
/**
* 该方法执行时机:areItemsTheSame(int, int)返回true 并且 areContentsTheSame(int, int)返回false
* 该方法返回Item中的变化数据,用于只更新Item中变化数据对应的UI
*/
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
return super.getChangePayload(oldItemPosition, newItemPosition)
}
}
调用方式:
//使用DiffUtil更新数据
val callback = PageDiffUtil(mItems, newItems)
val difResult = DiffUtil.calculateDiff(callback)
mItems.clear()
mItems.addAll(newItems)
difResult.dispatchUpdatesTo(adapter)
注意:如果想异步进行数据比较,可以使用AsyncListDiffer 或者RecyclerView中的ListAdapter。
2.3.5 支持转场动画Transformer
调用方式:ViewPager2.setPageTransformer(transformer),如果同时想执行多个Transformer,可以像下面这样写:
val multiTransformer = CompositePageTransformer()
multiTransformer.addTransformer(ScaleInTransformer())
multiTransformer.addTransformer(MarginPageTransformer(10))
ViewPager2.setPageTransformer(multiTransformer)
三 源码浅析
3.1 RecyclerView缓存机制
因为VP2
内部基于RecyclerView
,所以VP2
的缓存也是基于RecyclerView
缓存机制实现的,直接来看RecyclerView
的缓存机制:
缓存 | 涉及对象 | 作用 | 重新创建视图View(onCreateViewHolder) | 重新绑定数据(onBindViewHolder) |
---|---|---|---|---|
一级缓存 | mAttachedScrap | 缓存屏幕中可见范围的ViewHolder | false | false |
二级缓存 | mCachedViews | 缓存滑动时即将与RecyclerView分离的ViewHolder,按子View的position或id缓存,默认最多存放2个 | false | false |
三级缓存 | mViewCacheExtension | 开发者自行实现的缓存 | - | - |
四级缓存 | mRecyclerPool | ViewHolder缓存池,本质上是一个SparseArray,其中key是ViewType(int类型),value存放的是 ArrayList< ViewHolder>,默认每个ArrayList中最多存放5个ViewHolder | false | true |
RecyclerView
缓存机制更详细解析参见:Android深入理解RecyclerView的缓存机制 。在VP2
中主要使用的是mCachedViews
、mRecyclerPool
:
-
mCachedViews
:缓存滑动时即将与RecyclerView
页面分离的ViewHolder
,按子View
的position
或id
缓存,默认存放2个,可以通过setItemViewCacheSize(int size)
修改缓存个数。如果RecyclerView
开启了预抓取功能(默认预抓取个数为1),则缓存池大小默认为3(mCachedViews缓存2 + 预抓取个数1 )。 -
mRecyclerPool
:ViewHolder
缓存池,本质上是一个SparseArray
,其中key
是ViewType(int类型)
,value
存放的是ArrayList< ViewHolder>
,默认每个ArrayList
中最多存放5个ViewHolder
。回收到该缓存池的ViewHolder
会将数据解绑,当复用该ViewHolder
时,需要重新绑定数据(即重新走(onBindViewHolder
)。
3.2 offscreenPageLimit离屏缓存
//ViewPager2.java
public void setOffscreenPageLimit(@OffscreenPageLimit int limit) {
if (limit < 1 && limit != OFFSCREEN_PAGE_LIMIT_DEFAULT) {
throw new IllegalArgumentException(
"Offscreen page limit must be OFFSCREEN_PAGE_LIMIT_DEFAULT or a number > 0");
}
mOffscreenPageLimit = limit;
// Trigger layout so prefetch happens through getExtraLayoutSize()
mRecyclerView.requestLayout();
}
setOffscreenPageLimit
设置的是VP2
的离屏显示个数,默认是-1,因为RecyclerView
中的布局是通过LayoutManager
,所以真正进行离屏计算是在VP2.LinearLayoutManagerImpl#calculateExtraLayoutSpace()
中,该方法计算的是LinearLayoutManager
布局的额外空间,LinearLayoutManagerImpl
继承自LinearLayoutManager
:
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
int pageLimit = getOffscreenPageLimit();
if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
// Only do custom prefetching of offscreen pages if requested
super.calculateExtraLayoutSpace(state, extraLayoutSpace);
return;
}
final int offscreenSpace = getPageSize() * pageLimit;
extraLayoutSpace[0] = offscreenSpace;
extraLayoutSpace[1] = offscreenSpace;
}
getPageSize()
表示ViewPager2
的宽度,左右离屏大小都为getPageSize() * pageLimit
。extraLayoutSpace[0]
表示左边,extraLayoutSpace[1]
表示右边。比如设置offscreenPageLimit
为1,可以认为是把PageSize
扩大到3倍。左右两边各有一个离屏PageSize
的宽度,如图所示:
3.3 FragmentStateAdapter缓存原理
FragmentStateAdapter
的使用前面已经介绍过了,因为FragmentStateAdapter
继承自RecyclerView.Adapter
,所以可以直接通过setAdapter
设置给VP2
。我们知道FragmentStateAdapter
作为Adapter
时,每个Item
都是Fragment
,那么Fragment
又是怎么跟FragmentStateAdapter
关联起来的呢?下面就尝试分析一下:
//FragmentStateAdapter.java
final LongSparseArray<Fragment> mFragments = new LongSparseArray<>();
private final LongSparseArray<Integer> mItemIdToViewHolder = new LongSparseArray<>();
public abstract @NonNull Fragment createFragment(int position);
@NonNull
@Override
public final FragmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return FragmentViewHolder.create(parent);
}
//FragmentViewHolder.java
public final class FragmentViewHolder extends ViewHolder {
private FragmentViewHolder(@NonNull FrameLayout container) {
super(container);
}
@NonNull static FragmentViewHolder create(@NonNull ViewGroup parent) {
FrameLayout container = new FrameLayout(parent.getContext());
container.setLayoutParams(
new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
//设置唯一ID
container.setId(ViewCompat.generateViewId());
container.setSaveEnabled(false);
return new FragmentViewHolder(container);
}
@NonNull FrameLayout getContainer() {
return (FrameLayout) itemView;
}
}
在onCreateViewHolder
中设置的是名为FragmentViewHolder
的ViewHolder
,内部的根布局是一个FrameLayout
,为该FrameLayout
设置一个唯一ID
,后续复用ViewHolder
及Fragment的
布局时会使用。FragmentStateAdapter
内部两个很有用的数据结构:
final LongSparseArray<Fragment> mFragments = new LongSparseArray<>();
private final LongSparseArray<Integer> mItemIdToViewHolder = new LongSparseArray<>();
-
mFragments
:是position
与Fragment
的映射表。随着position
的增长,Fragment
是会不断的新建出来的。Fragment
可以被缓存起来,当它被回收后无法重复使用。 -
mItemIdToViewHolder
:是position
与ViewHolder
的Id
的映射表。由于ViewHolder
是RecyclerView
缓存机制的载体。所以随着position
的增长,ViewHolder
并不会像Fragment
那样不断的新建出来,而是会充分利用RecyclerView
的复用机制。
当VP2
滑动时,离当前正在显示的Item
的前面最近的2个会被缓存到mCachedViews
中,超过2个时会从mCachedViews
删除,并将其转移到RecyclerPool
中,此时会调用onViewRecycled()
如下:
@Override
public final void onViewRecycled(@NonNull FragmentViewHolder holder) {
final int viewHolderId = holder.getContainer().getId();
final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
if (boundItemId != null) {
removeFragment(boundItemId);
mItemIdToViewHolder.remove(boundItemId);
}
}
当ViewHolder
回收到RecyclerPool
中时,将ViewHolder
相关的信息删除。在前面的介绍中我们知道从mCachedViews
中取ViewHolder
时并不会执行onBindViewHolder
,只有从RecyclerPool
取ViewHolder
时才会执行到onBindViewHolder
,接着看一下onBindViewHolder
:
@Override
public final void onBindViewHolder(final @NonNull FragmentViewHolder holder, int position) {
//如果mItemIdToViewHolder中跟当前ViewHolder的ID一样,那么需要将mItemIdToViewHolder中的ID进行删除,并在后面重新对该ViewHolder的ID进行赋值
final long itemId = holder.getItemId();
final int viewHolderId = holder.getContainer().getId();
final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
if (boundItemId != null && boundItemId != itemId) {
removeFragment(boundItemId);
mItemIdToViewHolder.remove(boundItemId);
}
//在这里将viewHolerId重新添加到mItemIdToViewHolder中
mItemIdToViewHolder.put(itemId, viewHolderId); // this might overwrite an existing entry
//创建Fragment并添加到mFragments中
ensureFragment(position);
final FrameLayout container = holder.getContainer();
if (ViewCompat.isAttachedToWindow(container)) {
//...其他...
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom) {
if (container.getParent() != null) {
container.removeOnLayoutChangeListener(this);
//将新来的Fragment的布局依附到ViewHolder中
placeFragmentInViewHolder(holder);
}
}
});
}
gcFragments();
}
//onBindViewHolder()中调用该方法创建Fragment
private void ensureFragment(int position) {
long itemId = getItemId(position);
if (!mFragments.containsKey(itemId)) {
//在这里创建Fragment
Fragment newFragment = createFragment(position);
newFragment.setInitialSavedState(mSavedStates.get(itemId));
mFragments.put(itemId, newFragment);
}
}
可以看到在onBindViewHolder()
中创建了Fragment
并将其添加到了mFragments
中,从而Fragment
跟FragmentStateAdapter
关联起来了。
默认当前Item
的前面2个及后面的1个(RecyclerView
默认会开启预抓取能力:isItemPrefetchEnabled
默认为true
)总共3个Fragment
会缓存在mCachedViews
中;超过2个的位置时创建的Fragment
就会被销毁,有一种特殊情况需要注意:当VP2
滑动到最后时,当前Item
前面的3个(这里不是默认的2个了)Fragment
都会被缓存,因为滑动到最后了,后面预抓取的1个给到了前面。
四 结论
以从左到右布局且当前向右滑为例:
- 当没有设置
offscreenPageLimit
离屏缓存时,VP2
中的RecyclerView
默认会缓存左侧的2个Item
(缓存在mCachedViews
)以及右侧的1个Item
。 注意一点:当第一次加载时,由于还没有触发VP2
的onTouch
操作,所以此时还不会进行右侧的预抓取。 - 如果设置了
offscreenPageLimit
为1,则左右离屏各新增一个缓存的Item
,可以直观理解系统会默认把画布宽度增加到3倍(左右这两个默认不可见),加上RecyclerView
默认缓存的3个,除了当前显示的Item
,还会缓存总共5个Item
。
五 ViewPager、ViewPager2差异对比
功能 | ViewPager | ViewPager2 |
---|---|---|
Listener | addPageChangeListener | registerOnPageChangeCallback(OnPageChangeCallback callback),其中OnPageChangeCallback是一个抽象类,不同于接口方式,抽象类里用到哪个覆写哪个即可 |
Fragment | FragmentPagerAdapter、FragmentStatePagerAdapter | FragmentStateAdapter |
setOffscreenPageLimit(int num) | 离屏缓存,当设置小于1时,会强制设为1,即强制左右各缓存1个 | OFFSCREEN_PAGE_LIMIT_DEFAULT默认为-1,及默认不会离屏缓存 |
Adapter | PagerAdapter | RecyclerView.Adapter |
其他操作 | / | 支持RTL从右到左排序、垂直滑动、停止用户操作 |
六 参考
【1】官方:使用 ViewPager2 在 Fragment 之间滑动
【2】官方:从 ViewPager 迁移到 ViewPager2
【3】ViewPager2中的Fragment懒加载实现方式
【4】聊聊ViewPager2中的缓存和复用机制