Android-打造強(qiáng)大的視圖控件(電影選座)

前言

做Android幾年,到現(xiàn)在,突然感覺(jué)寫(xiě)東西的效率提高很多,能寫(xiě)的東西也越來(lái)越多,突然就有種,忙不過(guò)來(lái)的感覺(jué),既興奮,有時(shí)候又會(huì)感覺(jué)有些累了.

視圖控件是一類(lèi)控件,并不單選電影選座的.這只是其中最具有代表性的一個(gè)而矣.它們具有一個(gè)特性,繪制面積非常大,繪制元素往往很密集.需要全方位的滾動(dòng),可以縮放,等等.我們這次帶著一種不一樣的思路,來(lái)做一個(gè)真正強(qiáng)大的此類(lèi)基本視圖控件.

效果預(yù)覽

HierarchyView演示


HierarchyView演示

項(xiàng)目Github

下載示例

這里介紹一下當(dāng)前大部分此類(lèi)控件的弊端

  • 往往為純繪制,擴(kuò)展性極差
  • 因?yàn)槭褂肕atrix作縮放滾動(dòng),所以丟失了控件己有的fling滾動(dòng)效果.在矩陣面積較大時(shí),體驗(yàn)不好
  • 做一些效果很難,如點(diǎn)擊一類(lèi).

本項(xiàng)目使用核心技術(shù)

  • 控件繪制
  • 控件排版
  • 控件復(fù)用理解
  • Canvas繪圖

本項(xiàng)目達(dá)成目標(biāo)

  • 采用控件己有特性如滾動(dòng),慣性滾動(dòng)
  • 采用類(lèi)子控件排版并繪制,控制性好,使用如ListView/RecyclerView一般
  • 保留了控件所有操作,如點(diǎn)擊效果,點(diǎn)擊等.
  • 核心原理簡(jiǎn)單.擴(kuò)展性強(qiáng).是一套可大量并快速?gòu)?fù)用此類(lèi)需求和基礎(chǔ)性控件

原理講解(Kotlin)

基本原理1:仿制ViewGroup控件,因?yàn)閂iewGroup強(qiáng)制的測(cè)量,排版,以及繪制,我們無(wú)法控制,所以在此,我們需要模擬一個(gè)ViewGroup,實(shí)現(xiàn)子控件測(cè)量,排版,以及繪制
Step1 添加100個(gè)簡(jiǎn)單控件

示例為:HierarchyLayout1
本控件為一個(gè)繼承了View的子控件,非ViewGroup,初始添加100個(gè)子控件,此添加為添加到內(nèi)部維護(hù)的集合內(nèi)
 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,"點(diǎn)擊${indexOfChild(it)}",Toast.LENGTH_SHORT).show()
            }
            //本控件實(shí)現(xiàn)ViewManager方法,所以有addView,而非ViewGroup添加
            addView(view,ViewGroup.LayoutParams(300,300))
        }
    }

Step2 控件模擬測(cè)量

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 完成控件縮放控制
實(shí)現(xiàn)ScaleGestureDetector對(duì)象,完成縮放示例,

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)
        //計(jì)算出放大中心點(diǎn)
        val scrollX=((scrollX+detector.focusX)/matrixScaleX*getMatrixScaleX())
        val scrollY=((scrollY+detector.focusY)/matrixScaleY*getMatrixScaleY())
        //動(dòng)態(tài)滾動(dòng)至縮放中心點(diǎn)
        scrollTo(((scrollX-detector.focusX)).toInt(), ((scrollY-detector.focusY)).toInt())
        ViewCompat.postInvalidateOnAnimation(this)
        return true
    }

以上,完成了對(duì)基本原理的理解,這是區(qū)別通過(guò)純繪制的最大區(qū)別.保留了控件的所有特性,所以可以通過(guò)布局初始化控件,設(shè)置點(diǎn)擊,減少大量的繪制控制邏輯,
接下來(lái)正式開(kāi)始控件

