背景
Android10 系统自带有全局的手势方案,实现 back、home、recent 等按键。主要是在 systemui 和 原生的 launcher3 上进行实现。
如果我们想要实现一套全局手势方案,可以怎么实现呢?
基本原理:
系统获取全局全局的触摸事件,通过 AIDL 传递给系统服务,判断是否符合手势的逻辑,执行发送 keycode 实现 back、home 等操作。
本文重点介绍手势服务的实现。
设计
将手势服务进行功能拆解:
- 稳定性,全局,最好是作为系统服务,不被 kill
- 在系统端进行传递 Touch Event
- 自定义悬浮窗 view
- 接收 Touch Event 事件,并根据触摸的状态绘制对应的 view 图案
系统服务
作为一个系统的全局手势,我们希望一直都存在,不会因为各种原因导致被 kill。
对此,我们可以参考其它的 system server 的实现。
系统传递 Touch 事件
想要在系统端传递 Touch Event,我们可以有几个方案:比如反射,或者 AIDL 等方案。
为了防止该服务被三方发现和使用,可以使用 AIDL 的方案。
也可以直接在系统的源码中集成,但是这种会有一定的耦合性。
自定义系统服务
作为一个系统级服务,需要确保万一出现异常,可以被重启,所以我们可以借助看门狗的机制。
让自定义的服务继承看门狗的服务。
public class GestureManagerService extends IGestureManager.Stub
implements Watchdog.Monitor {
@Override
public boolean handleTouchEvents(MotionEvent event) throws RemoteException {
// 传递 Touch Event
}
private void startApplicationService() {
// 实例化 View 和 Layout 类
}
public static final class Lifecycle extends SystemService {
private final GestureManagerService mService;
public Lifecycle(final Context context) {
super(context);
mService = new GestureManagerService(context);
}
@Override
public void onStart() {
mService.start();
}
@Override
public void onBootPhase(int phase) {
if (phase == SystemService.PHASE_BOOT_COMPLETED) {
mService.startApplicationService();
}
}
public GestureManagerService getService() {
return mService;
}
}
启动
在 system server 中导入对应的 jar 包,并启动服务,当然作为系统服务,需要保证服务的稳定性,否则会影响到整个系统。
private void startOtherServices() {
dalvik.system.PathClassLoader loader = new dalvik.system.PathClassLoader(
"/system/framework/gesture_service.jar",
context.getClassLoader());
Class GestureServiceClazz = loader.loadClass(
"com.gesture.server.GestureManagerService");
Class GestureServiceLifecycleClazz = loader.loadClass(
"com.gesture.server.GestureManagerService$Lifecycle");
Object systemService = mSystemServiceManager.startService(
GestureServiceLifecycleClazz);
上述基本上可以作为一个系统服务的框架进行使用,这里省略 mk 相关编译的创建。
自定义悬浮窗
来到我们下一个重点,对于全局的时候,需要有一个全局的反馈,最好的方式是通过 UI 进行直观的呈现。
那么如何实现这样的动态 UI 呢?
最好的方案是通过自定义悬浮窗 View 实现。
接下来以实现一个左右侧边滑动的 back 手势的 UI 为例。
无论实现多么复杂的功能,要做的是将功能模块进行拆解,直到拆成一个个最小的单元,也就基本上实现完了。
动态绘制,离不开 view 和 layout 两部分,再加上一点算法。
考虑一下,这个全局的悬浮窗,要确保在屏幕的两侧都可以绘制手势的 UI,有什么方案可以实现呢。
方案一:我们将 layout 设置为全屏大小,在需要显示的区域,将 View 移动过去。
方案二:我们将 layout 固定为 View 的大小,在需要显示的区域,移动 layout 过去。
二者的优缺点,读者可自行思考一下。
接下来我们一个解决如何设计的问题。
如何定义对应的 layout
我们需要定义一个 layout 满足以下特征:处于顶部,不可触摸,支持透明。
具体代码如下:
public class GestureFrameLayout extends FrameLayout {
mWindowManager = (WindowManager) mContext.getApplicationContext().getSystemService(
mContext.WINDOW_SERVICE);
mLayoutParams = new WindowManager.LayoutParams();
// 必须设置为 TYPE_SYSTEM_ERROR 才置顶, 单独设置 TYPE_SYSTEM_OVERLAY 无效
mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ERROR |
WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY;
mLayoutParams.format = PixelFormat.TRANSLUCENT;// 支持透明
mLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
// 窗口的宽和高
// 上述方案一的初始化
//mLayoutParams.width = FrameLayout.LayoutParams.MATCH_PARENT;
//mLayoutParams.height = FrameLayout.LayoutParams.MATCH_PARENT;
// 上述方案二的初始化
mLayoutParams.width = mViewWidth;
mLayoutParams.height = mViewHeight;
// 窗口位置的偏移量
mLayoutParams.x = 0;
mLayoutParams.y = 0;
}
对于 LayoutParams 的具体参数定义,可自行 google。
如何自定义 View
我们再次拆解功能项。
这个 View 应该需要满足那些功能:非常重要的点就是需要可以动起来,所以动画是必不可少的。
那具体怎么动呢,需要我们定义一个样式。
为了让我们通过代码绘制的更加圆滑有美感,可以考虑使用贝塞尔曲线进行绘制。
关于贝塞尔曲线的原理和用法网上有大量的资料,这里就不罗列。
原理就是定义一个起点和一个终点,并通过增加中间两个控制点,实现半弧的效果。
读者也可以根据自身的需求设置不同的控制点,绘制不同效果的曲线。
考虑方便使用,我们定义一个接口类,并进行实现。
样式的类定义如下
public class DefaultSlideView implements ISlideView {
private Path bezierPath;
private Paint paint, arrowPaint;
private int backViewColor = 0xff000000;
public DefaultSlideView(Context context) {
init(context);
}
public void setBackViewColor(int backViewColor) {
this.backViewColor = backViewColor;
}
public void setArrowColor(int arrowColor) {
this.arrowColor = arrowColor;
}
private void init(Context context) {
bezierPath = new Path();
paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.FILL);
paint.setColor(backViewColor);
paint.setStrokeWidth(Utils.d2p(context, 1.5f));
arrowPaint = new Paint();
arrowPaint.setAntiAlias(true);
arrowPaint.setStyle(Paint.Style.FILL_AND_STROKE);
arrowPaint.setColor(arrowColor);
arrowPaint.setStrokeWidth(Utils.d2p(context, 1.5f));
arrowPaint.setStrokeCap(Paint.Cap.ROUND);
}
@Override
public void onDraw(Canvas canvas, float currentWidth) {
float height = getHeight();
int maxWidth = getWidth();
float centerY = height / 2;
float progress = currentWidth / maxWidth;
if (progress == 0) {
return;
}
paint.setColor(backViewColor);
paint.setAlpha((int) (200 * progress));
float bezierWidth = currentWidth / 2;
float coordinateX = 0;
coordinateX = maxWidth;
bezierWidth = getOpposite(bezierWidth);
bezierPath.reset();
bezierPath.moveTo(coordinateX, 0);
// 可以调整下面的一些参数达到不同的效果
bezierPath.cubicTo(coordinateX, height / 4f, bezierWidth, height * 3f / 8, bezierWidth, centerY);
bezierPath.cubicTo(bezierWidth, height * 5f / 8, coordinateX, height * 3f / 4, coordinateX, height);
canvas.drawPath(bezierPath, paint);
arrowPaint.setColor(arrowColor);
arrowPaint.setAlpha((int) (255 * progress));
//画箭头,这里可以替换其它的图案
float arrowLeft = currentWidth / 6;
if (progress <= 0.2) {
//ingore
} else if (progress <= 0.7f) {
//起初变长竖直过程
float newProgress = (progress - 0.2f) / 0.5f;
canvas.drawLine(arrowLeft, centerY - arrowWidth * newProgress, arrowLeft,
centerY + arrowWidth * newProgress, arrowPaint);
} else {
//后面变形到完整箭头过程
float arrowEnd;
arrowEnd = arrowLeft + (arrowWidth * (progress - 0.7f) / 0.3f);
canvas.drawLine(arrowEnd, centerY - arrowWidth, arrowLeft, centerY, arrowPaint);
canvas.drawLine(arrowLeft, centerY, arrowEnd, centerY + arrowWidth, arrowPaint);
}
}
}
自定义 View 实现,主要是继承 View,并且根据滑动的距离,从隐藏到逐渐显示。
class SlideBackView extends View {
private ISlideView slideView = new DefaultSlideView(context);
private ValueAnimator animator;
private float rate = 0;//曲线的控制点
// 进行绘制
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
slideView.onDraw(canvas, rate);
}
public void updateRate(float updateRate, boolean hasAnim) {
if (updateRate > slideView.getWidth()) {
updateRate = slideView.getWidth();
}
if (rate == updateRate) {
return;
}
cancelAnim();
if (!hasAnim) {
rate = updateRate;
invalidate();
if (rate == 0) {
setVisibility(GONE);
}else{
setVisibility(VISIBLE);
}
}
animator = ValueAnimator.ofFloat(rate, updateRate);
animator.setDuration(200);
animator.addUpdateListener(animation -> {
rate = (Float) animation.getAnimatedValue();
postInvalidate();
if (rate == 0) {
// 隐藏 View
setVisibility(GONE);
}else{
// 显示 View
setVisibility(VISIBLE);
}
});
animator.setInterpolator(DECELERATE_INTERPOLATOR);
animator.start();
}
private void cancelAnim() {
if (animator != null && animator.isRunning()) {
animator.cancel();
}
}
@Override
protected void onDetachedFromWindow() {
cancelAnim();
if (rate != 0) {
rate = 0;
invalidate();
}
super.onDetachedFromWindow();
}
}
如何将 View 加载进去
有了上面的 layout 和 view,接下来就是如何将他们加载到一起的问题了。
可以使用 addView 进行实现。
mWindowManager.addView(slideBackView, mLayoutParams);
用完则将 View 进行释放。
mWindowManager.removeView(slideBackView);
初始化 View
slideBackView = new SlideBackView(context, slideView);
此部分在 GestureFrameLayout 中实现。
如何手势 UI 跟手
首先我们需要拿到触摸屏幕的 Touch Event Down 事件。
并判断是否满足我们执行 back 的条件,如果是则记录下拉对应 Touch Event 的 X 和 Y 值。
上面我们谈到 layout 和 View 两种实现方式。
对于方案一,我们需要移动的是 View 的位置。
由于是侧滑所以我们需要让 View 贴着屏幕的侧边,在考虑 View 本身的大小,计算如下。
其中 横坐标为在屏幕的最左边 0 和最右边 screenWidth - ViewWidth。
竖坐标为 mDownY - (mSlideViewHeight / 2) - screenHeight
但是这里的值都需要设置为负数,原因是跟底层的 View 实现有关系。
slideBackView.scrollTo(mViewX, mViewY);
方案二,我们需要移动的是 layout 的位置。
但是问题来了,这个 x 和 y 应该设置多少呢?
我们写入的 layout 系统会分配到屏幕的正中间。
通过下面的 View 坐标,相信读者可以想到具体如何转换为 x、y 的值。
写入的方式如下:
mLayoutParams.x = mViewX;
mLayoutParams.y = mViewY;
执行按键动作
这一步是最简单,只需要调用 Android 的 keycode 就可以实现。
总结
我们通过上面的核心模块,基本上能实现基于悬浮窗的手势服务方案。
并且学会自己完全使用代码进行绘制 View。
感兴趣的读者,可以考虑比如截屏手势等类似实现方案。