Android - 泡泡View新姿势

1,303 阅读8分钟

前言

未读小红点,未读数量,很多App中都有这种需求,也有很多第三方库提供了这个功能。
本文称之为泡泡View,看了几个第三方库,没找到比较满意的,主要有以下几点:

  • 泡泡View被写死,比如固定了只能显示文字
  • 对目标View入侵严重,把泡泡View和目标View添加到了同一个容器
  • 泡泡View位置没办法自定义

为了解决以上问题,尝试了另外一种实现方案,先看一下大概效果,下面再做介绍:

实现流程分析

为了避免入侵目标View,需要把泡泡View显示在一个独立的容器里面,但这会引发以下问题:

问题1. 如何同步可见状态?

举个例子:当目标View由不可见变为可见状态,是否需要显示泡泡View?
分析一下具体情况:

  • 未读数量等于0,App业务隐藏泡泡View,目标View状态变为可见,不能显示泡泡View
  • 未读数量大于0,App业务显示泡泡View,目标View状态变为可见,要显示泡泡View

库里面监听到目标View状态变为可见的时候,没办法确定是否要显示泡泡View,因为泡泡View本身有显示隐藏逻辑,这个是App的业务逻辑。

解决办法:给泡泡View加一个父容器,对泡泡View父容器同步可见状态。当目标View可见就显示泡泡View父容器,目标View不可见就隐藏泡泡View父容器。

最后再把泡泡View父容器添加到某个容器上,这样子泡泡View的显示隐藏逻辑和库的逻辑就独立了。

问题2. 如何对齐?

  1. 获取目标View屏幕坐标
  2. 获取泡泡View父容器屏幕坐标
  3. 算出两组坐标在x和y方向的偏移量
  4. 用计算的偏移量设置泡泡View位置

举个例子,假如要让泡泡View和目标View左上角对齐:

  1. 目标View屏幕坐标:x=100, y=100
  2. 泡泡View父容器屏幕坐标:x=50, y=50
  3. 目标View坐标减去泡泡View父容器坐标,算出偏移量:x=50, y=50

注意,第3步中算出来的偏移量实际上是泡泡View相对于父容器的偏移量,而不是相对于屏幕左上角的偏移量,这点很重要。

根据第3步计算的结果,得到泡泡View相对于父容器的坐标是x=50, y=50,那么相对于屏幕的坐标就是当前坐标加上父容器的屏幕坐标,即x=100, y=100。这样子,泡泡View左上角和目标View就对齐了。

用图片来展示一下层级关系:

pop.png

问题3. 如何实时对齐?

虽然知道如何对齐了,但是目标View的大小和位置可能发生变化,泡泡View的大小也可能发生变化,变化之后就有可能不对齐了。所以需要监听目标View的和泡泡View的变化,重新计算坐标并更新位置。

我们可以使用以下两个接口来监听View:

  • 监听目标View用android.view.ViewTreeObserver.OnPreDrawListener
  • 监听泡泡View用android.View.OnLayoutChangeListener

这两个接口,默认为读者都了解,就不赘述了。
以上问题都明确之后,就可以开始写代码了,我们继续。

实现代码

主要实现步骤是:监听泡泡View和目标View变化,获取坐标,计算偏移量,用偏移量设置泡泡View的位置。
由于本文主要是介绍一下实现思路,具体实现细节没有展开说明,仅提供了封装后的示例代码。

1. 监听View刷新

ViewUpdater接口的作用是监听某个View的刷新,可以获取到View的大小,位置等属性。
示例代码:

ViewUpdater updater = new OnPreDrawUpdater();
updater.setView(view); // 设置要监听的View
updater.setUpdatable(new ViewUpdater.Updatable() {
    @Override
    public void update() {
        // 回调,触发计算逻辑
    }
});
updater.start(); // 开始监听

OnPreDrawUpdater是通过android.view.ViewTreeObserver.OnPreDrawListener接口来实现的,默认还提供了OnLayoutChangeUpdaterOnGlobalLayoutChangeUpdater,主要的区别是刷新频率不一样。

读者也可以实现ViewUpdater接口自定义刷新逻辑。

2.计算坐标

ViewTrack接口的作用是计算泡泡View和目标View对齐时,泡泡View相对于父容器的偏移量。
示例代码:

ViewTracker tracker = new FViewTracker();
tracker.setSource(popView); // 设置泡泡view
tracker.setTarget(targetView); // 设置目标view
tracker.setPosition(Position.TopLeft); // 设置对齐方式
tracker.setCallback(new ViewTracker.Callback() {
    @Override
    public void onUpdate(int x, int y, @NonNull View source, @NonNull View target) {
        // 回调泡泡view相对于父容器的偏移量
    }
});
tracker.update(); // 触发一次计算

到此为止,监听和计算的代码都封装好了,接下来可以写泡泡View库了,继续往下看。

3. 泡泡View库封装

目前的做法是,把步骤1和步骤2的代码封装为一个单独的库,然后泡泡View库依赖这个库对外提供接口。
Poper接口示例代码:

Poper poper = new FPoper(this)
        .setPopView(popView) // 设置泡泡View,可以是布局id或者View对象
        .setTarget(targetView) // 设置目标View
        .setPosition(Poper.Position.TopLeft)  // 设置左上角对齐,默认右上角对齐
        .setMarginX(0) // 设置对齐后的x方向间距,大于0往右,小于0往左,默认0
        .setMarginY(0) // 设置对齐后的y方向间距,大于0往下,小于0往上,默认0
        .attach(true); // true-依附目标view,false-移除依附

