使用自定义View和属性动画做一个自动动画汉诺塔 !

效果:
在这里插入图片描述
代码地址: https://github.com/mainxml/HanoiTower

自定义 ViewGroup HanoiTower 作为汉诺塔柱子, 并管理子view(盘子)
自定义 View DiskView 作为汉诺塔盘子视图

算法

汉诺塔每个柱子上的盘子是后进先出的, 就是说, 每个柱子都是一个栈:

public class PillarStack<T> extends Stack<T> {

    /** 柱子名 */
    private String mName;

    public PillarStack(String name) {
        mName = name;
    }

    public String getName() {
        return mName;
    }
}

然后这样就作为三个柱子的数据结构了👇

/** 汉诺塔柱子A */
private val pillarA = PillarStack<DiskView>("A")
/** 汉诺塔柱子B */
private val pillarB = PillarStack<DiskView>("B")
/** 汉诺塔柱子C */
private val pillarC = PillarStack<DiskView>("C")

这里假设大家都知道汉诺塔算法, 稍微难的地方就在如何根据汉诺塔算法的执行来使用属性动画对盘子进行移动呢?

/** 汉诺塔算法  */
private fun hanoiAlg(n: Int, a: PillarStack<DiskView>, b: PillarStack<DiskView>, c: PillarStack<DiskView>) {
    if (n > 0) {
        hanoiAlg(n - 1, a, c, b)
        move(a, c)
        hanoiAlg(n - 1, b, a, c)
    }
}

/** 移动圆盘  */
private fun move(source: PillarStack<DiskView>, target: PillarStack<DiskView>) {
    Log.i("HanoiTower", "${source.name} -> ${target.name}")

    val diskView = source.pop()
    animationMove(diskView, target.name) // 👈
    target.push(diskView)
}

上面的代码就是汉诺塔算法, 每次移动的操作都会移动栈(柱子)上的盘子, 执行动画的地方就在 animationMove() 方法中.

属性动画

/** 动画移动圆盘  */
private fun animationMove(child: View, target: String) {
    up(child)
    leftOrRight(child, target[0].toInt() - 'A'.toInt()) // 参数表示目标柱子和A柱子的距离
    down(child, target)
}

private fun up(child: View) {
    val endValue = height / 3f
    val animator = ObjectAnimator.ofFloat(child, "Y", endValue)
    animator.addListener(onEnd = {
        animatorList.remove(it)
        animatorList.first.start()
    })
    animatorList.addLast(animator)
}

private fun leftOrRight(child: View, moveCount: Int) {
    val endValue = child.x + width / 4f * moveCount
    val animator = ObjectAnimator.ofFloat(child, "X", endValue)
    animator.addListener(onEnd = {
        animatorList.remove(it)
        animatorList.first.start()
    })
    animatorList.addLast(animator)
}

private fun down(child: View, target: String) {
    var offsetBottom = 0f

    // 根据目标柱子上盘子的数量算下落偏移
    when(target) {
        "A" -> offsetBottom = pillarA.count() * (child.height + dp2px(2))
        "B" -> offsetBottom = pillarB.count() * (child.height + dp2px(2))
        "C" -> offsetBottom = pillarC.count() * (child.height + dp2px(2))
    }

    val endValue = height - dp2px(34) - offsetBottom
    val animator = ObjectAnimator.ofFloat(child, "Y", endValue)
    animator.addListener(onEnd = {
        animatorList.remove(it)
        if (animatorList.size > 0){
            animatorList.first.start()
        }
    })
    animatorList.addLast(animator)

    // 开始
    animatorList.first.start()
}

animatorList 是一个链表数组, 使用 ObjectAnimator 对子视图的 X 轴或 Y 轴进行移动. 每一次盘子的移动就有三次的动画, 分别是向上, 向左/右, 向下.

自定义View

