ViewPager2修改翻页动画时间
ViewPager2是google推出的替代viewpager的库。功能相比viewpager强大了不少。但是有个比较难受的点就是当使用setCurrentItem翻页时,viewpager2是不支持设置翻页动画时长的,并且动画时长非常快,这就导致了部分场景下快速翻页的效果不是特别合适。viewpager可以通过反射替换viewpager的”mScroller“字段完成动画时长的设置。
try {
val field = ViewPager::class.java.getDeclaredField("mScroller");
field.isAccessible = true
val scroller = MyScroller(context)
field.set(viewpager, scroller);
scroller.setmDuration(800);
} catch (e: Exception) {
e.printStackTrace()
}
但很遗憾的是viewpager2不支持这样去做,并且通过查阅资料viewpager2的作者明确表示没有这个功能的开发计划。 https://issuetracker.google.com/issues/122656759
jg...@google.comjg...@google.com #4Aug 7, 2019 07:07PM
Status: Won't Fix (Infeasible)
Unlikely to address due to bandwidth constraints - icebox for now.
去网上搜了一下解决方案,大部分都是使用FakeDrag 系列的api完成的这个功能,但是看了下网上的代码只是特别简单的实现了一个翻页动画,其中关于连续翻页或者翻页动画未完成时输入反方向动画事件,以及连续输入多对相反方向的操作事件的情况都没有处理。说白了根本不能用。
这个时候我就面临了两个选择:改回viewpager或者想办法解决这个问题。对比了一下这两个选项的工作量,感觉改回viewpager工作量是可见的但也是比较多的,解决这个问题呢,可能路子比较难走,但是假如走通了改动量应该是比较小的。所以觉得先尝试着解决一下这个问题。
根据viewpager上处理动画时间的经验,感觉viewpager2也有一个类似的对象来控制滚动时长,找到这个对象然后反射替换应该就行了。但是通过查看viewpager2源码其实可以看到viewpager2其实最终是调用了RecycleView的smoothScrollToPosition进行的滚动。
public void setCurrentItem(int item, boolean smoothScroll) {
if (isFakeDragging()) {
throw new IllegalStateException("Cannot change current item when ViewPager2 is fake "
+ "dragging");
}
setCurrentItemInternal(item, smoothScroll);
}
void setCurrentItemInternal(int item, boolean smoothScroll) {
...
// For smooth scroll, pre-jump to nearby item for long jumps.
if (Math.abs(item - previousItem) > 3) {
mRecyclerView.scrollToPosition(item > previousItem ? item - 3 : item + 3);
// TODO(b/114361680): call smoothScrollToPosition synchronously (blocked by b/114019007)
mRecyclerView.post(new SmoothScrollToPosition(item, mRecyclerView));
} else {
mRecyclerView.smoothScrollToPosition(item);
}
}
问题到这暂时变成了如何修改RecycleView 的滚动时长。还好这个问题网上是有一些方案的。基本都是继承LayoutManagere重写smoothScrollToPosition方法,开始滑动时设置一个自定义的RecyclerView.SmoothScroller对象,然后重写calculateSpeedPerPixel方法
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
LinearSmoothScroller smoothScroller =
new LinearSmoothScroller(recyclerView.getContext()) {
// 返回:滑过1px时经历的时间(ms)。
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return 150f / displayMetrics.densityDpi;
}
};
smoothScroller.setTargetPosition(position);
startSmoothScroll(smoothScroller);
}
calculateSpeedPerPixel的返回值则代表了RecycleView划过一个px所用的时间(这块有个小问题,后续讲述)。所以方案现在基本可以敲定就是自定义一个LinearLayoutManager,然后通过反射去替换viewpager2里的mLayoutManager字段。但是在执行时遇到一个问题,因为反射替换的时间肯定是viewpager2初始化之后,而viewpage2初始化之后其实是有很多其他字段已经持有了mLayoutManager的引用。
private void initialize(Context context, AttributeSet attrs) {
...
mLayoutManager = new LinearLayoutManagerImpl(context);
mRecyclerView.setLayoutManager(mLayoutManager);
...
mPageTransformerAdapter = new PageTransformerAdapter(mLayoutManager);
.
}
所以反射替换不能只替换viewpager2的mLayoutManager字段,还需要替换持有mLayoutManager引用的对象中的相关字段。这样一来有可能陷入一个循环,不断的去替换关联对象中的LinearLayoutManager实例。所以这个时候这个思路已经不是很合适了。其实我们通过阅读viewpager2的源码可以发现setCurrentItem的调用栈是特别浅的,只有两层,而且代码量也不大,我们完全可以在我们自己的代码中模拟setCurrentItem的以及LayoutManager.smoothScrollToPosition的方法体,这样就能方便的替换mLayoutManager smoothScrollToPosition中的关键对象。整体方案还是比较简单的,下面贴上整个工具类的代码:
class ViewPager2SlowScrollHelper(private val vp: ViewPager2, var duration: Long) {
private val recyclerView: RecyclerView
private val mAccessibilityProvider: Any
private val mScrollEventAdapter: Any
private val onSetNewCurrentItemMethod: Method
private val getRelativeScrollPositionMethod: Method
private val notifyProgrammaticScrollMethod: Method
init {
val mRecyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView")
mRecyclerViewField.isAccessible = true
recyclerView = mRecyclerViewField.get(vp) as RecyclerView
recyclerView.layoutManager
val mAccessibilityProviderField =
ViewPager2::class.java.getDeclaredField("mAccessibilityProvider")
mAccessibilityProviderField.isAccessible = true
mAccessibilityProvider = mAccessibilityProviderField.get(vp)
onSetNewCurrentItemMethod =
mAccessibilityProvider.javaClass.getDeclaredMethod("onSetNewCurrentItem")
onSetNewCurrentItemMethod.isAccessible = true
val mScrollEventAdapterField =
ViewPager2::class.java.getDeclaredField("mScrollEventAdapter")
mScrollEventAdapterField.isAccessible = true
mScrollEventAdapter = mScrollEventAdapterField.get(vp)
getRelativeScrollPositionMethod =
mScrollEventAdapter.javaClass.getDeclaredMethod("getRelativeScrollPosition")
getRelativeScrollPositionMethod.isAccessible = true
notifyProgrammaticScrollMethod = mScrollEventAdapter.javaClass.getDeclaredMethod(
"notifyProgrammaticScroll",
Int::class.java,
Boolean::class.java
)
notifyProgrammaticScrollMethod.isAccessible = true
}
/**
* 模拟手写Viewpage2的setCurrentItemInternal(int item, boolean smoothScroll)方法
* 其中smoothScroll为true
* 主要目的是通过手动实现vp的翻页方法达到控制RecycleView执行滚动的SmoothScroller对象
*/
fun setCurrentItem(item: Int) {
var item = item
val adapter: RecyclerView.Adapter<*> = vp.adapter as RecyclerView.Adapter<*>
if (adapter.itemCount <= 0) {
return
}
item = item.coerceAtLeast(0)
item = item.coerceAtMost(adapter.itemCount - 1)
if (item == vp.currentItem && vp.scrollState == ViewPager2.SCROLL_STATE_IDLE) {
return
}
if (item == vp.currentItem) {
return
}
vp.currentItem = item
onSetNewCurrentItemMethod.invoke(mAccessibilityProvider)
notifyProgrammaticScrollMethod.invoke(mScrollEventAdapter, item, true)
smoothScrollToPosition(item, vp.context, recyclerView.layoutManager)
}
/**
* 模拟手写RecyclerView的smoothScrollToPosition方法 替换了startSmoothScroll的参数达到了改变速度的目的
*/
private fun smoothScrollToPosition(
item: Int,
context: Context,
layoutManager: RecyclerView.LayoutManager?
) {
val linearSmoothScroller = getSlowLinearSmoothScroller(context)
replaceDecelerateInterpolator(linearSmoothScroller)
linearSmoothScroller.targetPosition = item
layoutManager?.startSmoothScroll(linearSmoothScroller)
}
/**
* 减速核心SmoothScroller对象,super.calculateSpeedPerPixel(displayMetrics) * slowCoefficient 为速度放慢slowCoefficient倍
* 既动画时长增加slowCoefficient倍
*/
private fun getSlowLinearSmoothScroller(context: Context): RecyclerView.SmoothScroller {
return object : LinearSmoothScroller(context) {
/**
* ??????
* ??????
* 按照sdk注释的内容理解这个方法的返回值为每个像素滚动的时间 例如返回 1 则代表滚动1个像素需要1ms 既1920px的滚动距离 则需要滚动1.92s
* 所以返回值应该是 duration/width 比如期望滚动1s 也就是需要返回 1000/vp.width
* 但是根据实际测试 如果按照返回值是 duration/width来计算 当返回 duration/width = 1时 duration期望应该是with(假设with是1920px duration则是1920ms)但是实际duration约等于3倍with(1920px滚动5700ms )????
* 暂无实际证据可以证实这个值是 3倍
* 但是calculateSpeedPerPixel的返回值的确和sdk注释描述的是有出入的,暂时先用3作为调整系数
* 也有可能是和我们设备相关 横屏 1920*1080 320dpi,使用的时候可以重新测试一下。
* ??????
* ??????
*/
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?): Float {
return duration/(vp.width.toFloat()*3.0f)
}
}
}
/**
* 修改SmoothScroller的默认差值器,将其改为线性输出,不然会影响后续的vp动画
* 如果没有自定义动画可以不用这个方法
*/
private fun replaceDecelerateInterpolator(linearSmoothScroller: RecyclerView.SmoothScroller) {
val mDecelerateInterpolatorField =
LinearSmoothScroller::class.java.getDeclaredField("mDecelerateInterpolator")
mDecelerateInterpolatorField.isAccessible = true
mDecelerateInterpolatorField.set(linearSmoothScroller, object : DecelerateInterpolator() {
override fun getInterpolation(input: Float): Float {
return input
}
})
}
}
需要额外注意点的是calculateSpeedPerPixel方法,这个方法经过我的实际测试和sdk的注释描述并不相符,也可能是我的设备问题,大家使用的时候需要注意这个问题。
使用方式是直接使用ViewPager2SlowScrollHelper.setCurrentItem 代替viewpager2.setCurrentItem 就可以了