泡泡View库提供的接口就是最终我们App中用到的接口,Poper接口完整的方法列表,点击这里

接下来我们看一下,一些没有交代的细节或者疑问。

注意事项

1. 泡泡View显示范围

注意,泡泡View父容器和泡泡View父容器的父容器,这两个容器,不要混淆。

  • 泡泡View父容器

库里面会自动创建泡泡View父容器对象SimplePoperParent,它本质上是一个FrameLayout,实现了PoperParent接口并重写了onLayout方法,目前暂不支持自定义,有兴趣的读者可以看一下源码。

/**
 * 泡泡View父容器
 */
interface PoperParent {
    /**
     * 把当前容器添加到指定容器
     */
    void attachToContainer(@NonNull ViewGroup container);

    /**
     * 把泡泡View添加到当前容器
     */
    void addPopView(@NonNull View popView);

    /**
     * 同步目标View的可见状态到当前容器
     *
     * @param isShown true-目标View可见,false-目标View不可见
     */
    void synchronizeVisibilityWithTarget(boolean isShown);

    /**
     * 移除自己
     */
    void removeSelf();
}

addPopView()方法把泡泡View加到父容器,attachToContainer()方法再把泡泡View父容器添加到指定的显示容器,具体是什么容器下面会介绍。其他方法看注释,比较好理解就不赘述了。

  • 泡泡View父容器的父容器

库中默认的实现是把泡泡View父容器添加到Activity中id为android.R.id.content的容器上,宽高都是match_parent,也就是说默认情况下泡泡View的可见范围是整个屏幕。关于android.R.id.content容器,默认为读者都了解,就不赘述了。

  • 目标View在窗口中该如何处理?

由于窗口显示在Activity上层,会遮挡默认容器android.R.id.content,所以需要指定窗口中的容器来显示泡泡View,即泡泡View父容器会被添加到该容器,Poper接口方法:

/**
 * 设置泡泡View的显示范围,默认是Activity中id为android.R.id.content的容器
 */
@NonNull
Poper setContainer(@Nullable ViewGroup container);

2. 自定义对齐

库中默认提供了9种对齐方式,即文章开头展示的位置。如何自定义呢?
例如:实现泡泡View显示在目标View的下方,即泡泡View的顶部和目标View的底部对齐。

首先设置Poper.Position.Bottom,让泡泡View底部和目标View底部对齐,再设置margin值让泡泡View往下偏移泡泡View的高度。

由于调用Poper.attach(true)的时候,泡泡View和目标View可能还未被Layout,没办法获取泡泡View的大小,所以需要通过Margin接口动态获取margin值。

interface Margin {
    /**
     * 返回间距
     */
    int getMargin();
}
Poper poper = new FPoper(this)
        .setPopView(popView)
        .setTarget(targetView)
        .setPosition(Poper.Position.Bottom)
        .setMarginY(new Poper.Margin() {
            @Override
            public int getMargin() {
                // 偏移泡泡View的高度
                return popView.getHeight();
            }
        })
        .attach(true);

3. 自定义Layouter

得到偏移量之后,库里面通过Layouter接口设置泡泡View的位置:

interface Layouter {
    /**
     * 位置回调
     *
     * @param x       泡泡View相对父容器的x值
     * @param y       泡泡View相对父容器的y值
     * @param popView 泡泡View
     * @param target  目标view
     */
    void layout(int x, int y, @NonNull View popView, @NonNull View target);
}
  • DefaultLayouter

DefaultLayouter是设置泡泡View位置的默认实现,代码如下:

public class DefaultLayouter implements Poper.Layouter {
    @Override
    public void layout(int x, int y, @NonNull View popView, @NonNull View target) {
        popView.offsetLeftAndRight(x - popView.getLeft());
        popView.offsetTopAndBottom(y - popView.getTop());
    }
}

实现比较简单,通过View.offsetLeftAndRight()View.offsetTopAndBottom()方法,改变View的lefttop属性,实现偏移。

  • FixBoundaryLayouter

如果泡泡View的宽高比较大,DefaultLayouter的实现可能导致偏移之后泡泡View的内容超出父布局而显示不全,比如泡泡View是一个列表,显示了很多数据。

这时候就需要FixBoundaryLayouter来修正泡泡View的ViewGroup.LayoutParams参数值,让其内容可以完全显示出来。示例代码:

// CombineLayouter可以组合多个Layouter
Poper.Layouter layouter = new CombineLayouter(
        // 默认Layouter
        new DefaultLayouter(),
        // 修正泡泡View高度
        new FixBoundaryLayouter(FixBoundaryLayouter.Boundary.Height)
);

Poper poper = new FPoper(this)
        .setPopView(popView)
        .setTarget(targetView)
        .setPosition(Poper.Position.Bottom)
        .setLayouter(layouter) // 设置Layouter
        .attach(true);

pop_list.gif

如果读者需要自定义的话,可以实现Layouter接口,设置对象给Poper即可。

结束

这个库是三四年前开始写的,陆陆续续迭代了一些版本,终于抽空写了个介绍,有不对的地方还请读者指正,一起讨论学习。

泡泡View:poper
作者邮箱:565061763@qq.com