Android 实现 iOS 上锁时通知列表效果

最近公司的项目要仿照 iOS 解锁时通知列表的效果,有 iPhone 的朋友可以自己看自己手机,没有的,要不问有的借个,然后观摩下~~或者看最后的实现效果,虽然说没有完全像,应该也有 7-8 分像吧。

既然是列表,好吧第一时间想到的就是 RecyclerView,然后顶部的时间日期播放器等等等,就放到一个头部就 OK 了,放一张手画的解决方案(字丑请无视),然后我们再慢慢分析。
Android 实现 iOS 上锁时通知列表效果_第1张图片
解决思路

列表大概分 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 {
    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 data) {
        super(context, data);
    }

    @Override
    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;
        }
    }

    @Override
    protected void setVariable(ViewDataBinding binding, Object o) {
        // 将 NotificationBean 的数据绑定到视图
        if (o instanceof NotificationBean) {
            binding.setVariable(BR.notification, o);
        }
    }

    @Override
    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,我们只需要判断两种情况:

  1. 如果最后一个可见的 View 是新的消息通知,那我们就不需要去设置占位 View 了
  2. 如果最后一个可见的 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 实际开发的时候和背景色一致)

Android 实现 iOS 上锁时通知列表效果_第2张图片
实现效果

最后附上源码地址 仿 iOS 解锁时通知列表

你可能感兴趣的:(Android 实现 iOS 上锁时通知列表效果)