如何实现微博评论列表滑动置顶效果
微博评论列表里所有评论的总高度如果没有占满一屏,那么会有一段固定的空白填充在底部,使得滑到最底时最新一条评论刚好置顶,如下图。对于只想看评论列表来说展示效率很高,也符合直觉。我们应用也需要实现这个效果,在这里分享一下实现的两种方法。
这里将评论列表底部空白部分称作 bottom space,评论列表用 RecyclerView 来实现。当评论列表的总高度大于等于 RecyclerView 高度时很简单,bottom space 的高度设为0就好了,问题主要是在评论列表总高度小于 RecyclerView 高度时,如何动态调整 bottom space 的高度。关于这点我们实现的时候先后采取了两种解决方案:
第一种
将 bottom space 作为 RecyclerView 的一种 item,插在列表最后一个位置,通过透明的 View 来实现,当评论列表发生增减时动态调整 bottom space 的高度。bottom space 的高度等于 RecyclerView 的高度减去评论列表的总高度。RecyclerView 的高度不难获取,主要是获取评论列表的总高度,因为每一条评论的内容都不一样,所以需要单独获取每条评论的高度然后相加。RecyclerView
没有提供获取 itemView 高度的接口,并且由于实现机制原因 itemView 在渲染出来之前是无法知道它的高度的。所以采取的办法是当评论列表数据刷新时记一个标志位 mNeedAdjustBottomSpace
表示需要调整 bottom space 高度。然后在页面滑动时,当页面内 item type 都是评论时就可以去根据评论列表总高度调整 bottom space 的高度。代码片段如下。
addScrollListener(new OnScrollListener() {
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
if (mNeedAdjustBottomSpace) {
// 页面内都是评论类型
boolean canAdjust = ((LinearLayoutManager) getLayoutManager()).findFirstVisibleItemPosition() >= getAdapter().getHeaderCount()
|| !recyclerView.canScrollVertically(1);
if (canAdjust) {
mNeedAdjustBottomSpace = false;
adjustBottomSpace();
}
}
});
private void adjustBottomSpace() {
int normalHeight = getNormalHeight();
if (normalHeight != 0) {
int newBottomSpaceHeight = Math.max(getHeight() - normalHeight, 0);
post(() -> getAdapter().setBottomSpaceHeight(newBottomSpaceHeight));
}
}
private int getNormalHeight() {
LinearLayoutManager lm = (LinearLayoutManager) getLayoutManager();
int result = 0;
int first = lm.findFirstVisibleItemPosition();
int end = lm.findLastVisibleItemPosition();
JAdapter adapter = getAdapter();
for (int i = Math.max(first, adapter.getHeaderCount());
i < Math.min(adapter.getItemCount() - adapter.getBottomSpaceCount(), end + 1); i++) {
View v = lm.findViewByPosition(i);
if (v != null && BaseAdapter.LOAD_MORE != adapter.getItemViewType(i)) {
result += v.getHeight();
}
}
return result;
}
getAdapter().setBottomSpaceHeight()
的实现是通过成员变量记住 bottom space 的高度,调用 Adapter.notify()
方法,然后在 Adapter.onBindViewHolder()
方法里改变 bottom space itemView 的高度。
这个方案虽然基本实现了我们需要的效果,但是还有两个问题:
- 只有评论列表刷新时才可以调整 bottom space有局限性,而且调整的时机判断有点 hack,不利于代码维护。
- 会在 RecyclerView 滑动过程中调用 Adapter notify方法,容易导致 RecyclerView inconsistent crash。
第二种
如果要解决第一种方案的问题,将 bottom space 作为一种 itemView 来实现这条路肯定走不通了。需要实现的效果换个思路想就是当滑动到最后一条评论时,让 RecyclerView 认为还没有滑到底,通过查看 RecyclerView 的代码发现在 onTouchEvent()
方法中对 MotionEvent.ACTION_MOVE
的处理有这么一段代码:
if (scrollByInternal(canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0,vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
这说明 scrollByInternal()
这个方法的返回值决定 RecyclerView 能不能继续往下滑动,继续查看这个方法发现了这行代码:
consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState)
当竖直方向滑动时 consumedY != 0
说明还可以继续往下滑动,按照这个变量的字面意思 mLayout.scrollVerticallyBy()
返回的是 LayoutManager 竖直滑动过的距离。继续查看 LinearLayoutManager.scrollVerticallyBy() 方法发现是在 layoutChunk() 里计算滑动过的距离,这个方法最关键的代码其实只有两行:
measureChildWithMargins(view, 0, 0);
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
mOrientationHelper.getDecoratedMeasurement(view) 里面实现很简单:
@Override
public int getDecoratedMeasurement(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
return mLayoutManager.getDecoratedMeasuredHeight(view) + params.topMargin + params.bottomMargin;
}
发现可以滑动的距离等于进入页面的 itemView 的高度加上 marginTop 和 marginBottom 的大小。所以要实现我们需要的效果只要将评论列表最后一个 itemView 的 marginBottom 看作 bottom space 就可以了。因为必须在 mOrientationHelper.getDecoratedMeasurement(view) 之前设置 marginBottom,所以选择在 measureChildWithMargins(view, 0, 0) 中设置。而且在这个方法里也可以获取每个 itemView 的高度,正好计算所有评论 itemView 的高度和。代码如下,重写了 LinearLayoutManager 的 measureChildWithMargins() 方法。
new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) {
private SparseIntArray mHeightArray = new SparseIntArray();
@Override
public void measureChildWithMargins(View child, int widthUsed, int heightUsed) {
super.measureChildWithMargins(child, widthUsed, heightUsed);
LayoutParams params = (LayoutParams) child.getLayoutParams();
int position = params.getViewAdapterPosition();
JAdapter adapter = getAdapter();
mHeightArray.put(position, child.getMeasuredHeight());
int bottomMargin = 0;
if (position == adapter.getItemCount() - 1) {
bottomMargin = getHeight();
for (int i = 0; i < mHeightArray.size(); i++) {
int key = mHeightArray.keyAt(i);
if (key >= adapter.getHeaderCount() && key < adapter.getItemCount()) {
int value = mHeightArray.get(key);
bottomMargin = Math.max(0, bottomMargin - value);
if (bottomMargin == 0) {
break;
}
}
}
}
params.bottomMargin = bottomMargin;
}
};
在这个方法里将每个 itemView 的高度存在 mHeightArray 内,当 itemView 是最后一个时,用 RecyclerView 的高度减去所有评论 itemView 的总高度得到 bottom space 的高度并设为最后一个 itemView 的 bottomMargin。这样就可以实现评论置顶的效果,并且在增减评论时都有效,而不局限刷新列表数据时。实现比第一种简单很多,而且容易维护。