Android-打造强大的视图控件(电影选座)

前言

做Android几年,到现在,突然感觉写东西的效率提高很多,能写的东西也越来越多,突然就有种,忙不过来的感觉,既兴奋,有时候又会感觉有些累了.

视图控件是一类控件,并不单选电影选座的.这只是其中最具有代表性的一个而矣.它们具有一个特性,绘制面积非常大,绘制元素往往很密集.需要全方位的滚动,可以缩放,等等.我们这次带着一种不一样的思路,来做一个真正强大的此类基本视图控件.

效果预览

HierarchyView演示


HierarchyView演示

项目Github

下载示例

这里介绍一下当前大部分此类控件的弊端

  • 往往为纯绘制,扩展性极差
  • 因为使用Matrix作缩放滚动,所以丢失了控件己有的fling滚动效果.在矩阵面积较大时,体验不好
  • 做一些效果很难,如点击一类.

本项目使用核心技术

  • 控件绘制
  • 控件排版
  • 控件复用理解
  • Canvas绘图

本项目达成目标

  • 采用控件己有特性如滚动,惯性滚动
  • 采用类子控件排版并绘制,控制性好,使用如ListView/RecyclerView一般
  • 保留了控件所有操作,如点击效果,点击等.
  • 核心原理简单.扩展性强.是一套可大量并快速复用此类需求和基础性控件

原理讲解(Kotlin)

基本原理1:仿制ViewGroup控件,因为ViewGroup强制的测量,排版,以及绘制,我们无法控制,所以在此,我们需要模拟一个ViewGroup,实现子控件测量,排版,以及绘制
Step1 添加100个简单控件

示例为:HierarchyLayout1
本控件为一个继承了View的子控件,非ViewGroup,初始添加100个子控件,此添加为添加到内部维护的集合内
 init {
        val random=Random()
        (0..100).forEach {
            val view=View(context)
            val color=Color.argb(0xff,random.nextInt(0xFF),random.nextInt(0xFF),random.nextInt(0xFF))
            val pressColor=Color.argb(0xff,Math.min(0xff,Color.red(color)+30),Math.min(0xff,Color.green(color)+30),Math.min(0xff,Color.blue(color)+30))
            val drawable=StateListDrawable()
            drawable.addState(intArrayOf(android.R.attr.state_empty),ColorDrawable(color))
            drawable.addState(intArrayOf(android.R.attr.state_pressed),ColorDrawable(pressColor))
            view.backgroundDrawable=drawable
            view.setOnClickListener {
                Toast.makeText(context,"点击${indexOfChild(it)}",Toast.LENGTH_SHORT).show()
            }
            //本控件实现ViewManager方法,所以有addView,而非ViewGroup添加
            addView(view,ViewGroup.LayoutParams(300,300))
        }
    }

Step2 控件模拟测量

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        for(view in views){
            measureChildWithMargins(view,MeasureSpec.getMode(widthMeasureSpec),MeasureSpec.getMode(heightMeasureSpec))
        }
    }

    fun measureChildWithMargins(child: View, widthMode: Int, heightMode: Int) {
        val lp = child.layoutParams as ViewGroup.LayoutParams
        val widthSpec = getChildMeasureSpec(width, widthMode, paddingLeft + paddingRight, lp.width)
        val heightSpec = getChildMeasureSpec(height, heightMode, paddingTop + paddingBottom, lp.height)
        child.measure(widthSpec, heightSpec)
    }


    fun getChildMeasureSpec(parentSize: Int, parentMode: Int, padding: Int, childDimension: Int): Int {
        val size = Math.max(0, parentSize - padding)
        var resultSize = 0
        var resultMode = 0
        if (childDimension >= 0) {
            resultSize = childDimension
            resultMode = View.MeasureSpec.EXACTLY
        } else {
            if (childDimension == ViewGroup.LayoutParams.MATCH_PARENT) {
                resultSize = size
                resultMode = parentMode
            } else if (childDimension == ViewGroup.LayoutParams.WRAP_CONTENT) {
                resultSize = size
                if (parentMode == View.MeasureSpec.AT_MOST || parentMode == View.MeasureSpec.EXACTLY) {
                    resultMode = View.MeasureSpec.AT_MOST
                } else {
                    resultMode = View.MeasureSpec.UNSPECIFIED
                }
            }
        }
        return View.MeasureSpec.makeMeasureSpec(resultSize, resultMode)
    }

Step3 模拟排版

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val value=8
        (0..getChildCount()-1).forEach {
            val row=(it/value)
            val column=it%value
            val childView=getChildAt(it)
            debugLog("onLayout index:$it row:$row column:$column")
            childView.layout((column*300), (row*300), ((column+1)*300), ((row+1)*300))
            setChildPress(childView,false)
        }
    }

