1. 例子
先来看两个例子。
1.1 小米手机上收到通知时
1.2 锤子手机上复制链接时
都是在屏幕上方显示了一个悬浮的通知,并且这个通知是在切换 activity 的时候或者按返回键的时候不消失的,当你横向滑动的时候才消失。
下面来实现类似的效果。
本文最后实现的效果如下:
2. 实现
由于悬浮通知不受到 activity 的控制,因此需要使用悬浮窗来实现。但是先不管悬浮窗,先实现一个可以随手滑动的消失的 view。
这个效果要满足以下几个要求:
- 当手指在屏幕上滑动不松开的时候,悬浮通知也随着手指滑动。
- 当手指松开的时候,判断滑动方向,来决定是向右还是向左消失。
- 当快速滑动(fling)的时候,朝着对应的方向消失。
- 滑动的时候,透明度(alpha)也相应地跟着变化。
可以重写 onTouch
方法,但是那样比较麻烦。这里使用一个 GestureDetector 类,可以方便的判断手势。
GestureDetector 的构造方法之一是:
public GestureDetector(Context context, OnGestureListener listener)
传入两个参数,第二个参数是一个手势监听回调;在回调中就可以获取各种位置信息,来进行处理。
创建 GestureDetector 的代码如下:
mDetector = new GestureDetector(context, new GestureDetector.OnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
return false;
}
@Override
public void onShowPress(MotionEvent e) {
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
float x1 = e1.getX();
float x2 = e2.getX();
int offsetX = (int) (x2 - x1);
layoutWithAlpha(getLeft() + offsetX, getTop(), getRight() + offsetX, getBottom());
return false;
}
@Override
public void onLongPress(MotionEvent e) {
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
Log.d(TAG, "onFling: =" + (e2.getX() - e1.getX()) + " velocityX=" + velocityX);
float fling = e2.getX() - e1.getX();
if (fling > 0) {
disappear(DIRECTION_LEFT);
} else {
disappear(DIRECTION_RIGHT);
}
return false;
}
});
其中,在 onScroll 方法中,计算出要滑动的位置:
float x1 = e1.getX();
float x2 = e2.getX();
int offsetX = (int) (x2 - x1);
e1 代表的是手指最初按下去的时候的 MotionEvent,e2 代表的是滑动到执行该 onScroll
的时候手指的位置,e1.getX() - e2.getX()
即代表在 x 方向上滑动了多少。
接下来通过 layoutWithAlpha
来重新摆放该 view 的位置并设置 alpha 值,传入 4 个参数分别是左上右下的值。
private void layoutWithAlpha(int l, int t, int r, int b) {
layout(l, t, r, b);
float alpha = (screenWidth - Math.abs(getLeft())) / screenWidth;
setAlpha(alpha);
}
layoutWithAlpha
这个方法其实就是调用 View 内部的 layout 来重新摆放位置,然后计算出相应的 alpha 值。
再看一下 onFling
方法。onFling
方法中的 e1 和 e2 与 onScroll
方法中的是一样的。onFling
先通过 e2.getX() - e1.getX()
判断滑动的方向,大于 0 表示向左滑动,反之向右。
这里 disappear
方法代码如下:
private void disappear(int direction) {
final int speed = direction == DIRECTION_LEFT ? 66 : -66;
Runnable r = new Runnable() {
@Override
public void run() {
int left = getLeft();
int right = getRight();
layoutWithAlpha(left + speed, getTop(), right + speed, getBottom());
if (left < screenWidth && right > 0) {
post(this);
}
}
}
};
post(r);
}
这里 66 是我随便设定的值,表示一次性移动多少 px。 disappear 方法就是不断的调用 layout 来进行移动,此时,SwipeNotification
的所有代码如下,但是此时 SwipeNotification
还只能放在 activity 内:
public class SwipeNotification extends LinearLayout {
private static final String TAG = "SwipeNotification";
private static final int DIRECTION_LEFT = 1;
private static final int DIRECTION_RIGHT = 2;
private static final int SPEED_PX = 66;
private GestureDetector mDetector;
private boolean isDisappearing = false;
private ImageView mImageView;
private TextView mTextView;
private static float screenWidth;
public SwipeNotification(@NonNull Context context) {
this(context, null);
}
public SwipeNotification(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SwipeNotification(@NonNull Context context, @Nullable AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
screenWidth = getScreenWidth(context);
View inflate = LayoutInflater.from(context).inflate(R.layout.item_notification, this);
mImageView = inflate.findViewById(R.id.image_view);
mTextView = inflate.findViewById(R.id.text_view);
mDetector = new GestureDetector(context, new GestureDetector.OnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
float x = e.getX();
float y = e.getY();
return false;
}
@Override
public void onShowPress(MotionEvent e) {
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
Log.d(TAG, "onSingleTapUp: ");
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
float x1 = e1.getX();
float x2 = e2.getX();
int offsetX = (int) (x2 - x1);
layoutWithAlpha(getLeft() + offsetX, getTop(), getRight() + offsetX, getBottom());
return false;
}
@Override
public void onLongPress(MotionEvent e) {
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
Log.d(TAG, "onFling: =" + (e2.getX() - e1.getX()) + " velocityX=" + velocityX);
float fling = e2.getX() - e1.getX();
if (fling > 0) {
disappear(DIRECTION_LEFT);
} else {
disappear(DIRECTION_RIGHT);
}
return false;
}
});
}
public void setText(CharSequence text) {
mTextView.setText(text);
}
public void setImageResource(int resId) {
mImageView.setImageResource(resId);
}
private void layoutWithAlpha(int l, int t, int r, int b) {
layout(l, t, r, b);
float alpha = (screenWidth - Math.abs(getLeft())) / screenWidth;
Log.d(TAG, "layoutWithAlpha: alpha = " + alpha);
setAlpha(alpha);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean detectedUp = event.getAction() == MotionEvent.ACTION_UP;
if (!mDetector.onTouchEvent(event) && detectedUp) {
onUp(event);
// return super.onTouchEvent(event);
}
return true;
}
private void onUp(MotionEvent event) {
Log.d(TAG, "onUp: ");
if (isDisappearing) {
return;
}
autoDisappear();
}
private void autoDisappear() {
Log.d(TAG, "autoDisappear: ");
if (getLeft() > screenWidth / 5) {
disappear(DIRECTION_LEFT);
} else if (getRight() < screenWidth * 4 / 5) {
disappear(DIRECTION_RIGHT);
}
}
private void disappear(int direction) {
isDisappearing = true;
final int speed = direction == DIRECTION_LEFT ? SPEED_PX : -SPEED_PX;
Log.d(TAG, "disappear: speed=" + speed);
Runnable r = new Runnable() {
@Override
public void run() {
int left = getLeft();
int right = getRight();
layoutWithAlpha(left + speed, getTop(), right + speed, getBottom());
Log.d(TAG, "run: layout");
if (left < screenWidth && right > 0) {
post(this);
}
}
};
post(r);
}
public static int getScreenWidth(Context context) {
WindowManager wm = (WindowManager)
context.getSystemService(Context.WINDOW_SERVICE);
return wm.getDefaultDisplay().getWidth();
}
}
需要注意的一点是,GestureDetector 没有判断手指抬起的时候的回调,因此需要手动在
onTouchEvent
当中捕获到这个手指抬起的动作,然后自动滑动消失。(注意:onSingleTapUp 只是单击抬起才触发,当滑动后不会再触发这个回调)
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean detectedUp = event.getAction() == MotionEvent.ACTION_UP;
if (!mDetector.onTouchEvent(event) && detectedUp) {
onUp(event);
}
return true;
}
这个悬浮通知的 xml 布局如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/shadow"
android:orientation="horizontal">
<ImageView
android:id="@+id/image_view"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="ic_launcher"
android:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center"
android:text="test" />
</LinearLayout>
然后再在 activity 的布局中引用一下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.enhao.rxjavatest.SwipeNotification
android:layout_width="match_parent"
android:layout_height="50dp" />
<com.enhao.rxjavatest.SwipeNotification
android:layout_width="match_parent"
android:layout_height="50dp" />
</LinearLayout>
ok,基本就实现了类似的效果,如下图:
但是这个是放在 activity 中的。为了实现在应用的全局内都能显示通知,就是说切换 activity,通知不消失,就要使用悬浮框了。
悬浮窗的权限申请不在本文的范围内,而且众多国产 rom 的对悬浮窗的权限管理不太一样。适配起来需要费一定的劲。
本文假设已经获取到了悬浮窗的权限。
由于这个悬浮窗是应用内全局的,因此打算把它做成一个单例类。
代码如下:
public class SwipeNotificationManager {
private static final String TAG = "FloatingWindowManager";
private static SwipeNotificationManager sManager;
private WindowManager mWindowManager;
private WindowManager.LayoutParams mParams;
// 用一个 LinearLayout 作为包含所有通知的容器
private LinearLayout mNotificationContainer;
private Context mContext;
// 用来记录包含通知的 LinearLayout 是否已经添加到窗口里了
private boolean isAdded;
// 最大允许显示的通知条目数量
private int mMaxCount = 3;
private SwipeNotificationManager(Context context) {
mContext = context;
mParams = new WindowManager.LayoutParams();
// 获取 WindowManager
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
// 新建一个包含所有通知的容器
mNotificationContainer = new LinearLayout(context);
mNotificationContainer.setOrientation(LinearLayout.VERTICAL);
// 设定一下 WindowManager.LayoutParams 的一些参数值
initWindowParams(mWindowManager, mParams);
// 将 mNotificationContainer 添加到窗口上
mWindowManager.addView(mNotificationContainer, mParams);
isAdded = true;
}
public static SwipeNotificationManager getInstance(Context context) {
if (sManager == null) {
synchronized (SwipeNotificationManager.class) {
if (sManager == null) {
sManager = new SwipeNotificationManager(context.getApplicationContext());
}
}
}
return sManager;
}
public void addNotification(final CharSequence text, int imageId) {
// 新建一条通知
final SwipeNotification notification = new SwipeNotification(mContext);
// 设置通知的文本和图片
notification.setText(text);
notification.setImageResource(imageId);
// 给通知添加点击事件
notification.setOnClickNotificationListener(
new SwipeNotification.OnClickNotificationListener() {
@Override
public void onClickNotification() {
Toast.makeText(mContext, text, Toast.LENGTH_SHORT).show();
}
});
// 若包含通知的 LinearLayout 已经被移除窗口,就加上去
if (!isAdded) {
mWindowManager.addView(mNotificationContainer, mParams);
Log.d(TAG, "addNotification: addView");
isAdded = true;
}
// 获取一共有多少条通知
int childCount = mNotificationContainer.getChildCount();
// 已经显示的通知数目大于了最大值,就将第一条移除
if (childCount == mMaxCount) {
mNotificationContainer.removeViewAt(0);
}
// 将通知添加进 mNotificationContainer
mNotificationContainer.addView(notification);
// 通知已经通过滑动移除出屏幕的时候,将通知从 mNotificationContainer 中移除
notification.setOnDisappearListener(new SwipeNotification.OnDisappearListener() {
@Override
public void onDisappear() {
mNotificationContainer.removeView(notification);
// 如果 mNotificationContainer 里面已经没有通知了,将 mNotificationContainer 从窗口移除
if (mNotificationContainer.getChildCount() == 0) {
mWindowManager.removeViewImmediate(mNotificationContainer);
isAdded = false;
}
}
});
}
private void initWindowParams(WindowManager manager, WindowManager.LayoutParams params) {
// android 8.0 以上,要将 type 设为 TYPE_APPLICATION_OVERLAY
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
params.type = WindowManager.LayoutParams.TYPE_PHONE;
}
mParams.format = PixelFormat.RGBA_8888;
params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
//调整悬浮窗显示的停靠位置为置顶
params.gravity = TOP;
//以屏幕左上角为原点,设置x、y初始值
params.x = 0;
params.y = 0;
//设置悬浮窗口长宽
params.width = MATCH_PARENT;
params.height = WRAP_CONTENT;
}
/**
* 设置一下最大的显示通知的数量
*/
private void setMaxCount(int maxCount) {
mMaxCount = maxCount;
}
其中,由于 GestureDetector 消耗掉了触摸事件,因此不能给 SwipeNotification 直接 setOnClickListener;为了使点击事件能够处理,需要在 GestureDetector 的 onSingleTapUp 里面添加一个回调。修改后的 SwipeNotification 代码如下:
public class SwipeNotification extends LinearLayout {
private static final String TAG = "SwipeNotification";
private static final int AUTO_DISMISS_TIME = 2000;
private static final int DIRECTION_LEFT = 1;
private static final int DIRECTION_RIGHT = 2;
private static final int SPEED_PX = 66;
private GestureDetector mDetector;
private OnDisappearListener mDisappearListener;
private OnClickNotificationListener mClickNotificationListener;
private boolean isDisappearing = false;
private ImageView mImageView;
private TextView mTextView;
private static float screenWidth;
public SwipeNotification(@NonNull Context context) {
this(context, null);
}
public SwipeNotification(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SwipeNotification(@NonNull Context context, @Nullable AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
screenWidth = getScreenWidth(context);
View inflate = LayoutInflater.from(context).inflate(R.layout.item_notification, this);
mImageView = inflate.findViewById(R.id.image_view);
mTextView = inflate.findViewById(R.id.text_view);
mDetector = new GestureDetector(context, new GestureDetector.OnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
float x = e.getX();
float y = e.getY();
return false;
}
@Override
public void onShowPress(MotionEvent e) {
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
Log.d(TAG, "onSingleTapUp: ");
if (mClickNotificationListener != null) {
mClickNotificationListener.onClickNotification();
}
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
float x1 = e1.getX();
float x2 = e2.getX();
int offsetX = (int) (x2 - x1);
layoutWithAlpha(getLeft() + offsetX, getTop(), getRight() + offsetX, getBottom());
return false;
}
@Override
public void onLongPress(MotionEvent e) {
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
Log.d(TAG, "onFling: =" + (e2.getX() - e1.getX()) + " velocityX=" + velocityX);
float fling = e2.getX() - e1.getX();
if (fling > 0) {
disappear(DIRECTION_LEFT);
} else {
disappear(DIRECTION_RIGHT);
}
return false;
}
});
}
public void setText(CharSequence text) {
mTextView.setText(text);
}
public void setImageResource(int resId) {
mImageView.setImageResource(resId);
}
private void layoutWithAlpha(int l, int t, int r, int b) {
layout(l, t, r, b);
float alpha = (1080f - Math.abs(getLeft())) / 1080f;
Log.d(TAG, "layoutWithAlpha: alpha = " + alpha);
setAlpha(alpha);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean detectedUp = event.getAction() == MotionEvent.ACTION_UP;
if (!mDetector.onTouchEvent(event) && detectedUp) {
onUp(event);
// return super.onTouchEvent(event);
}
return true;
}
private void onUp(MotionEvent event) {
Log.d(TAG, "onUp: ");
if (isDisappearing) {
return;
}
autoDisappear();
}
private void autoDisappear() {
Log.d(TAG, "autoDisappear: ");
if (getLeft() > screenWidth / 5) {
disappear(DIRECTION_LEFT);
} else if (getRight() < screenWidth * 4 / 5) {
disappear(DIRECTION_RIGHT);
}
}
private void disappear(int direction) {
isDisappearing = true;
final int speed = direction == DIRECTION_LEFT ? SPEED_PX : -SPEED_PX;
Log.d(TAG, "disappear: speed=" + speed);
Runnable r = new Runnable() {
@Override
public void run() {
int left = getLeft();
int right = getRight();
layoutWithAlpha(left + speed, getTop(), right + speed, getBottom());
Log.d(TAG, "run: layout");
if (left < screenWidth && right > 0) {
post(this);
} else {
if (mDisappearListener != null) {
mDisappearListener.onDisappear();
}
}
}
};
post(r);
}
public void setOnDisappearListener(OnDisappearListener listener) {
mDisappearListener = listener;
}
public void setOnClickNotificationListener(OnClickNotificationListener listener) {
mClickNotificationListener = listener;
}
public static int getScreenWidth(Context context) {
WindowManager wm = (WindowManager)
context.getSystemService(Context.WINDOW_SERVICE);
return wm.getDefaultDisplay().getWidth();
}
/**
* 通知滑动出去消失了的回调
*/
public interface OnDisappearListener {
void onDisappear();
}
/**
* 点击通知的回调
*/
public interface OnClickNotificationListener {
void onClickNotification();
}
}
在 activity 里面使用:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
SwipeNotificationManager mWindowManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWindowManager = SwipeNotificationManager.getInstance(this);
Button addButton = (Button) findViewById(R.id.btn_add_notification);
Button startActivityButton = (Button) findViewById(R.id.btn_start_activity);
addButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String text = "通知:" + String.valueOf(Math.random());
mWindowManager.addNotification(text, R.mipmap.ic_launcher);
}
});
startActivityButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(MainActivity.this, MainActivity.class);
startActivity(intent);
}
});
}
最终效果图如下: