android 自定义小红点,Android自定义控件 | 小红点的三种实现(终结)

上一篇通过在父控件绘制前景的方式展示小红点,在布局文件中配置标记控件就能为任意子控件添加小红点。实现方案是”布局文件中配置带小红点控件 id,在父控件中获取它们的坐标,并在其右上角绘制圆圈“。但这个方案有一个漏洞,当子控件做动画,即子控件尺寸发生变化时,小红点不会联动。效果入下图:

33ce203a5c0e

image

所以新的课题是:如何在父控件中监听子控件重绘并作出响应?

监听重绘

在父控件的draw(),dispatchDraw(),drawChild()中打 log,子控件做动画时都未能捕获到联动的事件。

突然想起androidx.coordinatorlayout.widget.CoordinatorLayout中的Behavior,在onDependentViewChanged()中可以实时获得关联控件的属性变化。它是如何做到的?沿着调用链往上查找:

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()传递出去。沿着调用链再往上:

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()中监听到它们的属性变化。

将这套机制照搬到自定义容器控件TreasureBox:

//自定义容器控件,需配合标记控件使用

class TreasureBox @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :

ConstraintLayout(context, attrs, defStyleAttr) {

//'标记控件列表,用于标记哪些子控件需要小红点'

private var treasures = mutableListOf()

//'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()

fun onPreDraw(treasureBox: TreasureBox) {

ids.map { treasureBox.findViewById(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()

//'关联控件id列表'

internal var ids = mutableListOf()

fun onPreDraw(treasureBox: TreasureBox) {

//'在关联控件重绘前,遍历它们检查其属性是否变更'

ids.forEachIndexed { index, id ->

treasureBox.findViewById(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

}

}

还需要变更下小红点绘制逻辑,之前的逻辑如下:

//'小红点标记控件'

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(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,小红点就能和动画联动了。为控件添加位移和缩放动画,测试一下:

33ce203a5c0e

image

GG思密达~

。位移动画的确会联动,但缩放并没有~

打了 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()

//'关联子控件当前帧区域列表'

var rects = mutableListOf()

//'关联子控件上一帧区域列表'

var lastRects = mutableListOf()

fun onPreDraw(treasureBox: TreasureBox) {

//'遍历关联控件'

ids.forEachIndexed { index, id ->

treasureBox.findViewById(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(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)

}

}

}

大功告成,效果如下:

33ce203a5c0e

image

talk is cheap, show me the code

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值