美文网首页AndroidAndroid自定义View
Android-打造强大的视图控件(电影选座)

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

作者: 章鱼脑袋 | 来源:发表于2017-10-19 21:16 被阅读193次

    前言

    做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小时,就写出核心,然后优化而成,所以读懂核心 ,此类控件以后就非常简单了.并且第二版对二维运算的简化,有更多可参考地方.

    以上,非常感谢阅读!

    `

    相关文章

      网友评论

      本文标题:Android-打造强大的视图控件(电影选座)

      本文链接:https://www.haomeiwen.com/subject/mkupuxtx.html