上一篇经过在父控件绘制前景的方式展现小红点,在布局文件中配置标记控件就能为任意子控件添加小红点。实现方案是”布局文件中配置带小红点控件 id,在父控件中获取它们的坐标,并在其右上角绘制圆圈“。但这个方案有一个漏洞,当子控件作动画,即子控件尺寸发生变化时,小红点不会联动。效果入下图:android
本文是系列文章的第七篇,系列文章以下:git
在父控件的draw()
,dispatchDraw()
,drawChild()
中打 log,子控件作动画时都未能捕获到联动的事件。github
忽然想起androidx.coordinatorlayout.widget.CoordinatorLayout
中的Behavior
,在onDependentViewChanged()
中能够实时得到关联控件的属性变化。它是如何作到的?沿着调用链往上查找:canvas
public class CoordinatorLayout extends ViewGroup{
final void onChildViewsChanged(@DispatchChangeEvent final int type) {
final int childCount = mDependencySortedChildren.size();
for (int i = 0; i < childCount; i++) {
final View child = mDependencySortedChildren.get(i);
//'遍历全部依赖的子控件'
for (int j = i + 1; j < childCount; j++) {
final View checkChild = mDependencySortedChildren.get(j);
...
if (b != null && b.layoutDependsOn(this, checkChild, child)) {
...
final boolean handled;
switch (type) {
case EVENT_VIEW_REMOVED:
// EVENT_VIEW_REMOVED means that we need to dispatch
// onDependentViewRemoved() instead
b.onDependentViewRemoved(this, checkChild, child);
handled = true;
break;
default:
//'将子控件变化传递出去'
handled = b.onDependentViewChanged(this, checkChild, child);
break;
}
...
}
}
}
}
复制代码
当关联子控件发生变化时,会遍历关联控件并将变换经过onDependentViewChanged()
传递出去。沿着调用链再往上:bash
public class CoordinatorLayout extends ViewGroup{
class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
@Override
public boolean onPreDraw() {
//'在 onPreDraw() 中捕获子控件属性变化事件'
onChildViewsChanged(EVENT_PRE_DRAW);
return true;
}
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
if (mNeedsPreDrawListener) {
if (mOnPreDrawListener == null) {
//'在 onAttachedToWindow() 中构建PreDrawListener'
mOnPreDrawListener = new OnPreDrawListener();
}
final ViewTreeObserver vto = getViewTreeObserver();
//'注册 View 树观察者'
vto.addOnPreDrawListener(mOnPreDrawListener);
}
}
}
//'全局 View 树观察者'
public final class ViewTreeObserver {
public interface OnPreDrawListener {
//'view 树被绘制前该接口被调用,此时 view 树中全部视图已经被 measure 和 layout '
public boolean onPreDraw();
}
}
复制代码
CoordinatorLayout
在onAttachedToWindow()
时注册了 View 树观察者,子控件属性变化时一定会触发 View树重绘,这样就能够在onPreDraw()
中监听到它们的属性变化。dom
将这套机制照搬到自定义容器控件TreasureBox
:ide
//自定义容器控件,需配合标记控件使用
class TreasureBox @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
ConstraintLayout(context, attrs, defStyleAttr) {
//'标记控件列表,用于标记哪些子控件须要小红点'
private var treasures = mutableListOf<Treasure>()
//'View 树观察者'
private var onPreDrawListener: ViewTreeObserver.OnPreDrawListener = ViewTreeObserver.OnPreDrawListener {
//'View 树重绘前通知全部标记控件'
treasures.forEach { treasure -> treasure.onPreDraw(this) }
true
}
override fun onViewAdded(child: View?) {
super.onViewAdded(child)
//存储标记控件
(child as? Treasure)?.let { treasure ->
treasures.add(treasure)
}
}
override fun onViewRemoved(child: View?) {
super.onViewRemoved(child)
//移除标记控件
(child as? Treasure)?.let { treasure ->
treasures.remove(treasure)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
//'注册 View 树监听器'
viewTreeObserver.addOnPreDrawListener(onPreDrawListener)
}
复制代码
这样当须要绘制小红点的子控件属性发生变化时,标记控件就能够在onPreDraw()
中收到通知:函数
//'抽象标记控件'
abstract class Treasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
View(context, attrs, defStyleAttr) {
//'关联控件 id 列表'
internal var ids = mutableListOf<Int>()
fun onPreDraw(treasureBox: TreasureBox) {
ids.map { treasureBox.findViewById<View>(it) }.forEach { v ->
//'这里能够监听到关联子控件属性变化'
}
}
复制代码
每次 View 树重绘前均可以在onPreDraw()
中实时获取子控件的宽高及坐标,为了不过分重绘,只有当属性变化时,才触发父控件重绘。须要记忆上次重绘的属性,经过比较就能知道属性是否发生变动:布局
abstract class Treasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
View(context, attrs, defStyleAttr) {
//'关联控件属性,与关联控件id列表一一对应'
var layoutParams = mutableListOf<LayoutParam>()
//'关联控件id列表'
internal var ids = mutableListOf<Int>()
fun onPreDraw(treasureBox: TreasureBox) {
//'在关联控件重绘前,遍历它们检查其属性是否变动'
ids.forEachIndexed { index, id ->
treasureBox.findViewById<View>(id)?.let { v ->
LayoutParam(v.width, v.height, v.x, v.y).let { lp ->
//'若关联控件属性变动,触发父控件重绘'
if (layoutParams[index] != lp) {
if (layoutParams[index].isValid()) {
treasureBox.postInvalidate()
}
layoutParams[index] = lp
}
}
}
}
}
//'控件属性实体类,储存宽高和坐标'
data class LayoutParam(var width: Int = 0, var height: Int = 0, var x: Float = 0f, var y: Float = 0f) {
private var id: Int? = null
override fun equals(other: Any?): Boolean {
if (other == null || other !is LayoutParam) return false
//'只有全部属性都同样,才认为属性没有变动'
return width == other.width && height == other.height && x == other.x && y == other.y
}
fun isValid() = width != 0 && height != 0
}
}
复制代码
还须要变动下小红点绘制逻辑,以前的逻辑以下:post
//'小红点标记控件'
class RedPointTreasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
Treasure(context, attrs, defStyleAttr) {
override fun drawTreasure(treasureBox: TreasureBox, canvas: Canvas?) {
//'遍历关联控件,并在父控件画布上对应位置绘制小红点'
ids.forEachIndexed { index, id ->
treasureBox.findViewById<View>(id)?.let { v ->
//'经过关联控件的 right 值,决定小红点横坐标'
val cx = v.right + v.width + offsetXs.getOrElse(index) { 0F }.dp2px()
//'经过关联控件的 top 值,决定小红点纵坐标'
val cy = v.top + offsetYs.getOrElse(index) { 0F }.dp2px()
val radius = radiuses.getOrElse(index) { DEFAULT_RADIUS }.dp2px()
canvas?.drawCircle(cx, cy, radius, bgPaint)
}
}
}
}
复制代码
若是沿用这套绘制逻辑,即便父控件监听到子控件重绘,小红点也不会跟着联动。那是由于 View 的getTop()
和getRight()
不包含位移值:
public class View{
public final int getTop() {
return mTop;
}
public final int getRight() {
return mRight;
}
}
复制代码
而getX()
和getY()
则包含了位移值:
public class View{
public float getX() {
return mLeft + getTranslationX();
}
public float getY() {
return mTop + getTranslationY();
}
}
复制代码
只须要将绘制逻辑中的v.right
和v.top
换成v.x
和v.y
,小红点就能和动画联动了。为控件添加位移和缩放动画,测试一下:
打了 log 才发现,View 经过setScale()
的方式进行动画时,它的宽高和坐标并不会发生变化。。。
但必然是有一个属性的值变化了,虽然暂且不知道它是啥?
只能打开View
源码,遍历全部get
开头的函数,而后把它们的值打印在onPreDraw()
中。通过屡次尝试,终于找到了一个函数,它的返回值和子控件缩放动画联动:
public class View{
public void getHitRect(Rect outRect) {
if (hasIdentityMatrix() || mAttachInfo == null) {
outRect.set(mLeft, mTop, mRight, mBottom);
} else {
final RectF tmpRect = mAttachInfo.mTmpTransformRect;
tmpRect.set(0, 0, getWidth(), getHeight());
//'将 matrix 值考虑在内'
getMatrix().mapRect(tmpRect)
outRect.set((int) tmpRect.left + mLeft, (int) tmpRect.top + mTop,
(int) tmpRect.right + mLeft, (int) tmpRect.bottom + mTop);
}
}
}
复制代码
当子控件作缩小动画时,该函数返回的Rect
中的left
会变大而right
会变小。
函数的返回值在mLeft
,mRight
,mTop
,mBottom
的基础上叠加了matrix
的值。作动画的属性值最终都会反映到matrix
上,这样一分析好像能自圆其说,即该函数会实时返回 view 因动画而改变的属性值。
如此一来,只须要记忆上一次的Rect
,就能在下次重绘前经过比较得知子控件是否作了动画:
//标记控件
abstract class Treasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
View(context, attrs, defStyleAttr) {
//关联子控件id列表
internal var ids = mutableListOf<Int>()
//'关联子控件当前帧区域列表'
var rects = mutableListOf<Rect>()
//'关联子控件上一帧区域列表'
var lastRects = mutableListOf<Rect>()
fun onPreDraw(treasureBox: TreasureBox) {
//'遍历关联控件'
ids.forEachIndexed { index, id ->
treasureBox.findViewById<View>(id)?.let { v ->
//'得到当前帧控件区域'
v.getHitRect(rects[index])
//'若当前帧控件区域变动,则通知父控件重绘'
if (rects[index] != lastRects[index]) {
treasureBox.postInvalidate()
//'更新上一帧控件区域'
lastRects[index].set(rects[index])
}
}
}
}
//解析 xml 读取关联子控件id
open fun readAttrs(attributeSet: AttributeSet?) {
attributeSet?.let { attrs ->
context.obtainStyledAttributes(attrs, R.styleable.Treasure)?.let {
divideIds(it.getString(R.styleable.Treasure_reference_ids))
it.recycle()
}
}
}
//'分割关联子控件id字串'
private fun divideIds(idString: String?) {
idString?.split(",")?.forEach { id ->
ids.add(resources.getIdentifier(id.trim(), "id", context.packageName))
//'为每一个关联子控件初始化当前帧区域'
rects.add(Rect())
//'为每一个关联子控件初始化上一帧区域'
lastRects.add(Rect())
}
ids.toCollection(mutableListOf()).print("ids") { it.toString() }
}
}
复制代码
绘制小红点逻辑也要作响应改动:
class RedPointTreasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
Treasure(context, attrs, defStyleAttr) {
//'在父控件画布的前景上绘制小红点'
override fun drawTreasure(treasureBox: TreasureBox, canvas: Canvas?) {
ids.forEachIndexed { index, id ->
treasureBox.findViewById<View>(id)?.let { v ->
//'小红点圆心横坐标依赖于当前帧区域右边界'
val cx = rects[index].right + offsetXs.getOrElse(index) { 0F }.dp2px()
//'小红点圆心纵坐标依赖于当前帧区域上边界'
val cy = rects[index].top + offsetYs.getOrElse(index) { 0F }.dp2px()
val radius = radiuses.getOrElse(index) { DEFAULT_RADIUS }.dp2px()
canvas?.drawCircle(cx, cy, radius, bgPaint)
}
}
}
复制代码
大功告成,效果以下: