前言
未读小红点,未读数量,很多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. 如何对齐?
- 获取目标View屏幕坐标
- 获取泡泡View父容器屏幕坐标
- 算出两组坐标在x和y方向的偏移量
- 用计算的偏移量设置泡泡View位置
举个例子,假如要让泡泡View和目标View左上角对齐:
- 目标View屏幕坐标:x=100, y=100
- 泡泡View父容器屏幕坐标:x=50, y=50
- 目标View坐标减去泡泡View父容器坐标,算出偏移量:x=50, y=50
注意,第3步中算出来的偏移量实际上是泡泡View相对于父容器的偏移量,而不是相对于屏幕左上角的偏移量,这点很重要。
根据第3步计算的结果,得到泡泡View相对于父容器的坐标是x=50, y=50,那么相对于屏幕的坐标就是当前坐标加上父容器的屏幕坐标,即x=100, y=100。这样子,泡泡View左上角和目标View就对齐了。
用图片来展示一下层级关系:
问题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
接口来实现的,默认还提供了OnLayoutChangeUpdater
,OnGlobalLayoutChangeUpdater
,主要的区别是刷新频率不一样。
读者也可以实现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的left
和top
属性,实现偏移。
- 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);
如果读者需要自定义的话,可以实现Layouter
接口,设置对象给Poper
即可。
结束
这个库是三四年前开始写的,陆陆续续迭代了一些版本,终于抽空写了个介绍,有不对的地方还请读者指正,一起讨论学习。
泡泡View:poper
作者邮箱:565061763@qq.com