贝店首页滑动体验优化实践

背景

贝贝的各个业务线APP中,常常会出现类似下面结构的页面:
列表模板
整个页面主要由头部信息区域、tab区域、列表内容区域组成。当向上滑动,头部内容滑出屏幕后,tab栏会吸顶,列表内容可以继续滑动。要实现这个页面结构,贝贝目前已有成熟的方案。
贝店首页也有这样的类似需求。用贝贝的成熟方案很容易实现,但是存在一个问题:
列表模板
当向上滑动页面,tab栏到达屏幕顶部的临界状态时,之后列表内容就不能继续向上滑动,需要松开手指后才能继续进行滑动操作。
为了更好的用户体验,我们决定解决这个问题,实现无阻断的流畅滑动体验。

需求分析

从贝店的具体需求出发,与贝贝首页相比,发现有些差异:贝店的tab栏是一个横向混动的时间轴,tab栏下方的列表区域是单个列表,而不是可以左右滑动的ViewPager。时间轴内选中一个时间点tab,下面的列表内容会同步刷新为对应数据。
基于此,我们选用RecyclerView来呈现列表数据,由于用不到ViewPager,我们对BeiBeiSDK中的公共组件PagerSlidingTabStrip进行了改造,剔除了其中的ViewPager相关逻辑,变得更轻量,产生了自定义的时间轴控件PagerSlidingTimeSlotTabStrip。
最后,要实现无阻断滑动,首先想到的是从事件分发的角度来处理滑动冲突问题,但这个方案导致的页面结构比较复杂,同时也需要自定义相关控件来处理Touch事件,比较繁琐。在当时贝店版本飞速迭代的情况下,我们采用了比较讨巧的方案:
整个列表包括头部内容区域只用一个RecyclerView来实现,为了实现时间轴吸顶效果,同时存在两个时间轴控件A和B,A位于HeaderView内,B悬浮在RecyclerView上方,默认不可见。他们的状态完全同步,如下图:
列表模板

详细实现

来看下时间轴控件PagerSlidingTimeSlotTabStrip:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class PagerSlidingTimeSlotTabStrip extends HorizontalScrollView {
// 略去一坨代码...
private OnTabSelectedListener mSelectedListener;
// 与当前控件完全同步的控件
private PagerSlidingTimeSlotTabStrip mAttachedTabStrip;
public interface OnTabSelectedListener {
void onNewTabSelected(int curPosition);
}
public void setTabSelectedListener(OnTabSelectedListener listener) {
mSelectedListener = listener;
if (mAttachedTabStrip != null && !mIsOnlyAttachScroll) {
mAttachedTabStrip.setTabSelectedListener(listener);
}
}
// 这个bool是为了控制当前控件与attached的控件之间不触发死循环,因为两者的联动是需要互相attach的,有些操作会不断相互调用,出现死循环
private boolean mIsOnlyAttachScroll = false;
public void setAttachedTabStrip(PagerSlidingTimeSlotTabStrip tabStrip) {
setAttachedTabStrip(tabStrip, false);
}
public void setAttachedTabStrip(PagerSlidingTimeSlotTabStrip tabStrip, boolean isOnlyAttachScroll) {
mAttachedTabStrip = tabStrip;
mIsOnlyAttachScroll = isOnlyAttachScroll;
}
// 刷新数据
public void refreshData(List<TimeSlotModel> list) {
if (mAttachedTabStrip != null && !mIsOnlyAttachScroll) {
mAttachedTabStrip.refreshData(list);
}
// 刷新时间轴数据操作,略去
}
// 滚动到position位置的tab
public void scrollToChild(int position, int offset) {
if (mAttachedTabStrip != null && !mIsOnlyAttachScroll) {
mAttachedTabStrip.scrollToChild(position, offset);
}
// 具体滚动逻辑,略去
}
// 略去一坨代码...
@Override
protected void onScrollChanged(int x, int y, int oldX, int oldY) {
super.onScrollChanged(x, y, oldX, oldY);
if (mOnScrollChangeListener != null) {
// 外部设置的监听
mOnScrollChangeListener.onScrollChanged(this, x, y, oldX, oldY);
}
if (mAttachedTabStrip != null) {
// 联动滚动,不区分主动与被动,直接同步
mAttachedTabStrip.scrollTo(x, y);
}
}
}

之前提到,我们有两个一毛一样的时间轴控件,当到达吸顶临界状态的时候控制他们是否可见,因此重点在于如何处理两个时间轴的状态完全同步。这里通过调用setAttachedTabStrip方法,让时间轴A内部持有时间轴B的对象引用,这样一来,A发生任何滚动、时间项选中等等UI变化时,都可以及时的应用到B上,这样就保证了两者的同步。
这里需要特别指出的是,两个时间轴是有主动和被动的角色区分的:

1
2
3
// 互相attach
mTimeSlotTabStrip.setAttachedTabStrip(mStickyTimeSlotTabStrip);
mStickyTimeSlotTabStrip.setAttachedTabStrip(mTimeSlotTabStrip, true);

在列表的HeaderView内的时间轴A为主动角色,悬浮状态的时间轴B为被动角色。
因为两者互相attach,部分操作可能会导致死循环,所以只在操作A时同步给B。这样不会有问题,因为任意时刻,A和B只会有一个为可见状态,我们只能看到当前所操作的那个时间轴。
可能有人会有疑惑:既然操作时只把A的状态同步给B,那么AB的可见状态切换时,如何保证两者看起来一样呢?
看上面代码,PagerSlidingTimeSlotTabStrip的onScrollChanged滚动监听回调方法里,会去处理滚动同步,这里不会区分主被动角色。

关于时间轴控件的visible切换控制,是通过监听RecyclerView的滚动来实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 监听RecyclerView的滚动
private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener
= new RecyclerView.OnScrollListener() {
...
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// 列表中的时间档顶部到列表顶部的距离
int headerViewHeight = mHeaderViewContainer.getHeight() - mTimeSlotTabStrip.getHeight();
if (getScrollYDistance() > headerViewHeight) {
setStickyTimeSlotTabStropVisible(View.VISIBLE);
// 执行顶部tab隐藏动画
EventBus.getDefault().post(new TopbarAnimEvent(TopbarAnimEvent.TYPE_HIDE));
} else {
setStickyTimeSlotTabStropVisible(View.GONE);
EventBus.getDefault().post(new TopbarAnimEvent(TopbarAnimEvent.TYPE_SHOW));
}
}
private void setStickyTimeSlotTabStropVisible(int visible) {
if (visible == View.VISIBLE) {
// 同步两个时间档的当前滚动位置,只需要在mStickyTimeSlotTabStrip第一次展示的时候同步即可
mStickyTimeSlotTabStrip.scrollToChild(getCurrentTimeSlotIndex(), 0);
}
mStickyTimeSlotTabStrip.setVisibility(visible);
mStickyTimeSlotDividerLine.setVisibility(visible);
}
};

比较简单,这里不再赘述。
到此为止,核心的实现逻辑基本介绍完了,重点就在于如何控制两个时间轴控件的状态同步,让他们在visible切换后仍然看起来一样,实现无缝衔接。

总结

以上就是贝店首页无阻断流畅滑动体验的实践。在贝店业务快速迭代的背景下,我们需要用最快的方案实现需求,为了尽可能复用目前已有的组件,采用最小改动的方案来实现,以支撑业务飞速发展。

很惭愧<br><br>只做了一点微小的工作<br>谢谢大家