Android 悬浮窗实现系统手势方案

733 阅读6分钟

背景

Android10 系统自带有全局的手势方案,实现 back、home、recent 等按键。主要是在 systemui 和 原生的 launcher3 上进行实现。

如果我们想要实现一套全局手势方案,可以怎么实现呢?

基本原理:

系统获取全局全局的触摸事件,通过 AIDL 传递给系统服务,判断是否符合手势的逻辑,执行发送 keycode 实现 back、home 等操作。

本文重点介绍手势服务的实现。

设计

将手势服务进行功能拆解:

  1. 稳定性,全局,最好是作为系统服务,不被 kill
  2. 在系统端进行传递 Touch Event
  3. 自定义悬浮窗 view
  4. 接收 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 的值。

5817845230668.png

写入的方式如下:

mLayoutParams.x = mViewX;
mLayoutParams.y = mViewY;

执行按键动作

这一步是最简单,只需要调用 Android 的 keycode 就可以实现。

总结

我们通过上面的核心模块,基本上能实现基于悬浮窗的手势服务方案。

并且学会自己完全使用代码进行绘制 View。

感兴趣的读者,可以考虑比如截屏手势等类似实现方案。