1. 为何要重写一个这样的控件
- 旧的
DevicePullLayout
过于难以维护,2018年圣诞节被概念版云手机界面广告和设备管理界面滑动调整需求支配的恐惧至今印象深刻。 - 如今每次产品提出的涉及到需要改动首页UI结构的需求,开发们都如临大敌,以尽量诱导产品配合修改需求为首选技术方案。
那么以上2点DevicePullLayout
究竟是如何做到的?
2. 原有实现的不足
- 控件基本功能与业务耦合
- mManageView, mAdView, mDeviceViewPager
- addManageView(), addAdView(), addPageList()
- layoutStatus: TYPE_MANAGE
- mCurrentViewMode: MODE_SECOND_ITEM
- defaultShowAd
- pageChange
- 使用了容易出错的 事件分发机制方法dispatchTouchEvent
- 218行代码
- isOneMove(第一次执行move)
- mIsIntercept
- mMoveOrientation
- mChangeMoveOrientation
- canVerticalMove
- 受外部因素导致自身行为变化过多(Divergent Change)
- isViewPagerScrolling
- isDeviceNotRefreshing
- canMove
- 事件处理代码过于复杂
- isReady2SelfScroll
- isLastEventMoving
- isDeviceDown
- isAbovePager
- 状态切换逻辑过于复杂
- setLayoutStatus():
changeManageItem():if (TYPE_NONE == layoutStatus) {//只显示单个设备 ... } else if (TYPE_AD == layoutStatus) {//显示设备加广告 if (pageChange) { if (MODE_SECOND_ITEM == mCurrentViewMode) { mCurrentViewMode = MODE_SECOND_ITEM; ... } else { ... } ... } else if ... ...
if( status == ? ){ if(mode == ? ){ ... else if( status == ?... ...
- setLayoutStatus():
- 难于复用
与业务耦合严重,不具有通用性- mManageView, mAdView, mDeviceViewPager
- getTopHeight()...
- 难于扩展
由于其复杂性,难以修改,也即带来扩展的困难。
3. 曾经尝试过的其他方案
采用自定义方案之前,曾经尝试过的官方+第三方方案:
RecyclerView + GravitySnapHelper + OverScrollDecor
最后未采用的原因:
- 不能完全满足项目需求
1.1 无法限制一次只能滑动一页
1.2 滚出屏幕外的子项可见性发生变化时,控件整体会自动上/下移动
1.3 最底部的子项无法停止到与容器底部有一定间隔的位置 - 考虑将来项目需求变更,其定制性可能更强,需求更难满足
最终决定还是自定义。
4. VerticalPagerLayout
一句话介绍:一个垂直方向可分页滑动的控件。
4.1 功能列表
- 垂直方向分页滑动,滑动完成,内容自动按页停留在控件顶部/底部
- 支持滑动过程监听
- 支持滑动完成时选中某一页回调
- 支持可配置过度拖动
- 支持可配置最后一页是否黏住控件底部
- 支持可设置禁止垂直滑动
- 支持可配置禁止跨页滑动(阻力+自动回弹)
- 支持可配置是否在滑出屏幕外的子页可见性发生变化时,内容区位置保持不变
- 不可跨页滑动时,带有阻力效果
- 支持默认选中某个子页
- 支持手动选中某个子页
- 无需外部提供子页高度,自动测量
- 支持fling快速滑动手势
4.2 基本使用
首先通过gradle依赖:
项目根目录build.gradle
中:
repositories {
...
maven { url 'http://10.100.0.248:8081/nexus/content/repositories/rf-android-libs/' }
}
然后是模块build.gradle
:
implementation "com.leonxtp.verticalpagerlayout:verticalpagerlayout:0.1.9"
即可。
可以直接在代码中创建:
VerticalPagerLayout verticalPagerLayout = new VerticalPagerLayout(this);
也可以在xml中使用:
<?xml version="1.0" encoding="utf-8"?>
<com.leonxtp.verticalpagerlayout.VerticalPagerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/vertical_pager_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/tv_screen_info"
android:layout_width="match_parent"
android:layout_height="100dp"
android:onClick="move"
android:padding="16dp"
android:textSize="20sp"
android:textStyle="bold" />
<!-- 各种其他控件 -->
</com.leonxtp.verticalpagerlayout.VerticalPagerLayout>
各种配置:
// 设置是否可以跨页拖动
verticalPagerLayout.setCrossItemDragEnabled(false);
// 设置在滑出屏幕外的子页可见性发生变化时,内容区位置是否保持不变
verticalPagerLayout.setKeepContentOnItemVisibilityChanged(true);
// 设置是否支持过度拖动
verticalPagerLayout.setPullOverScrollEnabled(true);
// 最后一页是否黏住控件底部
verticalPagerLayout.setIsLastItemBottomSticky(false);
// 设置默认选中某页
verticalPagerLayout.setDefaultSelectedItem(0);
// 设置是否禁用垂直方向滑动
verticalPagerLayout.setVerticalMoveEnabled(false);
// 是否打印日志
verticalPagerLayout.setLogging(true);
// 是否打印滑动过程中的日志(日志非常多)
verticalPagerLayout.setLogMoveEvents(true);
// 设置滑动监听
verticalPagerLayout.addOnScrollListener(new OnItemScrollListener() {
@Override
public void onItemScrolled(View firstVisibleItem, int firstVisibleItemIndex, float firstVisibleItemOffset) {
// 控件正在滑动
}
}
@Override
public void onItemSelected(View selectedItem, int selectedIndex) {
// 滑动结束,选中了某一页
}
});
4.3 效果展示
手机投屏演示
4.4 简要类图关系
VerticalPagerLayout.png4.5 技术实现方案
对于重写此控件,当初定下的目标就是:
- 控件独立
- 通用
- 代码清晰易读、易维护
- 提供多种配置
基于以上标准,使用以下方案:
- 在
onLayout()
中初始化全部子页的高度数据 - 在
onLayout()
中处理子控件变化(可见性、高度),重新计算其高度数据。 - 在
onInterceptTouchEvent()
方法中判断是否需要拦截事件 - 在
onTouchEvent()
方法中处理滑动事件 - 在
computeScroll
中传递自动滚动的位置,并提供判断自动滚动是否完成。 - 在
MoveScrollHelper
中专门处理手指拖动的事件 - 在
AutoScrollHelper
中专门处理手指抬起后的自动滚动事件 - 在
SubItemChangeHelper
中专门处理子控件的变化 - 每次滑动结束后,都会计算当前选中的子页下标,并保存。
- 根据getScrollY(),以及所有子控件的高度,及自由滑动的高度,共同计算当前
8.1. 正在显示的子页下标
8.2. 当前子页已显示的部分高度
8.3. 判断当前的拖动是否需要加上阻力(到顶了/到底了/跨页了)
8.4. 手指抬起后的目标item
8.5. 到目标item所需要的滚动距离 - 滑动结束都会保存当前选中的页标,在子控件变化时,
onLayout()
会执行,触发SubItemChangeHelper
,执行判断变化的item是否为第一个,如是,且配置了滑出屏幕外的子页可见性发生变化时,内容区位置保持不变时,执行scrollBy()
快速滑动,填补变化后留下的空白/占位。 - 使用
VelocityTracker
帮助计算滑动的手势速度,当达到临界值以上时,视为快速滑动,不论是否目标子页已经超过了一半,都滑动到目标子页或者下一页。 - 提供
scrollToItem(int, int)
方法,手动滑动到某一页,可配置是平滑滚动还是快速滚动。内部还是使用ComputeUtil
计算滚动到目标子页所需要的距离,需要考虑支持最后item黏住底部的配置。
4.6 集成到项目中,替换DevicePullLayout
VerticalPagerLayout
提供了一定方法,与原控件实现的效果基本一致。
而对于未提供的方法,为与业务代码兼容,提供了一个适配器,PadVerticalPagerManager
,来兼容业务与新控件。
原DevicePullLayout#setLayoutStatus()
方法,改为调用:
public void setLayoutType(int type) {
Rlog.d("VerticalPagerLayout", "setLayoutType, " + type + ", mVPagerLayout:" + mVPagerLayout);
mCurrentLayoutType = type;
switch (type) {
case TYPE_ALL:
showTypeAll(isViewPagerChanged);
break;
case TYPE_MANAGE:
showTypeManage(isViewPagerChanged);
break;
case TYPE_AD:
showTypeAd(isViewPagerChanged);
break;
case TYPE_NONE:
showTypeNone(isViewPagerChanged);
break;
default:
mCurrentLayoutType = TYPE_NONE;
break;
}
}
private void showTypeManage(boolean isViewPagerChanged) {
Rlog.d("VerticalPagerLayout", "showTypeManage ready...");
if (mManageView != null && mManageView.getVisibility() == View.GONE) {
mManageView.setVisibility(View.VISIBLE);
}
if (mAdView != null && mAdView.getVisibility() == View.VISIBLE) {
mAdView.setVisibility(View.GONE);
}
mVPagerLayout.setPullOverScrollEnabled(true);
scrollToTargetIndex(isViewPagerChanged);
}
private void scrollToTargetIndex(boolean isViewPagerChanged) {
if (isViewPagerChanged) {
int lastSelectedIndex = mVPagerLayout.getLastSelectedItemIndex();
Rlog.d("VerticalPagerLayout", "scrollToTargetIndex(viewpager changed): " + lastSelectedIndex);
mVPagerLayout.scrollToItem(lastSelectedIndex, false);
} else if (isShowAd) {
Rlog.d("VerticalPagerLayout", "scrollToTargetIndex(showAd) scrollToItem: 1");
mVPagerLayout.scrollToItem(1, false);
} else {
Rlog.d("VerticalPagerLayout", "scrollToTargetIndex scrollToItem: 2");
mVPagerLayout.scrollToItem(2, false);
}
}
以柏林在我拉出这个开发分支之后开发的热启动展示首页设备列表广告为例,原代码为:
// 如果当前无广告展示
if (dplPadRootLayout.getmLayoutStatus() == DevicePullLayout.TYPE_MANAGE
|| dplPadRootLayout.getmLayoutStatus() == DevicePullLayout.TYPE_NONE) {
return;
}
// 当前只显示设备
if (dplPadRootLayout.getCurrentViewMode() == DevicePullLayout.MODE_NONE_ITEM) {
// 显示广告
boolean isShowAd = devAdDefaultShowAd(true);
if (isShowAd) {
// 如果满足条件
setDefaultShowAd(true);
// 强行设置展示广告状态
dplPadRootLayout.setCurrentViewMode(DevicePullLayout.MODE_SECOND_ITEM);
setLayoutStatus();
Rlog.d("deviceFragment", "满足热启动显示设备列表条件,直接展示");
StatisticsHelper.statisticsStatInfo(StatKey.DEV_LIST_AD_AUTO_OPEN, "openType:warm_boot");
}
}
改了之后:
int currentLayoutType = padVerticalPagerManager.getCurrentLayoutType();
// 如果当前无广告展示
if (currentLayoutType == PadVerticalPagerManager.TYPE_MANAGE ||
currentLayoutType == PadVerticalPagerManager.TYPE_NONE) {
return;
}
// 当前只显示设备
if (dplPadRootLayout.getLastSelectedItemIndex() == 2) {
// 显示广告
boolean isShowAd = devAdDefaultShowAd(true);
if (isShowAd) {
// 如果满足条件
setDefaultShowAd(true);
// 强行设置展示广告状态
dplPadRootLayout.scrollToItem(1, false);
// setLayoutStatus();
Rlog.d("deviceFragment", "满足热启动显示设备列表条件,直接展示");
StatisticsHelper.statisticsStatInfo(StatKey.DEV_LIST_AD_AUTO_OPEN, "openType:warm_boot");
}
}
4.7 新实现的优势
- 没有旧控件那么多的奇奇怪怪的事件冲突问题
- 完全脱离业务,只做UI上的事
- 通用,易复用
- 易维护
4.8 曾经遇到过的问题
- 多点触碰引发的多指滑动问题
- 子控件可见性变化时计算保持内容位置不变所需要的移动距离错误
-
quickScrollBy()
后未回调onItemSelected()
方法,即未回调当前选中了某页。 - 未支持跨子页拖动时的阻力效果
- 集成入项目之后,横向滑动设备列表,本来未展示的广告,自动展示出来
- 在onLayout()首次执行前,外部调用了scrollToItem()方法,无效
4.9 待完善
- 当配置可以跨子页滑动时,支持最后子页不黏住底部
5. 感谢
感谢各位大佬参加!
网友评论