前言
上篇文章经过一个有header和footer的翻滚控件(Viewgroup)学了下MeasureSpec、onMeasure以及onLayout,接下来就用一个翻滚挑选的控件(View)来学一下onDraw的运用,而且了解下在XML自界说控件参数。
需求
这儿便是一个翻滚挑选文字的控件,仍是挺常见的,之前用别人的,现在挑选手撕一个,中心思想如下:
- 1、有三层不同巨细及透明度的选项,选中项放在中心
- 2、承受一个列表的数据,静态时显现三个值,翻滚时显现四个值
- 3、滑动会形成三个选项翻滚,巨细透明度发生改变,会有一个新的选项呈现
- 4、翻滚必定间隔后,断定是否选中一个项目,并触发动画翻滚到选定项
作用图
编写代码
老实说下面写的代码并不好,特别是TextItem的制作,本来是能够经过一个数学函数,依据滑动间隔来映射缩放份额及方位的,如有需求能够参阅这个控件,忘了是几年前从哪里抄的了,里边对文字的控制写的很好。
下面是我手撕的代码:
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.view.View.MeasureSpec.*
import androidx.core.animation.addListener
import com.silencefly96.module_common.R
import kotlin.math.abs
import kotlin.math.min
/**
* 翻滚挑选文字控件
* 中心思想
* 1、有三层不同巨细及透明度的选项,选中项放在中心
* 2、承受一个列表的数据,静态时显现三个值,翻滚时显现四个值
* 3、滑动会形成三个选项翻滚,巨细透明度发生改变,会有一个新的选项呈现
* 4、翻滚必定间隔后,断定是否选中一个项目,并触发动画翻滚到选定项
*/
class ScrollSelectView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attributeSet, defStyleAttr){
//默许字体透明度,巨细不应该指定,应该依据view的高度按百分比适应
companion object{
const val DEFAULT_MAIN_TRANSPARENCY = 255
const val DEFAULT_SECOND_TRANSPARENCY = (255 * 0.5f).toInt()
//三种item类型
const val ITEM_TYPE_MAIN = 1
const val ITEM_TYPE_SECOND = 2
const val ITEM_TYPE_NEW = 3
}
//两层字体巨细及透明度
private var mainSize: Float = 0f
private var secondSize: Float = 0f
private val mainAlpha: Int
private val secondAlpha: Int
//主次item高度,由主item所占份额决议
private val mainItemPercent: Float
private var mainHeight: Float = 0f
private var secondHeight: Float = 0f
//字体相对于框的缩放份额
private val textScanSize: Int
//切换项意图y轴滑动间隔门限值
private var itemChangeYCapacity: Float
//释放滑动动画作用间隔
private var afterUpAnimatorPeriod: Int
//数据
@Suppress("MemberVisibilityCanBePrivate")
var mData: List<String>? = null
//挑选数据index
@Suppress("MemberVisibilityCanBePrivate")
var mCurrentIndex: Int = 0
//制作的item列表
private var mItemList: MutableList<TextItem> = ArrayList()
//单次事情序列累计滑动值
private var mScrollY: Float = 0f
//上次事情纵坐标
private var mLastY: Float = 0f
//画笔
private val mPaint: Paint
init {
//读取XML参数,设置相关特点
val attrArr = context.obtainStyledAttributes(attributeSet, R.styleable.ScrollSelectView)
//三层字体透明度设置,未设置运用默许值
mainAlpha = attrArr.getInteger(R.styleable.ScrollSelectView_mainAlpha,
DEFAULT_MAIN_TRANSPARENCY)
secondAlpha = attrArr.getInteger(R.styleable.ScrollSelectView_secondAlpha,
DEFAULT_SECOND_TRANSPARENCY)
textScanSize = attrArr.getInteger(R.styleable.ScrollSelectView_textScanSize, 2)
//取到的值限制为dp值,需求转换
itemChangeYCapacity =
attrArr.getDimension(R.styleable.ScrollSelectView_changeItemYCapacity, 0f)
itemChangeYCapacity = dp2px(context, itemChangeYCapacity).toFloat()
afterUpAnimatorPeriod =
attrArr.getInteger(R.styleable.ScrollSelectView_afterUpAnimatorPeriod, 300)
//获取主item所占份额,在onMeasure中核算得到主次item高度
mainItemPercent = attrArr.getFraction(
R.styleable.ScrollSelectView_mainItemPercent, 1,1,0.5f)
//回收
attrArr.recycle()
//设置画笔,在构造中初始化,不要写在onDraw里边,onDraw会不断触发
mPaint = Paint().apply {
flags = Paint.ANTI_ALIAS_FLAG
style = Paint.Style.FILL
//该方法即为设置基线上那个点究竟是left,center,仍是right
textAlign = Paint.Align.CENTER
color = Color.BLACK
}
//创立四个TextItem
mItemList.apply {
add(TextItem(ITEM_TYPE_SECOND, true))
add(TextItem(ITEM_TYPE_MAIN))
add(TextItem(ITEM_TYPE_SECOND))
add(TextItem(ITEM_TYPE_NEW))
}
}
//设置控件的默许巨细,实践viewgroup不需求
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//依据父容器给定巨细,设置本身宽高,wrap_content需求用到默许值
val width = getSizeFromMeasureSpec(300, widthMeasureSpec)
val height = getSizeFromMeasureSpec(200, heightMeasureSpec)
//Log.e("TAG", "onMeasure: height=$height")
//设置测量宽高,必定要设置,否则错给你看
setMeasuredDimension(width, height)
//如果滑动间隔门限值没有确认,应该依据view巨细设定,默许高度的二分之一
itemChangeYCapacity = if(itemChangeYCapacity == 0f) height / 2f
else itemChangeYCapacity
//有份额核算主次item高度
mainHeight = height * mainItemPercent
secondHeight = height * (1 - mainItemPercent) / 2
//得到本身高度后,就能够按份额设置字体巨细了
mainSize = mainHeight / textScanSize
secondSize = secondHeight / textScanSize
}
//依据MeasureSpec确认默许宽高,MeasureSpec限制了该view可用的巨细
private fun getSizeFromMeasureSpec(defaultSize: Int, measureSpec: Int): Int {
//获取MeasureSpec内形式和尺寸
val mod = getMode(measureSpec)
val size = getSize(measureSpec)
return when (mod) {
EXACTLY -> size
AT_MOST -> min(defaultSize, size)
else -> defaultSize //MeasureSpec.UNSPECIFIED
}
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
//制作显现的text,最多一起显现4个
//Log.e("TAG", "onDraw: mScrollY=$mScrollY")
for (item in mItemList) {
item.draw(mScrollY / itemChangeYCapacity ,mPaint, canvas)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
event?.let {
when(event.action) {
MotionEvent.ACTION_DOWN -> {
//按下开始核算滑动间隔
mScrollY = 0f
mLastY = it.y
}
MotionEvent.ACTION_MOVE -> move(event)
MotionEvent.ACTION_UP -> stopMove()
}
}
//view是最末端了,应该拦截touch事情,否则事情序列将舍弃
return true
}
private fun move(e: MotionEvent) {
val dy = mLastY - e.y
//更新mLastY值
mLastY = e.y
//设置滑动规模,抵达顶部不能下拉,抵达底部不能上拉
if (dy == 0f) return //为什么会有两次0???
if (dy < 0 && mCurrentIndex - 1 == 0) return
if (dy > 0 && mCurrentIndex + 1 == mData!!.size - 1) return
//累加滑动间隔
mScrollY += dy
//如果滑动间隔切换了选中值,重绘前修正选中值
if (mScrollY >= itemChangeYCapacity) changeItem(mCurrentIndex + 1)
else if (mScrollY <= -itemChangeYCapacity) changeItem(mCurrentIndex - 1)
//滑动后触发重绘,制作时处理滑动作用
//Log.e("TAG", "move: mScrollY=$mScrollY")
invalidate()
}
//统一修正mCurrentIndex,各个TextItem需求复位
private fun changeItem(index: Int) {
mCurrentIndex = index
//耗费滑动间隔
mScrollY = 0f
//对各个TextItem复位,重新调用setup即可
for (item in mItemList) {
item.setup()
}
}
private fun stopMove() {
//完毕滑动后断定,滑动间隔超过itemChangeYCapacity一半就切换了选中项
val terminalScrollY: Float = when {
mScrollY > itemChangeYCapacity / 2f -> itemChangeYCapacity
mScrollY < -itemChangeYCapacity / 2f -> -itemChangeYCapacity
//滑动没有抵达切换选中项作用,应该康复到原先状况
else -> 0f
}
//这儿运用ValueAnimator处理剩余的间隔,模仿滑动到需求的方位
val animator = ValueAnimator.ofFloat(mScrollY, terminalScrollY)
//Log.e("TAG", "stopMove: mScrollY=$mScrollY, terminalScrollY=$terminalScrollY")
animator.addUpdateListener { animation ->
//Log.e("TAG", "stopMove: " + animation.animatedValue as Float)
mScrollY = animation.animatedValue as Float
invalidate()
}
//动画完毕时要更新选中的项目
animator.addListener (onEnd = {
if (mScrollY == itemChangeYCapacity) changeItem(mCurrentIndex + 1)
else if (mScrollY == -itemChangeYCapacity) changeItem(mCurrentIndex - 1)
})
//滑动动画总时刻应该和间隔有关
val percent = terminalScrollY / (itemChangeYCapacity / 2f)
animator.duration = (afterUpAnimatorPeriod * abs(percent)).toLong()
//animator.duration = afterUpAnimatorPeriod.toLong()
animator.start()
}
//单位转换
@Suppress("SameParameterValue")
private fun dp2px(context: Context, dpVal: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, dpVal, context.resources
.displayMetrics
).toInt()
}
//依赖于控件宽高及数据,需求在onMeasure之后初始化一下
inner class TextItem(
private val type: Int,
//上下两个次item移动时是对称的,要创立时确认
private val isTopSecond: Boolean = false
){
private var index: Int = 0
private var x: Float = 0f
private var y: Float = 0f
private var textSize: Float = 0f
private var alpha: Int = 0
private var height: Float = 0f
private var dSize = 0f
private var dAlpha = 0
private var dY = 0f
private var isInit = false
fun setup() {
//x为中心即可
x = measuredWidth / 2f
//依据类型设置特点
when(type) {
ITEM_TYPE_MAIN -> {
index = mCurrentIndex
y = measuredHeight / 2f
textSize = mainSize
alpha = mainAlpha
height = mainHeight
}
ITEM_TYPE_SECOND -> {
index = if (isTopSecond) mCurrentIndex - 1
else mCurrentIndex + 1
y = if (isTopSecond) secondHeight / 2f
else measuredHeight - secondHeight / 2f
textSize = secondSize
alpha = secondAlpha
height = secondHeight
}
else -> {
index = mCurrentIndex + 2
//初始化时未确认方位
y = 0F
textSize = 0f
alpha = 0
height = 0f
}
}
}
private fun calculate(delta: Float) {
//依据类型得到改变值
when(type) {
ITEM_TYPE_MAIN -> {
//不管向那儿移动都应该是变小
dSize = (mainSize - secondSize) * -abs(delta)
dAlpha = ((mainAlpha - secondAlpha) * -abs(delta)).toInt()
//主次item中线之间的间隔,delta>0页面上移,y减小
dY = (mainHeight + secondHeight) / 2f * -delta
}
ITEM_TYPE_SECOND -> {
//以上面为准,下面对称即可
if (isTopSecond && delta > 0 || !isTopSecond && delta < 0) {
//项目变为消失,值变小
dSize = secondSize * -abs(delta)
dAlpha = (secondAlpha * -abs(delta)).toInt()
//消失的高度为次item的高度一半
//上面item消失时y减小,下面item消失时y添加
dY = secondHeight / 2f *
if (isTopSecond) -abs(delta) else abs(delta)
}else {
//项目变为选中,值变大
dSize = (mainSize - secondSize) * abs(delta)
dAlpha = ((mainAlpha - secondAlpha) * abs(delta)).toInt()
//上面item变为选中时y添加,下面item变为选中时y减小
dY = (mainHeight + secondHeight) / 2f *
if (isTopSecond) abs(delta) else -abs(delta)
}
}
else -> {
//新项目终态便是次item,不管怎么移动值都是变大的
dSize = secondSize * abs(delta)
dAlpha = (secondAlpha * abs(delta)).toInt()
//从边沿移动到次item方位,即次item高度一半
//delta>0从下面呈现,y应该变小,delta<0从上面呈现,y变大
dY = secondHeight / 2f * -delta
//移动时才确认新item的y和index
y = if (delta > 0) measuredHeight.toFloat() else 0f
index = if (delta > 0) mCurrentIndex + 2 else mCurrentIndex - 2
}
}
}
fun draw(delta: Float, paint: Paint, canvas: Canvas?) {
//确保在onMeasure后初始化
if (!isInit) {
setup()
isInit = true
}
//核算特点改变
calculate(delta)
//修正画笔并制作,留意改变的值都是相对于本来的值,不要去修正本来的值
paint.textSize = textSize + dSize
paint.alpha = alpha + dAlpha
canvas?.drawText(getText(), x, getBaseline(paint, y + dY), paint)
}
private fun getText(): String {
//断定规模
if (index < 0 || index >= mData!!.size) return ""
return mData!![index]
}
private fun getBaseline(paint: Paint, tempY: Float): Float {
//制作字体的参数,受字体巨细样式影响
val fmi = paint.fontMetricsInt
//top为基线到字体上边框的间隔(负数),bottom为基线到字体下边框的间隔(正数)
//基线中心点的y轴核算公式,即中心点加上字体高度的一半,基线中心点x便是中心点x
return tempY - (fmi.top + fmi.bottom) / 2f
}
}
}
首要问题
自界说XML参数
这个网上应该有很多教程了,首要便是要创立一个value里边的xml来界说特点,在XML运用的运用引进命名空间,并运用这些特点,终究在控件代码中读取参数值。下面是这个控件的自界说特点:
res->value->scrolll_select_view_style.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ScrollSelectView">
<attr name="mainAlpha" format="integer"/>
<attr name="secondAlpha" format="integer"/>
<attr name="textScanSize" format="integer"/>
<attr name="changeItemYCapacity" format="dimension"/>
<attr name="afterUpAnimatorPeriod" format="integer"/>
<attr name="mainItemPercent" format="fraction"/>
</declare-styleable>
</resources>
这儿有六个控制特点,mainAlpha和secondAlpha是设定中心和第二层文字的透明度,值为[0,255];textScanSize是文字相对于文字框的缩放份额,比如设置为2的话文字巨细便是文字框的一半;changeItemYCapacity是滑动导致item切换的间隔,单位为dp,即手指滑过这么长的间隔就切换了选中项;afterUpAnimatorPeriod是手指抬起时,康复到默许状况或许跳转到指定状况的最大时刻间隔,动画时刻会依据滑动间隔占changeItemYCapacity的份额核算得到;mainItemPercent是中心item占整个控件的高度份额,巨细为[0,100],第二层的item的高度为剩余高度的一半。
Paint的初始化
Paint的初始化也是一个老生常谈的问题了,不能在onDraw里边创立,由于onDraw会频频被调用,同类对象也不应该在onDraw里边创立。
控件的默许巨细
自界说view需求在onMeasure设定默许巨细,否则运用wrap_content的时分会出问题,前几篇文章和注释都写的很清楚了。
和宽高有关的默许特点设置
一切和控件宽高有关的默许特点都需求在onMeasure中设置,别的TextItem的setup顶用到了measuredWidth和measuredHeight,也需求在onMeasure后初始化,这儿就在第一次制作的时分初始化了。
文字制作到中心方位
经过paint要将文字制作到中心方位,需求结合textAlign(Paint.Align.CENTER)和fontMetricsInt核算得到制作的纵坐标方位。
滑动逻辑处理
由于这个承继的是View,所以在onTouchEvent中耗费事情即可,必定要对ACTION_DOWN事情返回true。
移动时做了三步操作,一是断定是否能够移动,二是累加移动dy并触发重绘,三是判断滑动间隔是否切换了选中值,抵达切换条件时,应该修正选中的index、累加的滑动间隔,并将各个item重置到本来的方位及状况(ps.好像便是改了个index。。。)。这儿有点不太好了解,便是每当滑动抵达切换条件后,就修正选中,重置各个item,逻辑上是一个跳动性修正,可是对于draw来说,仅仅制作的新的图像罢了。
滑动完毕后触发动画翻滚到选定项
当滑动完毕后应该将控件滑动到一个选中项,即让选中项放置到中心,有三种状况,一个是没有切换,一个是上一个index,一个是后一个index。这儿利用了ValueAnimator去模仿滑动,首先核算到终究状况的滑动间隔,然后从当时滑动间隔不断更新,终究抵达终究状况的滑动间隔。只要更新累加的滑动值,触发重绘,在onDraw中会和move一样进行处理。终究在动画完毕后,要和move一样切换选中值及一些其他操作。
滑动导致的文字的制作
文字的制作我封装到了TextItem这个内部类里,仍是比较杂乱,可能有三层item吧,不应该搞太多的。
制作逻辑首要分成两部分,一部分是静态的方位及状况,一部分是滑动导致的改变值核算。静态的方位及状况写在setup里边,仍是比较简单的,便是依据type去确认。
滑动导致的改变值核算就杂乱多了,首要便是依据滑动间隔和滑动切换项意图门限值确认一个百分比delta,再依据delta去核算改换的特点值。实践上想简单点,不便是特点变大或许特点变小的问题么,首先依据delta的正负值确认滑动的方向,再经过滑动方向去确认该类型的item应该特点变大或许特点变小,一个一个去设置就行了,便是代码比较多罢了。
写到这儿我发现前面我说应该用一个数学函数去映射的,我这abs(delta)不便是一个映射函数么。。公然写代码和写文章更能让直接深刻了解内容。这儿abs(delta)换成抛物线确实可能会好看些,有时机改改。
运用
运用起来很简单,直接设定数据和初始index就能够,暂时没有动态切换,能够重写下setter函数,invalidate()就能够了。
xml
<com.silencefly96.module_common.view.ScrollSelectView
android:id="@+id/hhView"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="@color/teal_700"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
code
binding.hhView.mData = ArrayList<String>().apply{
add("第一个")
add("第二个")
add("第三个")
add("第四个")
add("第五个")
}
binding.hhView.mCurrentIndex = 2
规模限制偶然不收效
实践运行起来我发现对规模限制偶然会不收效,暂时还没有处理,应该是动画没有完成就滑动形成的,不太好办,当然这个控件首要意图是学习onDraw,写的这么杂乱了,意图现已抵达了,要学习制作的话后面再写几个控件吧。