DiskView 作为盘子视图, 就是简单的绘制一个圆角矩形出来而已, 代码就不上了, HanoiTower 是一个 ViewGroup, 负责对子view(DiskView)进行布局:

class HanoiTower @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
                                           defStyleAttr: Int = 0, defStyleRes: Int = 0) :
        ViewGroup(context, attrs, defStyleAttr, defStyleRes) {

    /** 汉诺塔柱子A */
    private val pillarA = PillarStack<DiskView>("A")
    /** 汉诺塔柱子B */
    private val pillarB = PillarStack<DiskView>("B")
    /** 汉诺塔柱子C */
    private val pillarC = PillarStack<DiskView>("C")
    /** 圆盘数量 */
    private var diskCount = 3

    private val animatorList = LinkedList<ValueAnimator>()
    private var paint: Paint

    init {
        // ViewGroup默认不执行onDraw, 而我们需要绘画
        setWillNotDraw(false)

        paint = Paint().apply {
            color = Color.GRAY
            style = Paint.Style.FILL
            isAntiAlias = true
        }

        setDiskCount(diskCount)
    }

    /**
     * 设置初始盘子数
     */
    fun setDiskCount(count: Int) {
        if (pillarA.size == count){
            return
        }
        diskCount = count

        for (i in 0 until animatorList.size) {
            animatorList.first.cancel()
        }
        animatorList.clear()

        pillarA.clear()
        pillarB.clear()
        pillarC.clear()
        removeAllViews()

        val random = Random()
        for (i in 0 until diskCount) {
            val diskView = DiskView(context)

            // 随机深色
            var r: Int
            var g: Int
            var b: Int
            do {
                r = random.nextInt(256)
                g = random.nextInt(256)
                b = random.nextInt(256)
            } while (r - g - b < 0)
            val color = Color.rgb(r, g, b)

            diskView.setColor(color)
            pillarA.add(diskView)

            addView(diskView)
        }

        requestLayout()
    }

    /** 开始 */
    fun start() {
        setDiskCount(diskCount)
        // 使用post让页面重建后再开始
        post { hanoiAlg(diskCount, pillarA, pillarB, pillarC) }
    }

    /** 汉诺塔算法  */
    private fun hanoiAlg(n: Int, a: PillarStack<DiskView>, b: PillarStack<DiskView>, c: PillarStack<DiskView>) {
        if (n > 0) {
            hanoiAlg(n - 1, a, c, b)
            move(a, c)
            hanoiAlg(n - 1, b, a, c)
        }
    }

    /** 移动圆盘  */
    private fun move(source: PillarStack<DiskView>, target: PillarStack<DiskView>) {
        Log.i("HanoiTower", "${source.name} -> ${target.name}")

        val diskView = source.pop()
        animationMove(diskView, target.name)
        target.push(diskView)
    }

    /** 动画移动圆盘  */
    private fun animationMove(child: View, target: String) {
        up(child)
        leftOrRight(child, target[0].toInt() - 'A'.toInt())
        down(child, target)
    }

    private fun up(child: View) {
        val endValue = height / 3f
        val animator = ObjectAnimator.ofFloat(child, "Y", endValue)
        animator.addListener(onEnd = {
            animatorList.remove(it)
            animatorList.first.start()
        })
        animatorList.addLast(animator)
    }

    private fun leftOrRight(child: View, moveCount: Int) {
        val endValue = child.x + width / 4f * moveCount
        val animator = ObjectAnimator.ofFloat(child, "X", endValue)
        animator.addListener(onEnd = {
            animatorList.remove(it)
            animatorList.first.start()
        })
        animatorList.addLast(animator)
    }

    private fun down(child: View, target: String) {
        var offsetBottom = 0f

        // 根据目标柱子上盘子的数量算下落偏移
        when(target) {
            "A" -> offsetBottom = pillarA.count() * (child.height + dp2px(2))
            "B" -> offsetBottom = pillarB.count() * (child.height + dp2px(2))
            "C" -> offsetBottom = pillarC.count() * (child.height + dp2px(2))
        }

        val endValue = height - dp2px(34) - offsetBottom
        val animator = ObjectAnimator.ofFloat(child, "Y", endValue)
        animator.addListener(onEnd = {
            animatorList.remove(it)
            if (animatorList.size > 0){
                animatorList.first.start()
            }
        })
        animatorList.addLast(animator)

        // 开始
        animatorList.first.start()
    }

    /**
     * 汉诺塔的大小不用根据子View的大小来决定, 所以直接测量所有子View
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 宽规格大小
        val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
        // 第一根柱子的中点
        val pillarDistance = widthSpecSize / 4

        var childWidth = pillarDistance

        children.forEach {
            it.layoutParams.width = childWidth
            val newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY)
            childWidth -= dp2px(8).toInt()

            measureChild(it, newWidthMeasureSpec, heightMeasureSpec)
        }

        val defaultWidth = dp2px(300).toInt()
        val defaultHeight = dp2px(300).toInt()
        val measuredWidth = View.resolveSize(defaultWidth, widthMeasureSpec)
        val measuredHeight = View.resolveSize(defaultHeight, heightMeasureSpec)
        setMeasuredDimension(measuredWidth, measuredHeight)
    }

    /**
     * 把全部子View排布到第一根柱子上
     */
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 获取此ViewGroup去掉Padding后的高度
        val h = height - paddingTop - paddingBottom
        // 第一根柱子的X轴中点
        val pillarCenter = width / 4
        // 圆盘底部开始布局的位置
        var bottomStart = h - dp2px(34) // h - dp2px(24) 为底盘开始的地方

        for (i in 0 until childCount) {
            val child = getChildAt(i)
            child.layout(
                    pillarCenter - child.measuredWidth / 2,
                    bottomStart.toInt(),
                    pillarCenter - child.measuredWidth / 2 + child.measuredWidth,
                    bottomStart.toInt() + child.measuredHeight
            )
            bottomStart -= child.measuredHeight + dp2px(2)
        }
    }

    override fun onDraw(canvas: Canvas?) {
        val pl = paddingLeft.toFloat()
        val pt = paddingTop.toFloat()
        val pr = paddingRight.toFloat()
        val pb = paddingBottom.toFloat()
        val w = width - pl - pr
        val h = height - pt - pb

        // 第一根柱子的X轴中点
        val pillarDistance = w / 4
        // 柱子的宽度
        val pillarWidth = dp2px(4)

        canvas?.apply {
            // 画第一根柱子
            drawRect(pillarDistance - pillarWidth / 2, h / 2,
                    pillarDistance + pillarWidth / 2, h, paint)
            // 画第二根柱子
            drawRect(pillarDistance * 2 - pillarWidth / 2, h / 2,
                    pillarDistance * 2 + pillarWidth / 2, h, paint)
            // 画第三根柱子
            drawRect(pillarDistance * 3 - pillarWidth / 2, h / 2,
                    pillarDistance * 3 + pillarWidth / 2, h, paint)
            // 画底盘
            drawRect(pl, h - dp2px(24), w, h, paint)
        }
    }

    private fun dp2px(dp: Int): Float {
        val scale = context.resources.displayMetrics.density
        return dp * scale + 0.5f
    }
}

属性动画执行一次的时间默认是300ms, 移动一次盘子需要三次动画, 共900ms, 约1s, 传说某间寺院有三根银棒, 上串 64 个金盘. 寺院里的僧侣依照一个古老的预言, 以汉诺塔规则移动这些盘子, 预言说当这些盘子移动完毕,世界就会灭亡.
若传说属实,僧侣们最少需移动 2^64-1 次才能完成这个任务, 若他们每秒可完成一个盘子的移动, 就需要 5849 亿年才能完成.

整个宇宙现在也不过 137 亿年.

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值