本文为稀土技能社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
引子
view.setOnClickListener { // 当控件被点击时触发的逻辑 }
正是由于 View 对控件点击采用了策略模式,才使得监听任何控件的点击事情变得易如反掌。
我有一个希望。。。
假如 View 能有一个可见性监听该多好啊!
view.setOnVisibilityChangeListener { isVisible: Boolean -> }
体系并未供给这个办法。。。
但事务上有可见性监听的需要,比如曝光埋点。当某控件可见时,上报XXX。
数据剖析同学常常诉苦曝光数据不准确,有的场景曝光多报了,有的场景曝光少报了。。。
开发同学看到曝光埋点也很头痛,不同场景的曝光检测有不同的办法,缺乏一致的可见性检测进口,存在必定重复开发。
本文就试图为单个控件以及列表项的可见性供给一致的检测进口。
控件的可见性遭到诸多要素的影响,下面是影响控件可见性的十大要素:
- 手机电源开关
- Home 键
- 动态替换的 Fragment 遮挡了原有控件
- ScrollView, NestedScrollView 的翻滚
- ViewPager, ViewPager2 的翻滚
- RecyclerView 的翻滚
- 被 Dialog 遮挡
- Activity 切换
- 同一 Activity 中 Fragment 的切换
- 手动调用 View.setVisibility(View.GONE)
- 被输入法遮盖
能否把这一切的状况都经过一个回调办法表达?目标是经过一个 View 的扩展办法完结上述一切状况的检测,并将可见性回调给上层,形如:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {}
若能完成就极大化简了上层可见性检测的复杂度,只需要如下代码就能完成任意控件的曝光上报埋点:
view.onVisibilityChange { view, isVisible ->
if(isVisible) { // 曝光埋点 }
else {}
}
控件大局可见性检测
可见性检测分为两步:
- 捕获机遇:调用检测算法检测控件可见性的机遇。
- 检测算法:描述怎么检测控件是否对用户可见。
拿“手动调用 View.setVisibility(View.GONE)”举例,得先捕获 View Visibility 产生改变的机遇,并在此刻检测控件的可见性。
下面是View.setVisibility()
的源码:
// android.view.View.java
public void setVisibility(@Visibility int visibility) {
setFlags(visibility, VISIBILITY_MASK);
}
体系并未在该办法中供给类似于回调的接口,即一个 View 的实例无法经过回调的方式捕获到 visibility 改变的机遇。
莫非经过自界说 View,然后重写 setVisibility() 办法?
这个做法接入成本太高且不具备通用性。
除了“手动调用 View.setVisibility(View.GONE)”,剩余的影响可见性的要素大多都可找到对应回调。莫非得在fun View.onVisibilityChange()
中对每个要素逐一增加回调吗?
这样完成过分复杂了,并且也不具备通用性,假设有例外状况,fun View.onVisibilityChange()
的完成就得修正。
上面列出的十种影响控件可见性的要素都是现象,不同的现象背面或许对应相同的实质。
经过深挖,上述现象的实质可被收敛为下面四个:
- 控件大局重绘
- 控件大局翻滚
- 控件大局焦点改变
- 容器控件新增子控件
下面就针对这四个实质编程。
捕获大局重绘机遇
体系供给了ViewTreeObserver
:
public final class ViewTreeObserver {
public void addOnGlobalLayoutListener(OnGlobalLayoutListener listener) {
checkIsAlive();
if (mOnGlobalLayoutListeners == null) {
mOnGlobalLayoutListeners = new CopyOnWriteArray<OnGlobalLayoutListener>();
}
mOnGlobalLayoutListeners.add(listener);
}
}
ViewTreeObserver 是一个大局的 View 树改变调查者,它供给了一系列大局的监听器,大局重绘即是其间OnGlobalLayoutListener
:
public interface OnGlobalLayoutListener {
public void onGlobalLayout();
}
当 View 树产生改变需要重绘的时候,就会触发该回调。
调用 View.setVisibility(View.GONE) 之所以能将控件隐藏,正是由于整个 View 树触发了一次重绘。(任何一次细小的重绘都是从 View 树的树根自顶向下的遍历并触发每一个控件的重绘,不需要重绘的控件会跳过,关于 Adroid 制作机制的剖析能够点击Android自界说控件 | View制作原理(画多大?))
在可见性检测扩展办法中捕获第一个机遇:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
}
其间viewTreeObserver
是 View 的办法:
// android.view.View.java
public ViewTreeObserver getViewTreeObserver() {
if (mAttachInfo != null) {
return mAttachInfo.mTreeObserver;
}
if (mFloatingTreeObserver == null) {
mFloatingTreeObserver = new ViewTreeObserver(mContext);
}
return mFloatingTreeObserver;
}
getViewTreeObserver() 用于回来当时 View 地点 View 树的调查者。
大局重绘其实覆盖了上述的两个场景:
- 同一 Activity 中 Fragment 的切换
- 手动调用 View.setVisibility(View.GONE)
- 被输入法覆盖
这两个场景都会产生 View 树的重绘。
捕获大局翻滚机遇
- ScrollView, NestedScrollView 的翻滚
- ViewPager, ViewPager2 的翻滚
- RecyclerView 的翻滚
上述三个机遇的一起特点是“产生了翻滚”。
每个可翻滚的容器控件都供给了各自翻滚的监听
// android.view.ScrollView.java
public interface OnScrollChangeListener {
void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY);
}
// androidx.viewpager2.widget.ViewPager2.java
public abstract static class OnPageChangeCallback {
public void onPageScrolled(int position, float positionOffset, @Px int positionOffsetPixels) {}
public void onPageSelected(int position) {}
public void onPageScrollStateChanged(@ScrollState int state) {}
}
// androidx.recyclerview.widget.RecyclerView.java
public abstract static class OnScrollListener {
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {}
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {}
}
莫非要针对不同的翻滚控件设置不同的翻滚监听器?
这样可见性检测就和控件耦合了,不具有通用性,也愧对View.onVisibilityChange()
这个姓名。
还好又在ViewTreeObserver
中找到了大局的翻滚监听:
public final class ViewTreeObserver {
public void addOnScrollChangedListener(OnScrollChangedListener listener) {
checkIsAlive();
if (mOnScrollChangedListeners == null) {
mOnScrollChangedListeners = new CopyOnWriteArray<OnScrollChangedListener>();
}
mOnScrollChangedListeners.add(listener);
}
}
public interface OnScrollChangedListener {
public void onScrollChanged();
}
在可见性检测扩展办法中捕获第二个机遇:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
}
捕获大局焦点改变机遇
下面这些 case 都是焦点产生了改变:
- 手机电源开关
- Home 键
- 被 Dialog 遮挡
- Activity 切换
相同借助于 ViewTreeObserver 能够捕获到焦点改变的机遇。
到目前为止,大局可见性扩展办法中已经监听了三种机遇,分别是大局重绘、大局翻滚、大局焦点改变:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
viewTreeObserver.addOnWindowFocusChangeListener {}
}
捕获新增子控件机遇
最终一个 case 是最复杂的:动态替换的 Fragment 遮挡了原有控件。
该场景如下图所示:
界面中有一个底边栏,其间包括各种 tab 标签,点击其间的标签会以 Fragment 的形式从底部弹出。此刻,底边栏各 tab 从可见变为不行见,当点击回来时,又从不行见变为可见。
一开端的思路是“从被遮挡的 View 本身出发”,看看某个 View 被遮挡后,其本身的特点是否会产生改变?
View 内部以is
最初的办法如下所示:
我把其间姓名看上去或许和被遮挡有相关的办法值全都打印出来了,然后触发 gif 中的场景,调查这些值在触发前后是否会产生改变。
几十个特点,一一比对,在看花眼之前,log 告诉我,被遮挡之后,这些都没有产生任何改变。。。。
失望。。。但还不死心,继续寻找其他办法:
我又找了 View 内部一切has
最初的办法,也把其间看上去和被遮挡有关的办法全打印出来了。。。你猜成果怎么着?依然是徒劳。。。。
我开端质疑起点是否正确。。。此刻一声雷鸣劈醒了我。
视图只或许了解其本身以及其基层视图的状况,它无法得知它的平级甚至是父亲的制作状况。而 gif 中的场景,即是在底边栏的同级有一个 Fragment 的容器。并且当视图被其他层级的控件遮挡时,整个制作体系也不用通知那个被遮挡的视图,否则多低效啊(我yy的,若有大佬知道底细,欢迎留言指点一二。)
经过这层考虑之后,我跳出了被遮挡的那个视图,转而去 Fragment 的容器哪里寻求解决计划。
Fragment 要被增加到 Activity 必须供给一个容器控件,容器控件供给了一个回调用于监听子控件被增加:
// android.view.ViewGroup.java
public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener) {
mOnHierarchyChangeListener = listener;
}
public interface OnHierarchyChangeListener {
void onChildViewAdded(View parent, View child);
void onChildViewRemoved(View parent, View child);
}
为了监听 Fragment 被增加的这个瞬间,得为可见性检测扩展办法增加一个参数:
fun View.onVisibilityChange(
viewGroup: ViewGroup? = null, // 容器
block: (view: View, isVisible: Boolean) -> Unit
) { }
其间 viewGroup 表明 Fragment 的容器控件。
已然 Fragment 的增加也是往 View 树中刺进子控件,那 View 树必定会重绘,能够在大局重绘回调中进行分类评论,下面是伪代码:
fun View.onVisibilityChange(
viewGroup: ViewGroup? = null,
block: (view: View, isVisible: Boolean) -> Unit
) {
var viewAdded = false
// View 树重绘机遇
viewTreeObserver.addOnGlobalLayoutListener {
if(viewAdded){
// 检测新刺进控件是否遮挡当时控件
}
else {
// 检测当时控件是否出现在屏幕中
}
}
// 监听子控件刺进
viewGroup?.setOnHierarchyChangeListener(object : OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
viewAdded = true
}
}
override fun onChildViewRemoved(parent: View?, child: View?) {
viewAdded = false
}
})
}
子控件的刺进回调总是先于 View 树重绘回调。所以先在刺进时置标志位viewAdded = true
,以便在重绘回调中做分类评论。(由于检测子控件遮挡和是否出现在屏幕中是两种不同的检测计划)
可见性检测算法
检测控件的可见性的算法是:“判别控件的矩形区域是否和屏幕有交集”。
为此新增扩展特点:
val View.isInScreen: Boolean
get() = ViewCompat.isAttachedToWindow(this) && visibility == View.VISIBLE && getLocalVisibleRect(Rect())
val 类名.特点名: 特点类型
这样的语法用于为类的实例增加一个扩展特点,它并不是真地给类新增了一个成员变量,而是在类的外部新增特点值的获取办法。
当时新增的特点是 val 类型的,即常量,所以只需要为其界说个 get() 办法来表达怎么获取它的值。
View 是否在屏幕中由三个表达式一起决定。
- 先经过 ViewCompat.isAttachedToWindow(this) 判别控件是否依附于窗口。
- 再经过 visibility == View.VISIBLE 判别视图是否可见。
- 最终调用
getLocalVisibleRect()
判别它的矩形相对于屏幕是否可见:
// android.view.View.java
public final boolean getLocalVisibleRect(Rect r) {
final Point offset = mAttachInfo != null ? mAttachInfo.mPoint : new Point();
if (getGlobalVisibleRect(r, offset)) {
r.offset(-offset.x, -offset.y);
return true;
}
return false;
}
该办法会先获取控件相对于屏幕的矩形区域并存放在传入的 Rect 参数中,然后再将其偏移到控件坐标系。假如矩形区域为空,则回来 false 表明不在屏幕中,否则为 true。
刚才捕获的那一系列机遇,有或许会被屡次触发。为了只将可见性产生改变的事情回调给上层,得做一次过滤:
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 获取当时可见性
val isInScreen = this.isInScreen() && visibility == View.VISIBLE
// 无上一次可见性,表明第一次检测
if (lastVisibility == null) {
if (isInScreen) {
// 回调可见性回调给上层
block(this, true)
// 更新可见性
setTag(KEY_VISIBILITY, true)
}
}
// 当时可见性和前次不同
else if (lastVisibility != isInScreen) {
// 回调可见性给上层
block(this, isInScreen)
// 更新可见性
setTag(KEY_VISIBILITY, isInScreen)
}
}
过滤重复事情的计划是记录上一次可见性(记录在 View 的 tag 中),假如这一次可见性检测成果和上一次相同则不回调给上层。
将可见性检测界说为一个 lambda,这样就能够在捕获不同机遇时复用。
以下是完整的可见性检测代码:
fun View.onVisibilityChange(
viewGroups: List<ViewGroup> = emptyList(), // 会被刺进 Fragment 的容器调集
needScrollListener: Boolean = true,
block: (view: View, isVisible: Boolean) -> Unit
) {
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val KEY_HAS_LISTENER = "KEY_HAS_LISTENER".hashCode()
// 若当时控件已监听可见性,则回来
if (getTag(KEY_HAS_LISTENER) == true) return
// 检测可见性
val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 判别控件是否出现在屏幕中
val isInScreen = this.isInScreen
// 首次可见性改变
if (lastVisibility == null) {
if (isInScreen) {
block(this, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非首次可见性改变
else if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
}
// 大局重绘监听器
class LayoutListener : ViewTreeObserver.OnGlobalLayoutListener {
// 符号位用于差异是否是遮挡case
var addedView: View? = null
override fun onGlobalLayout() {
// 遮挡 case
if (addedView != null) {
// 刺进视图矩形区域
val addedRect = Rect().also { addedView?.getGlobalVisibleRect(it) }
// 当时视图矩形区域
val rect = Rect().also { this@onVisibilityChange.getGlobalVisibleRect(it) }
// 假如刺进视图矩形区域包括当时视图矩形区域,则视为当时控件不行见
if (addedRect.contains(rect)) {
block(this@onVisibilityChange, false)
setTag(KEY_VISIBILITY, false)
} else {
block(this@onVisibilityChange, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非遮挡 case
else {
checkVisibility()
}
}
}
val layoutListener = LayoutListener()
// 修改容器监听其刺进视图机遇
viewGroups.forEachIndexed { index, viewGroup ->
viewGroup.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
// 当控件刺进,则置符号位
layoutListener.addedView = child
}
override fun onChildViewRemoved(parent: View?, child: View?) {
// 当控件移除,则置符号位
layoutListener.addedView = null
}
})
}
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
// 大局翻滚监听器
var scrollListener:ViewTreeObserver.OnScrollChangedListener? = null
if (needScrollListener) {
scrollListener = ViewTreeObserver.OnScrollChangedListener { checkVisibility() }
viewTreeObserver.addOnScrollChangedListener(scrollListener)
}
// 大局焦点改变监听器
val focusChangeListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
val isInScreen = this.isInScreen
if (hasFocus) {
if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
} else {
if (lastVisibility == true) {
block(this, false)
setTag(KEY_VISIBILITY, false)
}
}
}
viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener)
// 为避免内存泄漏,当视图被移出的同时反注册监听器
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}
override fun onViewDetachedFromWindow(v: View?) {
v ?: return
// 有时候 View detach 后,还会执行大局重绘,为此退后反注册
post {
try {
v.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
} catch (_: java.lang.Exception) {
v.viewTreeObserver.removeGlobalOnLayoutListener(layoutListener)
}
v.viewTreeObserver.removeOnWindowFocusChangeListener(focusChangeListener)
if(scrollListener !=null) v.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
viewGroups.forEach { it.setOnHierarchyChangeListener(null) }
}
removeOnAttachStateChangeListener(this)
}
})
// 符号已设置监听器
setTag(KEY_HAS_LISTENER, true)
}
该控件可见性检测办法,最大的用途在于检测 Fragment 的可见性。具体讲解能够点击 页面曝光难点剖析及应对计划
引荐阅览
事务代码参数透传满天飞?(一)
事务代码参数透传满天飞?(二)
全网最优雅安卓控件可见性检测
全网最优雅安卓列表项可见性检测
页面曝光难点剖析及应对计划
你的代码太烦琐了 | 这么多对象名?
你的代码太烦琐了 | 这么多办法调用?