前言
做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
以上,完成了对基本原理的理解,这是区别通过纯绘制的最大区别.保留了控件的所有特性,所以可以通过布局初始化控件,设置点击,减少大量的绘制控制逻辑,
接下来正式开始控件
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在滚动时建立回收与复用机制
- 复用原理为:界面发生滚动时,获得当前屏幕矩阵位置:screenRect.set(scrollX, scrollY, scrollX + width, scrollY + height)
- 清空所有集合内添加控件到缓存,等待被使用
- 快速索引到当前横/纵向(第二版己优化),然后遍历并刷新所有数据(这里做法非常合理,效率很高,不能通过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,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)
}
- 绘制所有元素
//遍历所有子孩子
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小时,就写出核心,然后优化而成,所以读懂核心 ,此类控件以后就非常简单了.并且第二版对二维运算的简化,有更多可参考地方.以上,非常感谢阅读!
`