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

前言

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

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

效果預(yù)覽

HierarchyView演示


HierarchyView演示

項(xiàng)目Github

下載示例

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

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

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

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

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

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

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è)座位是否可見
         */
        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,繪制的另一種思想,做此類視圖,體驗(yàn)與性能并存,第二版專為優(yōu)化性能,做到百億以上,無(wú)壓力運(yùn)算.本項(xiàng)目是以HierarchyLayout為核心開發(fā)完后,花4小時(shí),就寫出核心,然后優(yōu)化而成,所以讀懂核心 ,此類控件以后就非常簡(jiǎn)單了.并且第二版對(duì)二維運(yùn)算的簡(jiǎn)化,有更多可參考地方.

以上,非常感謝閱讀!

`

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末遇伞,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子捶牢,更是在濱河造成了極大的恐慌赃额,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,657評(píng)論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件叫确,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡芍锦,警方通過(guò)查閱死者的電腦和手機(jī)竹勉,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,889評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)娄琉,“玉大人次乓,你說(shuō)我怎么就攤上這事吓歇。” “怎么了票腰?”我有些...
    開封第一講書人閱讀 164,057評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵城看,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我杏慰,道長(zhǎng)测柠,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,509評(píng)論 1 293
  • 正文 為了忘掉前任缘滥,我火速辦了婚禮轰胁,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘朝扼。我一直安慰自己赃阀,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,562評(píng)論 6 392
  • 文/花漫 我一把揭開白布擎颖。 她就那樣靜靜地躺著榛斯,像睡著了一般。 火紅的嫁衣襯著肌膚如雪搂捧。 梳的紋絲不亂的頭發(fā)上驮俗,一...
    開封第一講書人閱讀 51,443評(píng)論 1 302
  • 那天,我揣著相機(jī)與錄音异旧,去河邊找鬼意述。 笑死,一個(gè)胖子當(dāng)著我的面吹牛吮蛹,可吹牛的內(nèi)容都是我干的荤崇。 我是一名探鬼主播,決...
    沈念sama閱讀 40,251評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼潮针,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼术荤!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起每篷,我...
    開封第一講書人閱讀 39,129評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤瓣戚,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后焦读,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體子库,經(jīng)...
    沈念sama閱讀 45,561評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,779評(píng)論 3 335
  • 正文 我和宋清朗相戀三年矗晃,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了仑嗅。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,902評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖仓技,靈堂內(nèi)的尸體忽然破棺而出鸵贬,到底是詐尸還是另有隱情,我是刑警寧澤脖捻,帶...
    沈念sama閱讀 35,621評(píng)論 5 345
  • 正文 年R本政府宣布阔逼,位于F島的核電站,受9級(jí)特大地震影響地沮,放射性物質(zhì)發(fā)生泄漏嗜浮。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,220評(píng)論 3 328
  • 文/蒙蒙 一诉濒、第九天 我趴在偏房一處隱蔽的房頂上張望周伦。 院中可真熱鬧,春花似錦未荒、人聲如沸专挪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,838評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)寨腔。三九已至,卻和暖如春率寡,著一層夾襖步出監(jiān)牢的瞬間迫卢,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,971評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工冶共, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留乾蛤,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,025評(píng)論 2 370
  • 正文 我出身青樓捅僵,卻偏偏與公主長(zhǎng)得像家卖,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子庙楚,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,843評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容