一 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 ViewPager2基本使用
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" />
因为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)
}
}
//mVP2Adapter = VpAdapter() //RecyclerView.Adapter
mVP2Adapter = ViewPager2Adapter(this) //FragmentStateAdapter
VP2.adapter = mVP2Adapter
使用起来很简单,效果图不再贴出~
2.3 进阶使用
2.3.1 Fragment懒加载
VP2 使用FragmentStateAdapter 加载Fragment 时,是通过setOffscreenPageLimit(int limit) 设置离屏缓存数量,当limit<1 时,不会进行预加载,即不会回调Fragment 相应的生命周期;反之会进行预加载,并回调预加载Fragment 相应的生命周期,limit 的默认值OFFSCREEN_PAGE_LIMIT_DEFAULT 为-1,即默认就是懒加载;这一点跟VP 不同,VP 中默认值为1,即默认就会加载左右两侧的Fragment 。
如果在VP2 中既想缓存Fragment(设置setOffscreenPageLimit()的参数>=1 ),同时又想对数据进行懒加载(Fragment 可见时才去请求数据),可以像下面这样:
/**
* 懒加载Fragment
*/
abstract class BaseLazyFragment : Fragment() {
private var mIsFirstLoad = true //是否是首次加载
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
if (container != null) {
val rootView = inflater.inflate(getLayoutId(), container, false)
initViews(rootView)
return rootView
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onResume() {
super.onResume()
if (mIsFirstLoad) {
initData()
mIsFirstLoad = false
}
}
@LayoutRes
protected abstract fun getLayoutId(): Int
protected fun initViews(view: View) {}
protected fun initData() {}
}
其中onResume() 只会在当前Fragment 可见时执行,所以用一个Boolean 字段来控制只执行一次数据请求。
PS:offscreenPageLimit对mCachedViews的影响
- 当没有设置
offscreenPageLimit 离屏缓存时,VP2 中的RecyclerView 默认会在mCachedViews 中缓存前面的2个Item 以及后面预抓取的1个Item 。 - 如果设置了
offscreenPageLimit 为1,则左右离屏各新增一个缓存的Item ,可以认为是把画布宽度增加到3倍(左右这两个默认不可见),加上RecyclerView 默认缓存的3个,除了当前显示的Item ,还会缓存总共5个Item 。
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增量更新
VP2 内部由RecyclerView 实现,所以支持DiffUtil 进行增量更新,从而提高性能;尽量避免使用notifyDatasetChanged() 全量更新。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,可以认为是把屏幕扩大到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 会被重新利用。
当VP2 滑动时,当前屏幕正在显示的前面最近的2个Item 会被缓存到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个给到了前面。当第一次加载时,由于还没有触发VP2 的onTouch 操作,所以此时还不会进行后面的预抓取。
四 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中的缓存和复用机制
|