Step1設(shè)計(jì)數(shù)據(jù)適配器

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

        /**
         * 獲得座位排左側(cè)指示控件
         */
        abstract fun getSeatNumberView(parent:ViewGroup):View

        /**
         * 綁定座位序列
         */
        open fun bindSeatNumberView(view:View,row:Int)=Unit
        /**
         * 綁定序號(hào)列數(shù)據(jù)
         */
        open fun bindNumberLayout(numberLayout:ViewGroup)=Unit
        /**
         * 獲得座位號(hào)
         */
        abstract fun getSeatView(parent:ViewGroup,row:Int,column:Int):View

        /**
         * 綁定座位數(shù)據(jù)
         */
        abstract fun bindSeatView(parent:ViewGroup,view:View,row:Int,column:Int)

        /**
         * 獲得座位列數(shù)
         */
        abstract fun getSeatColumnCount():Int

        /**
         * 獲得座位排數(shù)
         */
        abstract fun getSeatRowCount():Int

        /**
         * 獲得橫向多余空間
         */
        abstract fun getHorizontalSpacing(column:Int):Int

        /**
         * 獲得縱向多余空間
         */
        abstract fun getVerticalSpacing(row:Int):Int

        /**
         * 某個(gè)座位是否可見(jiàn)
         */
        open fun isSeatVisible(row:Int,column:Int)=true

        /**
         * 獲得當(dāng)前座位節(jié)點(diǎn)信息
         */
        fun getSeatNodeItem(row:Int,column:Int)=table.seatArray[row][column]

        /**
         * 選中一個(gè)條目
         */
        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初始化信息

以一個(gè)對(duì)象,初始化記錄所有座位的節(jié)點(diǎn)信息,排版位置,行,列(第一版時(shí)做法)等,放在一個(gè)二維數(shù)組內(nèi).方便快速索引,然后測(cè)量所有基礎(chǔ)控件

 /**
     * 設(shè)置數(shù)據(jù)適配器
     */
    fun setAdapter(newAdapter: SeatTableAdapter){
        //重置table
        resetSeatTable()
        adapter= newAdapter
        //屏幕附加信息
        seatLayout = newAdapter.getHeaderSeatLayout(parent as ViewGroup)
        //屏幕布局
        screenView = newAdapter.getHeaderScreenView(parent as ViewGroup)
        //執(zhí)行計(jì)算,獲得矩陣前信息/屏幕信息/座位以及整個(gè)影院大小信息
        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)
            //添加節(jié)點(diǎn)信息
            (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在滾動(dòng)時(shí)建立回收與復(fù)用機(jī)制

  1. 復(fù)用原理為:界面發(fā)生滾動(dòng)時(shí),獲得當(dāng)前屏幕矩陣位置:screenRect.set(scrollX, scrollY, scrollX + width, scrollY + height)
  2. 清空所有集合內(nèi)添加控件到緩存,等待被使用
  3. 快速索引到當(dāng)前橫/縱向(第二版己優(yōu)化),然后遍歷并刷新所有數(shù)據(jù)(這里做法非常合理,效率很高,不能通過(guò)tag復(fù)用,因?yàn)樾枰檎?性能就低,直接清洗,再使用,效率最高)
//起始縱向矩陣
        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)
        }

/**
     * 查找屏幕內(nèi)起始計(jì)算矩陣,因?yàn)楫?dāng)數(shù)據(jù)量非常大時(shí),不快速找到起始遍歷位置,會(huì)非常慢
     */
    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
                }
            }
        }
        //檢測(cè)最后結(jié)果
        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()
        //當(dāng)前屏幕所占矩陣
        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)
        //繪左側(cè)指示器
        drawNumberIndicator(canvas, matrixScaleX, matrixScaleY)
        //繪當(dāng)前座位描述
        drawSeatLayout(canvas)
        //繪縮略圖
        drawPreView(canvas)
        debugLog("onDraw:${System.currentTimeMillis()-st}")
    }

   /**
     * 繪制當(dāng)前屏幕內(nèi)座位
     */
    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()
    }

以上,完成了所有核心說(shuō)明
以模擬ViewGroup,復(fù)用View,繪制的另一種思想,做此類(lèi)視圖,體驗(yàn)與性能并存,第二版專(zhuān)為優(yōu)化性能,做到百億以上,無(wú)壓力運(yùn)算.本項(xiàng)目是以HierarchyLayout為核心開(kāi)發(fā)完后,花4小時(shí),就寫(xiě)出核心,然后優(yōu)化而成,所以讀懂核心 ,此類(lèi)控件以后就非常簡(jiǎn)單了.并且第二版對(duì)二維運(yùn)算的簡(jiǎn)化,有更多可參考地方.

以上,非常感謝閱讀!

`

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容