最近公司的项目要仿照 iOS 解锁时通知列表的效果,有 iPhone 的朋友可以自己看自己手机,没有的,要不问有的借个,然后观摩下~~或者看最后的实现效果,虽然说没有完全像,应该也有 7-8 分像吧。
既然是列表,好吧第一时间想到的就是 RecyclerView,然后顶部的时间日期播放器等等等,就放到一个头部就 OK 了,放一张手画的解决方案(字丑请无视),然后我们再慢慢分析。列表大概分 4 部分,头部,新消息通知,占位的空 View(至于为什么要放一个占位的 View 稍后再说),旧消息通知。看过 iPhone 效果的朋友应该能发现,旧通知往上滚动的时候,第一屏的 item 的透明度会慢慢从 0 到 1 变化,当快达到和新通知交界的地方,会有一个往上顶的动作,如果说还没达到交界的地方,会迅速弹回,然后透明度又从 1 转为 0,当达到交界以后,就随着新消息通知一起滚动,不再变化,这些效果,我们就需要通过占位的 View 来帮助实现了。接着就开始上代码继续分析啦(代码用到了 DataBinding 不熟悉的小伙伴可以熟悉下)~
首先我们需要定义 Adapter,在这之前,我们先定义消息的实体类,为了方便,我们就只定义 2 个关键属性,一个是消息的内容,一个是判断是否为新消息
public class NotificationBean {
private String notificationContent;
private boolean isNewNotification;
// 省略 N 多 setter/getter 和构造方法
}
那么我们的 adapter 布局也就放一个 TextView 让其加载消息的内容即可
然后,我们的占位 View 也是列表数据中的一个,所以我们也需要将其布局定义出来
这里有个坑,虽然我们的占位 View 就一个子控件,但是我们还是需要给嵌套一层父布局,因为 RecyclerView 不允许直接修改 ViewHolder 的 LayoutParam,一定要注意。
然后我们定义自己的 adapter
/**
* 因为需要多种数据类型,所以我们这边的数据的类型定义为 Object
*/
public class NotificationAdapter extends BaseRvHeaderFooterAdapter
Ok,我们的正餐就要来了。
我们先定义几个需要用到的属性
/**
* 占位 View 的属性
*/
private FrameLayout.LayoutParams blankLp;
/**
* 占位 View 的初始高度
*/
private int blankViewHeight;
private LinearLayoutManager mListManager;
/**
* 刚进入时能见的最后一个 itemPosition
*/
private int initLastItemPosition;
/**
* 用于记录 RecyclerView 滑动
*/
private int lastVerticalOffset, currentVerticalOffset, verticalDelta;
/**
* 判断是否往上滑
*/
private boolean isDragUp;
首先我们需要获取刚进入界面时,最后一个 item 是什么,这个值关系到我们占位 View 需要设置多少高度,有个坑,我们获取这个值的时候不能立刻在 onCreate 里面直接就获取到正确值,这边我做了一个延时去取值。然后我们根据取到的值去判断当前的 View 是一个怎么样的 View,我们只需要判断两种情况:
- 如果最后一个可见的 View 是新的消息通知,那我们就不需要去设置占位 View 了
- 如果最后一个可见的 View 是旧的消息通知,那我们就把屏幕的高度的 5/6 - 整个头部的高度 - 新通知消息的高度,但是存在负值的情况,所以我们就设置一个最小的高度值,我这边设置的 100,然后取两个值的最大值即可
Observable.timer(100, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer() {
@Override
public void accept(Long aLong) throws Exception {
initLastItemPosition = mListManager.findLastVisibleItemPosition();
// 获取 View 类型的方法
int lastViewType = lastPositionItemType();
switch (lastViewType) {
/**
* 最后一个可见为新通知,取消占位 View
*/
case NEW_NOTIFICATION_VIEW:
blankLp.height = 0;
break;
/**
* 最后一个可见为旧通知,(高度为 5/6 屏幕高度 -
* 新通知区域高度 - 头部高度) 和 100 之间的最大值
*/
case OLD_NOTIFICATION_VIEW:
blankLp.height =
Math.max(mScreenHeight * 5 / 6 - mHeadHeight - getNewNotificationAreaHeight(), 100);
}
// 这里需要把我们取得的高度复制给占位 View
blankViewHeight = blankLp.height;
View blankView = getBlankView();
if (blankView != null) {
blankView.findViewById(R.id.blank_content).setLayoutParams(blankLp);
/**
* 延时设置透明度,需要先把 占位 view 的高度设置完成以后再设置
* 不做延时会出现数量多于实际数量
*/
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
initOldNotificationArea();
}
}, 50);
}
}
});
获取到必要的值后,我们需要通过 RecyclerView 的 OnScrollListener 来设置 View 的高度的变化
mViewBinding.notificationList.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// 判断手势上滑还是下滑
isDragUp = dy > 0;
currentVerticalOffset = recyclerView.computeVerticalScrollOffset();
/**
* 滑动距离
*/
verticalDelta = currentVerticalOffset - lastVerticalOffset;
final View blankView = getBlankView();
/**
* 动态设置 BlankView 高度
*/
if (blankView != null && blankView.getHeight() > blankViewHeight / 5 && blankView.getHeight() <= blankViewHeight) {
blankLp.height -= 4 * verticalDelta;
/**
* 当前屏的 OldNotification
*/
final List oldNotifications = getOldNotificationItemsCurrentPage();
final int range = oldNotifications.size();
for (int i = 0; i < range; i++) {
oldNotifications.get(i).setAlpha(1 - (blankLp.height * 1.0f * (i + 1)) / (3 * blankViewHeight));
}
/**
* 最小范围,小于最小值的时候,我们添加一个动画,可以缓慢变化,否则太快了
*/
if (blankLp.height <= blankViewHeight / 5) {
ValueAnimator scrollAnim = ValueAnimator.ofInt(blankLp.height, 0);
scrollAnim.setDuration(100);
scrollAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int animValue = (int) animation.getAnimatedValue();
blankLp.height = animValue;
blankView.findViewById(R.id.blank_content).setLayoutParams(blankLp);
for (int i = 0; i < range; i++) {
oldNotifications.get(i).setAlpha(1 - (animValue * 1.0f * ((i + 1))) / (3 * blankViewHeight));
}
}
});
scrollAnim.start();
}
/**
* 最大值的时候,就是反弹的时候,我们将旧通知的部分 Item 透明度设置 0 不可见
*/
if (blankLp.height >= blankViewHeight) {
blankLp.height = blankViewHeight;
for (int i = 0; i < range; i++) {
oldNotifications.get(i).setAlpha(0);
}
}
blankView.findViewById(R.id.blank_content).setLayoutParams(blankLp);
}
/**
* 前一个值赋给上一个值,用于计算滑动的距离
*/
lastVerticalOffset = currentVerticalOffset;
}
});
最后我们还需要监听 RecyclerView 的手势,判断是否到达了底部,做相应的处理
mViewBinding.notificationList.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
final View blankView = getBlankView();
if (blankView != null && blankLp.height > 0 && blankLp.height < blankViewHeight) {
blankLp.height = blankViewHeight;
blankView.findViewById(R.id.blank_content).setLayoutParams(blankLp);
/**
* 获取头部距离顶部的高度
*/
int topOffset = mViewBinding.notificationList.getChildAt(0).getTop();
/**
* 头部回弹
*/
mViewBinding.notificationList.scrollBy(0, topOffset);
} else if (blankView != null && !isBottom()) {
/**
* 占位 view 高度为 0,释放手指回弹头部
*/
int topOffset = mViewBinding.notificationList.getChildAt(0).getTop();
int bottomOffset = mViewBinding.notificationList.getChildAt(0).getBottom();
mViewBinding.notificationList.scrollBy(0, Math.abs(topOffset) > Math.abs(bottomOffset) ? bottomOffset : topOffset);
}
}
return true;
}
});
最后可以查看我们的效果 (粉红色部分就是占位的 View 实际开发的时候和背景色一致)
最后附上源码地址 仿 iOS 解锁时通知列表