最近公司的项目要仿照 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 让其加载消息的内容即可
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="notification"
type="kuky.com.iosnotificationapplication.NotificationBean" />
</data>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="16dp"
android:text="@{notification.notificationContent}"
android:textColor="#AA000000"
android:textSize="18sp"
tools:text="Notification" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_alignParentBottom="true"
android:background="#FFFFFF" />
</RelativeLayout>
</layout>
然后,我们的占位 View 也是列表数据中的一个,所以我们也需要将其布局定义出来
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<View
android:id="@+id/blank_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/colorAccent" />
</FrameLayout>
</layout>
这里有个坑,虽然我们的占位 View 就一个子控件,但是我们还是需要给嵌套一层父布局,因为 RecyclerView 不允许直接修改 ViewHolder 的 LayoutParam,一定要注意。
然后我们定义自己的 adapter
/**
* 因为需要多种数据类型,所以我们这边的数据的类型定义为 Object
*/
public class NotificationAdapter extends BaseRvHeaderFooterAdapter<Object> {
public static final int NOTIFICATION_TYPE = 0;
public static final int BLANK_TYPE = 1;
public static final int OTHER_TYPE = 2;
public NotificationAdapter(Context context, List<Object> data) {
super(context, data);
}
protected int getLayoutId(int viewType) {
// 根据返回的 Type 返回不同的布局
switch (viewType) {
case NOTIFICATION_TYPE:
return R.layout.item_notification;
case BLANK_TYPE:
return R.layout.item_blank;
default:
return 0;
}
}
protected void setVariable(ViewDataBinding binding, Object o) {
// 将 NotificationBean 的数据绑定到视图
if (o instanceof NotificationBean) {
binding.setVariable(BR.notification, o);
}
}
protected int getItemType(int position) {
// 根据数据类型判断是否为消息分组还是空的占位 View
if (mData.get(position) instanceof NotificationBean) {
return NOTIFICATION_TYPE;
} else if (mData.get(position) instanceof String) {
return BLANK_TYPE;
} else {
return OTHER_TYPE;
}
}
}
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<Long>() {
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() {
public void run() {
initOldNotificationArea();
}
}, 50);
}
}
});
获取到必要的值后,我们需要通过 RecyclerView 的 OnScrollListener 来设置 View 的高度的变化
mViewBinding.notificationList.addOnScrollListener(new RecyclerView.OnScrollListener() {
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<View> 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() {
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() {
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 解锁时通知列表