作者: zibuyuqing | 来源:发表于2018-04-04 11:34 被阅读0次

    项目地址:https://github.com/zibuyuqing/RoundCorner
    app体验地址:https://www.coolapk.com/apk/180019

    需求分析

    目前大部分手机的通知都是以顶部弹出框的形式提示用户,这样的设计肯定是符合交互习惯的,但是对于爱玩jiji的我,这种逻辑还不够刺激,甚至有些死板,那就要思考了——用什么方式能即不影响用户操作又能告诉用户来通知了呢?想起之前看过一款曲面屏手机来电时的效果(屏幕朝下时我们依然可以通过那个曲面看到炫酷的光线),来了灵感,姑且就撸一套通知系统吧,也为手机加点灵气。

    效果展示

    当我们手机来通知时,屏幕边缘会产生炫酷的光效,目前实现了五种动画,分别是:转圈圈、一飞冲天、淡入淡出、中出(不要在意名字,根据动画特点取得,噗...)和闪点点,动画的效果均可自定义,我截取了一部份无码动图,请勿传播:


    转圈圈.gif 一飞冲天.gif 淡入淡出.gif 中出.gif

    所用技术

    想要实现此项目,您需要了解一下技术点,我在这里简单描述一下

    1.通知监听

    通知的监听有两种方式可以实现:一种是通过无障碍AccessibilityService监听通知栏(常见的抢红包助手就是这货搞的),来通知时我们就启动动画,但是这种方式不方便对通知识别,因此做应用的绑定有些困难;另外一种是通过NotificationListenerService实现(桌面上的应用通知角标是这货搞的),里面有个onNotificationPosted方法,可以很轻松的知道发通知的应用是哪个。很明显,后者比较适合这个项目。

    2.悬浮窗

    想要在任何界面都可以出现,那只有悬浮窗咯,当然这里的“任何”只针对android7.0以下的版本,8.0的系统做了很多限制,悬浮窗在某些界面出不来的。

    3.自定义view

    不难不难,搞定path就行。

    4.保活

    保活的方式很多,比如前台进程、双进程、JobService,广播监听等,android 5.0以前,通过一定的手段,可以保证应用不被杀死(也就是流氓软件),但是在6.0以后的android系统中,你想要完全保活是不可能了,除非你和某个手机大佬比较熟,把你的应用加入白名单,否则就别想了。项目中使用双进程保活,配合自启动,效果还不错(心态稳)。

    view实现

    1.构造Path

    Path是啥?做Android开发的同学应该都知道path的强大吧,良好的path动画就像刚出浴的美人,让人有一种“莫名”的激动甚至还有点心痒痒(这是比喻)。我们这个功能的所有动画都是path实现的,那就会会她吧。

    1.1直角矩形框

    很多人想到了Path的addRect方法对吧。但是!这个绘制出来的矩形起点是屏幕的坐上角(0,0),而我们要实现的动画是左右两边对称的,这就不好控制了,所以还是老老实实的lineTo,moveTo吧

                // edge 是线宽的一半,避免所画的线被屏幕边沿遮住一半
                float edge = mStrokeWidth / 2;  // mStrokeWidth为线宽
                mPath.moveTo(mScreenWidth / 2 - edge, edge);
                mPath.lineTo(edge, edge);
                mPath.lineTo(edge, mScreenHeight - edge);
                mPath.lineTo(mScreenWidth - edge, mScreenHeight - edge);
                mPath.lineTo(mScreenWidth - edge, edge);
                mPath.close();
    

    1.2圆角矩形框

    Path里也有直接绘制圆角矩形的方法:addRoundRect(..),但是正如之前所说,我们动画是对称的,所以最好从屏幕宽度的一半开始构造

            // 因为加了屏幕圆角功能,所以要判断圆角是否已经显示了
            // mCornerSize 为圆角的半径,依此确定矩形边框圆角大小
            if (isCornersShown()) {
                //起点移到顶部中间
                mPath.moveTo(mScreenWidth / 2 - edge, edge);
                //绘制到左边沿
                mPath.lineTo(mCornerSize, edge);
                //左上角圆弧
                mPath.arcTo(new RectF(edge, edge, (mCornerSize * 2.0f + edge),
                        (mCornerSize * 2.0f + edge)), 270, -90.0f, false);
                
                //绘制左边沿
                mPath.lineTo(edge, mScreenHeight - mCornerSize - edge);
                //左下圆弧
                mPath.arcTo(new RectF(edge, (mScreenHeight - 2 * mCornerSize - edge),
                        (mCornerSize * 2.0f + edge), (mScreenHeight - edge)), 180.0f, -90.0f, false);
                
                mPath.lineTo(mScreenWidth - mCornerSize - edge, mScreenHeight - edge);
                //右下圆弧
                mPath.arcTo(new RectF((mScreenWidth - 2 * mCornerSize - edge), (mScreenHeight - 2 * mCornerSize - edge),
                        (mScreenWidth - edge), (mScreenHeight - edge)), 90.0f, -90.0f, false);
                mPath.lineTo(mScreenWidth - edge, mCornerSize + edge);
                //右上圆弧
                mPath.arcTo(new RectF((mScreenWidth - 2 * mCornerSize - edge), edge,
                        (mScreenWidth - edge), (mCornerSize * 2.0f + edge)), 0.0f, -90.0f, false);
                //回到起点
                mPath.lineTo(mScreenWidth / 2 - edge, edge);
                //闭合
                mPath.close();
            }
    

    这里注意lineTo,MoveTo,addArc和arcTo之间的区别,前人讲的很好,轻点一下:http://android.jobbole.com/83384/

    到这我们把最最重要的path构建好了,欣赏一下:


    QQ截图20180404134753.png

    2.实现动画

    2.1准备工作

    (1)构思:
    动画形式的灵感来源于街边的广告牌,在屏幕四边绘制彩色线条,配合明暗变化达到闪烁效果,线条的长短是截取刚刚绘制的path的一部分,随时间改变截取的长度和位置。本案例实现了五种动画效果,具体长什么样可参照预览图或体验apk(不大不大,1M多)
    (2)准备材料
    这里准备材料是要准备我们的画笔,颜色渲染器等。画笔style要设置成STROKE,由于我们还要使用混合色,那就要设置Shader了,用哪个呢?想来想去还是LinearGradient比较适合

            mColorShader = new LinearGradient(0, 0, mScreenWidth, mScreenHeight, mMixedColorArr, null, Shader.TileMode.MIRROR);
            mMixedPaint.setShader(mColorShader);
    

    2.2动画实现

    我个人比较偏爱属性动画,先来定义一个ValueAnimator,然后我们设置好动画进度监听接口,这也是整个动画系统的驱动者

            //动画状态监听
            mAnimatorListener = new AnimatorListenerAdapter() {
                @Override
                public void onAnimationCancel(Animator animation) {
                    super.onAnimationCancel(animation);
                    hide(true);
                    mCurrentRepeatCount = 0;// 记录动画重复次数
                }
    
                @Override
                public void onAnimationRepeat(Animator animation) {
                    super.onAnimationRepeat(animation);
                    mCurrentRepeatCount++;
                }
    
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    hide(false);
                    mCurrentRepeatCount = 0;
                }
    
                @Override
                public void onAnimationStart(Animator animation) {
                    super.onAnimationStart(animation);
                }
            };
            //动画进度监听
            mValueUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    mProgress = (float) animation.getAnimatedValue();
                    if (mStateListener != null) {
                        mStateListener.onAnimationRunning(mProgress);
                    }
                    flush(); // 刷新view属性
                }
            };
            mValueAnimator = ValueAnimator.ofFloat(0, 1);
            mValueAnimator.addUpdateListener(mValueUpdateListener);
            mValueAnimator.addListener(mAnimatorListener);
    

    代码中出现了hide(boolean immediately)和flush()两个方法,其中hide方法是动画结束或者在某些突发情况下让WindowManager把这个view移除掉,参数标记是否立即移除,如果为true我们直接调用removeView,如果为false,我们在执行一个渐隐的动画,不至于那么突兀。flush()炒鸡简单,就是更新view的

        private void flush() {
            if (needChangeAlpha) {
                mMixedPaint.setAlpha((int) (mProgress * 255));
                mPrimaryPaint.setAlpha((int) (mProgress * 255));// 绘制单个色调的画笔
            }
            invalidate();
        }
    

    万物基于progress,这个代表动画的进度,对view和path的处理依据这货,它的值是:(float) animation.getAnimatedValue(),由于我们的过程是0到1,所有计算起来贼方便。
    ok,前期准备工作完成,我们来讲重点吧。
    (1)淡入淡出效果
    这个动画是最简单的,我们做个开胃菜,但是光有渐隐渐显的效果还不够,我们还需要让颜色“流动”起来,视觉上看上去更绚丽,来,上Matrix

        private void drawFadeInOutStyle(Canvas canvas) {
            mTranslationX = mProgress * mScreenWidth;
            mTranslationY = mProgress * mScreenHeight;
            mGradientMatrix.setTranslate(mTranslationX, mTranslationY);
            mColorShader.setLocalMatrix(mGradientMatrix);
            canvas.drawPath(mPath, mMixedPaint);
        }
    

    简单吧,效果参照预览图或体验apk。
    (2)闪点点效果
    大致知道path怎么用了吧,我们来增加点难度,做一下大部分广告灯的效果——闪点点。这里们会用到DashPathEffect,因为我们的Path是整个闭环,要做出“点”的效果,就要用虚线了。看一下这货的构造函数

    public DashPathEffect( float[] intervals,float phase)
    

    第一个参数是一个float数组,长度必须是偶数且>=2,指定了多少长度的实线之后再画多少长度的空白。例如我的是这样的 mPathIntervals = new float[]{mStrokeWidth, mScreenHeight / 80},翻译过来就是每隔 mScreenHeight / 80画一条长为mStrokeWidth的线;第二个参数指定了绘制的虚线相对了起始地址(Path起点)。我们来看一下代码

        private void drawLatticeStyle(Canvas canvas) {
            // step 是混合色数组的index 通过变化我们实现不同颜色切换
            int step = mCurrentRepeatCount % 3; // mCurrentRepeatCount 当前动画重复次数
            mPhase = (int) (step * mPathIntervals[1] / 3);
            mPrimaryPaint.setColor(mMixedColorArr[step]);
            // 渐隐效果
            mPrimaryPaint.setAlpha((int) (mProgress * 255));
            mPathEffect = new DashPathEffect(mPathIntervals, mPhase);
            mPrimaryPaint.setPathEffect(mPathEffect);
            canvas.drawPath(mPath, mPrimaryPaint);
        }
    

    这样我们就实现了闪点效果,之前没贴图,这里我们看一下


    闪点点.gif

    当然,欢迎体验apk
    (3)中出
    余下来的三种动画都是在特定的progress截取Path的不同部位,通过控制线条的长短和位置,然后加上显隐变化来实现,这里我们挑最复杂的中出来讲吧。


    Screenshot_2018-04-11-15-07-32-198_com.miui.home.png

    原谅我画的图比较乱,大家看了这个图可能会很懵逼,没事,我可能解释的更懵逼。
    先看一下图中的单词意思
    startP : 整条Path的绘制起点
    LSP :left start position 左边线条的起始点
    LEP :left end position 左边线条终止点
    RSP : right start position 右边线条起始点
    REP : right end position 右边线条终止点
    distance:线条的长度
    既然这里有了左右两条线段,我们就要分两边来分别计算起始点,并且根据progress的不同动态改变起始点,达到移动的效果。
    因为这里是顺时针转的,为了方便,我将参考点设置为各边的终点。这里要注意“点”的获取,以起始点为例,我们知道了起始点的progress(总progress为1),
    那起始点 startPosition = pathLength * startProgress,而pathLength的获取方法如下

    PathMeasure measure = new PathMeasure();
    measure.setPath(path,false);
    pathLength = measure.getLength();
    

    ok,下面看代码

    private void drawMiddleOutStyle(Canvas canvas) {
            Path leftMiddlePath = new Path();
            // 整个一圈的 progress 为 1
            // 初始化进度 屏幕左边缘中点 即 1/4
            float leftReferencePro = 0.25f;
    
            Path rightMiddlePath = new Path();
            // 初始化进度 屏幕右边缘中点 即 3/4
            float rightReferencePro = 0.75f;
            // 线宽设置为屏幕高度的 1/4 ,可随意
            float distance = mScreenHeight >> 2;
    
            // range的设置是为了在动画刚开始时做一个线段有短变长的效果
    
            float offsetProcess = distance / mPathLength;
            float range = offsetProcess;
    
    
            if (mProgress < range) {
                // 当总进度小于range时 即线段长小于distance时 我们让线段不断变长
    
                // 改变左边起始点
                float leftStartPro = leftReferencePro - mProgress;
                float leftEndPro = leftReferencePro;
                // 改变右边起始点
                float rightStartPro = rightReferencePro - mProgress;
                float rightEndPro = rightReferencePro;
                //截取path并绘制
                mPathMeasure.getSegment(leftStartPro * mPathLength, leftEndPro * mPathLength, leftMiddlePath, true);
                canvas.drawPath(leftMiddlePath, mMixedPaint);
                mPathMeasure.getSegment(rightStartPro * mPathLength, rightEndPro * mPathLength, rightMiddlePath, true);
                canvas.drawPath(rightMiddlePath, mMixedPaint);
    
            } else {
    
                // 当总进度>=range时 即线段长度达到distance时 我们固定线长
    
                //左边移动线段的起始progress
                float leftCursorStartPro = leftReferencePro - offsetProcess;
    
                //右边移动的线段
                Path rightCursorPath = new Path();
                float rightCursorStartPro = rightReferencePro - offsetProcess;
    
                // 右边移动线段起始点
                float rightPosition = rightCursorStartPro * mPathLength;
    
                if (leftCursorStartPro >= -offsetProcess) {
               
                    Path leftCursorPath = new Path();
                    float leftPosition = leftCursorStartPro * mPathLength;
                    mPathMeasure.getSegment(leftPosition, leftPosition + distance, leftCursorPath, true);
                    canvas.drawPath(leftCursorPath, mMixedPaint);
                }
                if (leftCursorStartPro < 0) {
                    // 当左边到达起始点时,线段会逐渐缩短到0,这个时候我们需要补上一条新的path,否则就断片了
                    Path replenishPath = new Path();
                    float replenishPro = 1.0f - Math.abs(leftCursorStartPro);
                    float repStartPosition;
                    float repEndPosition;
                    // 为什么要0.5呢,因为走到一半要缩小
                    if (replenishPro > 0.5) {
                        repStartPosition = replenishPro * mPathLength;
                        repEndPosition = repStartPosition + distance;
                    } else {
                        repStartPosition = 0.5f * mPathLength;
                        repEndPosition = repStartPosition + mPathLength * (offsetProcess + rightCursorStartPro);
                    }
                    // 补充线段绘制
                    mPathMeasure.getSegment(repStartPosition, repEndPosition, replenishPath, true);
                    canvas.drawPath(replenishPath, mMixedPaint);
                }
                // 因为右边的游动线段起始是从0.75开始的,可以一直减到0
                mPathMeasure.getSegment(rightPosition, rightPosition + distance, rightCursorPath, true);
                canvas.drawPath(rightCursorPath, mMixedPaint);
    
            }
    
        }
    

    看吧,这一个动画就用了好几条path,比较复杂,好得注释比较详细,如果把这动画搞懂了,另外的动画都是小case了,这里我就不讲了,感兴趣的可以看一下源码哈。

    控制实现

    通知监听

    android中获取通知的方法通常有两种——无障碍和NotificationListenerService,无障碍就像爬虫,首先是不停的监听内容变化,然后对内容解析,提取我们想要的东西,比较麻烦还不稳定,还好系统有个叫NotificationListenerService的服务,这货可以很方便的告诉我们发起通知的是谁以及通知的内容是啥,太适合此功能的实现了。我们来看看怎么用
    (1)定义服务
    写一个NotificationListener继承自NotificationListenerService,然后重写onNotificationPosted方法,这个方法会传入一个StatusBarNotification的对象,通过这个对象,我们可以获取通知的内容

      @Override
        public void onNotificationPosted(StatusBarNotification sbn) {
            super.onNotificationPosted(sbn);
    
            String who = sbn.getPackageName();//获取发起通知的包名
            Notification notification = sbn.getNotification();//获取通知对象
            Bundle extras = sbn.getNotification().extras;
            String notificationTitle = extras.getString(Notification.EXTRA_TITLE);//通知标题
            CharSequence notificationText = extras.getCharSequence(Notification.EXTRA_TEXT);//获取通知内容
            if(sNotificationsChangedListener != null){
                sNotificationsChangedListener.onNotificationPosted(who);
            }
        }
    

    当然还有很多方法,感兴趣的同学可以研究一下google的文档。
    (2)在manifest文件里声明定义好的NotificationListener

            <service android:name="com.zibuyuqing.roundcorner.service.NotificationListener"
                android:label="@string/enhance_notification"
                android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
                <intent-filter>
                    <action android:name="android.service.notification.NotificationListenerService" />
                </intent-filter>
            </service>
    

    (3)申请权限
    要成功监听通知,需要得到系统的许可,毕竟这个是比较“危险”的行为,这个权限需要动态申请,当然不同的手机由于系统定制性不同,方案是不一样的,有的需要手动开启,这里我使用一种比较通用的方法

        /**
         * 检查是否有权限
         * @param context
         * @return
         */
        public static boolean checkNotificationListenPermission(Context context) {
            // 获取允许监听通知的包
            Set<String> packageNames = NotificationManagerCompat.getEnabledListenerPackages(context);
            if (packageNames.contains(context.getPackageName())) {
                return true;
            }
            return false;
        }
    
    
        /**
         * 申请权限
         */
        private void requestNotificationListenPermission() {
            try {
                Intent intent = new Intent(ACTION_NOTIFICATION_LISTENER_SETTINGS);
                startActivity(intent);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    

    控制通知线的显隐

    优化&适配

    是否有导航栏

    横竖屏切换

    保活&自启动

    相关文章

      网友评论

          本文标题:

          本文链接:https://www.haomeiwen.com/subject/udhkhftx.html