Step4 绘制控件

override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val value=8
        (0..getChildCount()-1).forEach {
            val row=(it/value)
            val column=it%value
            val childView=getChildAt(it)
            canvas.save()
            canvas.translate((column*300).toFloat(), (row*300).toFloat())
            childView.draw(canvas)
            canvas.restore()
        }
    }

Step5 完成控件缩放控制
实现ScaleGestureDetector对象,完成缩放示例,

private var MAX_SCALE=3.0f
private var MIN_SCALE=1f
override fun onScale(detector: ScaleGestureDetector): Boolean {
        var scaleFactor=detector.scaleFactor
        val matrixScaleX = getMatrixScaleX()
        val matrixScaleY = getMatrixScaleY()
        if(MIN_SCALE>scaleFactor*matrixScaleX){
            scaleFactor=MIN_SCALE/matrixScaleX
        } else if(MAX_SCALE<scaleFactor*matrixScaleX){
            scaleFactor=MAX_SCALE/matrixScaleX
        }
        scaleMatrix.postScale(scaleFactor, scaleFactor, detector.focusX, detector.focusY)
        //计算出放大中心点
        val scrollX=((scrollX+detector.focusX)/matrixScaleX*getMatrixScaleX())
        val scrollY=((scrollY+detector.focusY)/matrixScaleY*getMatrixScaleY())
        //动态滚动至缩放中心点
        scrollTo(((scrollX-detector.focusX)).toInt(), ((scrollY-detector.focusY)).toInt())
        ViewCompat.postInvalidateOnAnimation(this)
        return true
    }

以上,完成了对基本原理的理解,这是区别通过纯绘制的最大区别.保留了控件的所有特性,所以可以通过布局初始化控件,设置点击,减少大量的绘制控制逻辑,
接下来正式开始控件

Step1设计数据适配器

abstract class SeatTableAdapter(val table: SeatTable1){
        /**
         * 获得顶部座位
         */
        abstract fun getHeaderSeatLayout(parent:ViewGroup):View
        /**
         * 获得屏幕控件
         */
        abstract fun getHeaderScreenView(parent:ViewGroup):View

        /**
         * 获得座位排左侧指示控件
         */
        abstract fun getSeatNumberView(parent:ViewGroup):View

        /**
         * 绑定座位序列
         */
        open fun bindSeatNumberView(view:View,row:Int)=Unit
        /**
         * 绑定序号列数据
         */
        open fun bindNumberLayout(numberLayout:ViewGroup)=Unit
        /**
         * 获得座位号
         */
        abstract fun getSeatView(parent:ViewGroup,row:Int,column:Int):View

        /**
         * 绑定座位数据
         */
        abstract fun bindSeatView(parent:ViewGroup,view:View,row:Int,column:Int)

        /**
         * 获得座位列数
         */
        abstract fun getSeatColumnCount():Int

        /**
         * 获得座位排数
         */
        abstract fun getSeatRowCount():Int

        /**
         * 获得横向多余空间
         */
        abstract fun getHorizontalSpacing(column:Int):Int

        /**
         * 获得纵向多余空间
         */
        abstract fun getVerticalSpacing(row:Int):Int

        /**
         * 某个座位是否可见
         */
        open fun isSeatVisible(row:Int,column:Int)=true

        /**
         * 获得当前座位节点信息
         */
        fun getSeatNodeItem(row:Int,column:Int)=table.seatArray[row][column]

        /**
         * 选中一个条目
         */
        fun setItemSelected(row:Int,column:Int,select:Boolean){
            table.setItemSelected(row,column,select)
        }

        fun setItemSelected(item:SeatNodeInfo,select:Boolean){
            table.setItemSelected(item,select)
        }

        fun getSeatNodeByView(v:View)=table.getSeatNodeByView(v)


    }

Step2初始化信息

