项目地址: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();
}
}
网友评论