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

简介: 上一篇通过在父控件绘制前景的方式展示小红点,在布局文件中配置标记控件就能为任意子控件添加小红点。实现方案是”布局文件中配置带小红点控件 id,在父控件中获取它们的坐标,并在其右上角绘制圆圈“。但这个方

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

这是自定义控件系列教程的第七篇,系列文章目录如下:

  1. Android自定义控件 | View绘制原理(画多大?)
  2. Android自定义控件 | View绘制原理(画在哪?)
  3. Android自定义控件 | View绘制原理(画什么?)
  4. Android自定义控件 | 源码里有宝藏之自动换行控件
  5. Android自定义控件 | 小红点的三种实现(上)
  6. Android自定义控件 | 小红点的三种实现(下)
  7. Android自定义控件 | 小红点的三种实现(终结)


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

监听重绘

在父控件的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();
    }
}

CoordinatorLayoutonAttachedToWindow()时注册了 View 树观察者,子控件属性变化时必定会触发 View树重绘,这样就可以在onPreDraw()中监听到它们的属性变化。

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

//自定义容器控件,需配合标记控件使用
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
    }
}

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

//'小红点标记控件'
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.rightv.top换成v.xv.y,小红点就能和动画联动了。为控件添加位移和缩放动画,测试一下:


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<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)
            }
        }
    }

大功告成,效果如下:

talk is cheap, show me the code

目录
相关文章
|
2月前
|
缓存 搜索推荐 Android开发
安卓开发中的自定义控件实践
【10月更文挑战第4天】在安卓开发的海洋中,自定义控件是那片璀璨的星辰。它不仅让应用界面设计变得丰富多彩,还提升了用户体验。本文将带你探索自定义控件的核心概念、实现过程以及优化技巧,让你的应用在众多竞争者中脱颖而出。
|
15天前
|
搜索推荐 Android开发 开发者
安卓应用开发中的自定义控件实践
在安卓应用开发的广阔天地中,自定义控件如同璀璨的星辰,点亮了用户界面设计的夜空。它们不仅丰富了交互体验,更赋予了应用独特的个性。本文将带你领略自定义控件的魅力,从基础概念到实际应用,一步步揭示其背后的原理与技术细节。我们将通过一个简单的例子——打造一个具有独特动画效果的按钮,来展现自定义控件的强大功能和灵活性。无论你是初学者还是资深开发者,这篇文章都将为你打开一扇通往更高阶UI设计的大门。
|
2月前
|
缓存 搜索推荐 Android开发
安卓开发中的自定义控件基础与进阶
【10月更文挑战第5天】在Android应用开发中,自定义控件是提升用户体验和界面个性化的重要手段。本文将通过浅显易懂的语言和实例,引导你了解自定义控件的基本概念、创建流程以及高级应用技巧,帮助你在开发过程中更好地掌握自定义控件的使用和优化。
48 10
|
1月前
|
前端开发 Android开发 UED
安卓应用开发中的自定义控件实践
【10月更文挑战第35天】在移动应用开发中,自定义控件是提升用户体验、增强界面表现力的重要手段。本文将通过一个安卓自定义控件的创建过程,展示如何从零开始构建一个具有交互功能的自定义视图。我们将探索关键概念和步骤,包括继承View类、处理测量与布局、绘制以及事件处理。最终,我们将实现一个简单的圆形进度条,并分析其性能优化。
|
2月前
|
前端开发 搜索推荐 Android开发
安卓开发中的自定义控件实践
【10月更文挑战第4天】在安卓开发的世界里,自定义控件如同画家的画笔,能够绘制出独一无二的界面。通过掌握自定义控件的绘制技巧,开发者可以突破系统提供的界面元素限制,创造出既符合品牌形象又提供卓越用户体验的应用。本文将引导你了解自定义控件的核心概念,并通过一个简单的例子展示如何实现一个基本的自定义控件,让你的安卓应用在视觉和交互上与众不同。
|
27天前
|
开发框架 前端开发 Android开发
安卓与iOS开发中的跨平台策略
在移动应用开发的战场上,安卓和iOS两大阵营各据一方。随着技术的演进,跨平台开发框架成为开发者的新宠,旨在实现一次编码、多平台部署的梦想。本文将探讨跨平台开发的优势与挑战,并分享实用的开发技巧,帮助开发者在安卓和iOS的世界中游刃有余。
|
14天前
|
搜索推荐 前端开发 API
探索安卓开发中的自定义视图:打造个性化用户界面
在安卓应用开发的广阔天地中,自定义视图是一块神奇的画布,让开发者能够突破标准控件的限制,绘制出独一无二的用户界面。本文将带你走进自定义视图的世界,从基础概念到实战技巧,逐步揭示如何在安卓平台上创建和运用自定义视图来提升用户体验。无论你是初学者还是有一定经验的开发者,这篇文章都将为你打开新的视野,让你的应用在众多同质化产品中脱颖而出。
40 19
|
27天前
|
IDE Java 开发工具
移动应用与系统:探索Android开发之旅
在这篇文章中,我们将深入探讨Android开发的各个方面,从基础知识到高级技术。我们将通过代码示例和案例分析,帮助读者更好地理解和掌握Android开发。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的信息和技巧。让我们一起开启Android开发的旅程吧!
|
14天前
|
JSON Java API
探索安卓开发:打造你的首个天气应用
在这篇技术指南中,我们将一起潜入安卓开发的海洋,学习如何从零开始构建一个简单的天气应用。通过这个实践项目,你将掌握安卓开发的核心概念、界面设计、网络编程以及数据解析等技能。无论你是初学者还是有一定基础的开发者,这篇文章都将为你提供一个清晰的路线图和实用的代码示例,帮助你在安卓开发的道路上迈出坚实的一步。让我们一起开始这段旅程,打造属于你自己的第一个安卓应用吧!
41 14
|
17天前
|
Java Linux 数据库
探索安卓开发:打造你的第一款应用
在数字时代的浪潮中,每个人都有机会成为创意的实现者。本文将带你走进安卓开发的奇妙世界,通过浅显易懂的语言和实际代码示例,引导你从零开始构建自己的第一款安卓应用。无论你是编程新手还是希望拓展技术的开发者,这篇文章都将为你打开一扇门,让你的创意和技术一起飞扬。