以一个对象,初始化记录所有座位的节点信息,排版位置,行,列(第一版时做法)等,放在一个二维数组内.方便快速索引,然后测量所有基础控件

 /**
     * 设置数据适配器
     */
    fun setAdapter(newAdapter: SeatTableAdapter){
        //重置table
        resetSeatTable()
        adapter= newAdapter
        //屏幕附加信息
        seatLayout = newAdapter.getHeaderSeatLayout(parent as ViewGroup)
        //屏幕布局
        screenView = newAdapter.getHeaderScreenView(parent as ViewGroup)
        //执行计算,获得矩阵前信息/屏幕信息/座位以及整个影院大小信息
        val columnCount = newAdapter.getSeatColumnCount()
        val rowCount = newAdapter.getSeatRowCount()
        seatArray = Array(rowCount){ row->
            //添加序列信息
            val numberView=newAdapter.getSeatNumberView(parent as ViewGroup)
            newAdapter.bindSeatNumberView(numberView,row)
            numberLayout.addView(numberView)
            //添加节点信息
            (0..columnCount-1).map {SeatNodeInfo(row,it) }.toTypedArray()
        }
        val seatView = recyclerBin.newViewWithMeasured(seatArray[0][0])
        newAdapter.bindSeatView(parent as ViewGroup,seatView,0,0)
        addView(seatView)
        newAdapter.bindNumberLayout(numberLayout)
        requestLayout()
    }

Step3在滚动时建立回收与复用机制

  1. 复用原理为:界面发生滚动时,获得当前屏幕矩阵位置:screenRect.set(scrollX, scrollY, scrollX + width, scrollY + height)
  2. 清空所有集合内添加控件到缓存,等待被使用
  3. 快速索引到当前横/纵向(第二版己优化),然后遍历并刷新所有数据(这里做法非常合理,效率很高,不能通过tag复用,因为需要查找,性能就低,直接清洗,再使用,效率最高)
//起始纵向矩阵
        val startRange=findScreenRange(seatArray.map { it[0] }.toTypedArray()){
            tmpRect.set((it.left * matrixScaleX).toInt(),(it.top * matrixScaleY).toInt(),(it.right * matrixScaleX).toInt(), (it.bottom * matrixScaleY).toInt())
            intersetsVerticalRect(screenRect,tmpRect)
        }
        //横向查
        val endRange=findScreenRange(seatArray[0]){
            tmpRect.set((it.left * matrixScaleX).toInt(),(it.top * matrixScaleY).toInt(),(it.right * matrixScaleX).toInt(), (it.bottom * matrixScaleY).toInt())
            intersetsHorizontalRect(screenRect,tmpRect)
        }

/**
     * 查找屏幕内起始计算矩阵,因为当数据量非常大时,不快速找到起始遍历位置,会非常慢
     */
    private fun findScreenRange(array:Array<SeatNodeInfo>,predicate:(Rect)->Boolean):IntRange{
        var (start,end)=-1 to -1
        //纵向查
        run{ array.forEachIndexed { row,node ->
                val intersects=predicate(node.layoutRect)
                if(-1==start&&intersects){
                    start=row//记录头
                } else if(-1!=start&&!intersects){
                    end=row
                    return@run
                }
            }
        }
        //检测最后结果
        if(-1==end){
            end=array.size-1
        }
        return IntRange(start,end)
    }
  1. 绘制所有元素
//遍历所有子孩子
fun forEachChild(action:(View)->Unit)=views.forEach(action)

override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        adapter?:return
        val st=System.currentTimeMillis()
        //当前屏幕所占矩阵
        val matrixScaleX = getMatrixScaleX()
        val matrixScaleY = getMatrixScaleY()
        //绘制座位整体信息
        screenRect.set(scrollX, scrollY, scrollX + width, scrollY + height)
        //绘电影院座位
        forEachChild { drawSeatView(canvas, it, matrixScaleX, matrixScaleY) }
        //绘屏幕
        drawScreen(canvas, screenRect, matrixScaleX, matrixScaleY)
        //绘左侧指示器
        drawNumberIndicator(canvas, matrixScaleX, matrixScaleY)
        //绘当前座位描述
        drawSeatLayout(canvas)
        //绘缩略图
        drawPreView(canvas)
        debugLog("onDraw:${System.currentTimeMillis()-st}")
    }

   /**
     * 绘制当前屏幕内座位
     */
    private fun drawSeatView(canvas: Canvas,childView:View, matrixScaleX: Float, matrixScaleY: Float) {
        canvas.save()
        //此处,按此比例放大控件
        canvas.scale(matrixScaleX, matrixScaleY)
        canvas.translate(childView.left.toFloat(), childView.top.toFloat())
        val item=childView.tag as SeatNodeInfo
        childView.isSelected=item.select
        childView.draw(canvas)
        canvas.restore()
    }

以上,完成了所有核心说明
以模拟ViewGroup,复用View,绘制的另一种思想,做此类视图,体验与性能并存,第二版专为优化性能,做到百亿以上,无压力运算.本项目是以HierarchyLayout为核心开发完后,花4小时,就写出核心,然后优化而成,所以读懂核心 ,此类控件以后就非常简单了.并且第二版对二维运算的简化,有更多可参考地方.

以上,非常感谢阅读!

`

推荐阅读更多精彩内容