最近比较忙,研讨杂乱的东西需求大量集中的时刻,可是又抽不出来,就写点简单的东西吧。车载应用开发中有一个几乎避不开的自界说View,便是收音机的刻度条。本篇文章咱们来研讨怎么制作一个收音机的刻度尺。

本系列文章的目的是在解说自界说View是怎么完结的,阅览时,注意一些通用作用的完结方式,而不要仅仅局限于怎么完结本文中提到的刻度尺。

本文触及的的知识点如下:

  1. 自界说View时的一些常识,例如:怎么处理 layout_height layout_width

  2. 刻度的制作;

  3. OverScroller介绍,以及怎么完结惯性滑动;

  4. 滑动方位批改,完结刻度吸附作用。

完结思路

写一个 Android 收音机 UI 上的刻度尺 View 的根本思路如下:

  • 第一步,创立一个自界说 View 类,继承自 View 并依据需求重写构造办法和onMeasure、onLayout、onDraw 办法。
  • 第二步,在 onDraw 办法中运用 Canvas 和 Paint 方针来制作刻度尺的各个部分,包括刻度线,刻度值,指示器等。
  • 第三步,运用 scrollBy 或许 scrollTo 办法来完结刻度尺的滑动作用,并运用 Scroller 或许 OverScroller 方针来完结惯性滑动作用。
  • 第四步,在自界说 View 类中界说一些接口或许回调办法,用于与外部进行通讯和交互。

下面咱们来一一完结。

完结过程

界说View的宽、高

界说View的宽、高便是重写onMeasure办法。还记得View丈量形式的意义吗?没关系,咱们简单回想一下即可。

  • MeasureSpec.EXACTLY(精确形式)

当咱们在xml中将为layout_heightlayout_width设定为match_parent或许详细的值时,在onMeasure时对应的丈量形式就会是EXACTLY,表明当时View的高度或宽度值是现已确定好的。

所以,在EXACTLY时咱们一般不会批改体系丈量出的值,直接将其用作当时View的高度或宽度。

  • MeasureSpec.AT_MOST(至多形式)

当咱们在xml中将为layout_heightlayout_width设定为wrap_content时,在onMeasure时对应的丈量形式就会是AT_MOST,表明体系并不知道当时View的高度和宽度,可是有一个确定规模值,只要不超过体系给出值,都能够。

所以,在EXACTLY时咱们需求核算出当时View需求的高度和宽度(也便是View默认宽高)。大于、等于体系的丈量值时,运用体系的丈量值;小于体系的丈量值时,运用咱们自己的。

  • MeasureSpec.UNSPECIFIED(不约束形式)

当自界说View的父布局时ScrollView一类,能够跟从子View巨细改动自身巨细的View时,在onMeasure时对应的丈量形式就会是UNSPECIFIED。处理方式不定,一般也能够直接采用体系的丈量值。

在本例中,咱们只核算View的高度,宽度运用体系丈量好的即可。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    setMeasuredDimension(
        MeasureSpec.getSize(widthMeasureSpec),
        measureHeight(heightMeasureSpec)
    )
}
private fun measureHeight(heightMeasureSpec: Int): Int {
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)
    var height = 0
    when (heightMode) {
        // MeasureSpec.EXACTLY 为match_parent或许详细的值
        MeasureSpec.EXACTLY -> {
            height = heightSize
        }
        // MeasureSpec.AT_MOST 为wrap_content
        MeasureSpec.AT_MOST -> {
            // 高度 = 刻度尺长刻度的高度 + 上边距 + 下边距
            height = longScaleHeight.coerceAtLeast(pointHeight) + paddingTop + paddingBottom
            // 假如高度大于父容器给的高度,则取父容器给的高度
            height = height.coerceAtMost(heightSize)
        }
        // MeasureSpec.UNSPECIFIED 父容器关于子容器没有任何约束,子容器想要多大就多大,多呈现于ScrollView
        MeasureSpec.UNSPECIFIED -> {
            height = heightSize
        }
    }
    return height
}

为了支撑xml中的padding特色,在计量高度时还需求加上paddingTop、paddingBottom。然后,在onSizeChanged办法中咱们会得到View的最终宽、高,并据此核算出刻度条长指针、短指针的高度。

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    viewWidth = w
    viewHeight = h
    initParams()
}
private fun initParams() {
    // 长刻度的高度 = 控件高度 - 上边距 - 下边距
    longScaleHeight = height - paddingTop - paddingBottom
    // 短刻度的高度 = 长刻度的高度 - 15dp
    shortScaleHeight = longScaleHeight - 15.dp
}

到此,咱们界说的View现已能够正确处理xml中的layout_heightlayout_width特色了。接下来咱们开端制作刻度条。

制作刻度

制作刻度原理并不杂乱,思路如下:

  • 运用刻度尺的最大值 – 最小值,得到总的刻度数
  • 循环总刻度数,运用drawLine制作出线条
  • 制作出收音机刻度尺中心的游标
override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    for (i in 0..scaleCount) {
        // 点从下往上制作
        // 刻度开端的x坐标  = 刻度距离 * i + 左边距
        val x1 = scaleSpace * i
        // 刻度开端的y坐标 = 上边距
        val y1 = height - paddingBottom
        // 刻度结尾的x坐标 = 刻度开端的x坐标
        val x2 = x1
        // 刻度结尾的y坐标 = 控件高度 - 下边距 - 刻度高度
        val y2 = height - paddingBottom - (if (i % 10 == 0) longScaleHeight else shortScaleHeight)
        // 制作表尺刻度
        canvas?.drawLine(
            x1.toFloat(), y1.toFloat(),
            x2.toFloat(), y2.toFloat(),
            linePaint
        )
        drawCenterLine(canvas)
    }
}
private fun drawCenterLine(canvas: Canvas?) {
    // 表尺中心点的x坐标 +翻滚的距离是为了让中心点一直在屏幕中心
    val centerPointX = viewWidth / 2 + scrollX
    // 中心刻度的开端y坐标 = 上边距 - 5dp(加长5dp)
    val centerStartPointY = paddingTop - 5.dp
    // 中心刻度的结尾y坐标 = 控件高度 - 下边距 + 5dp(加长5dp)
    val centerEndPointY = viewHeight - paddingBottom + 5.dp
    canvas?.drawLine(
        centerPointX.toFloat(), centerStartPointY.toFloat(),
        centerPointX.toFloat(), centerEndPointY.toFloat(),
        pointPaint
    )
}

完结上述步骤咱们就能够看到下面的作用。

RE:从零开始的车载Android HMI(四) - 收音机刻度尺

这一步,咱们制作出了刻度尺的刻度和游标,现已能够看到刻度尺根本的雏形了。接下来,咱们持续完结刻度尺的滑动。

接触滑动

惯例完结接触滑动的方式之一是重写OnTouchEvent办法,依据接触移动时的坐标判别是否处于滑动状况。本例中,咱们运用手势辨认类 – GestureDetector

GestureDetector是一个用于检测用户在屏幕上的手势操作的类,它能够辨认一些根本的手势,如按下、抬起、滑动、长按、轻击、快速滑动等。

它的运用办法是创立一个GestureDetector实例,并传入一个GestureDetector.OnGestureListener接口,该接口界说了一些办法,用于处理不同的手势事情。在需求检测手势的View或Activity中,将接触事情传递给GestureDetectoronTouchEvent办法,从而让GestureDetector响应接触事情并调用相应的监听器办法。

    private val gestureDetector by lazy { GestureDetector(context, touchGestureListener) }
    private val touchGestureListener = object : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            return true
        }
        override fun onScroll(
            e1: MotionEvent,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float
        ): Boolean {
            // 当监听到滑动事情时,翻滚到指定方位
            scrollBy(distanceX.toInt(), 0)
            return true
        }
    }
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        // 
        return gestureDetector.onTouchEvent(event!!)
    }

上面的代码中,当GestureDetector监听到接触手势为滑动onScroll时,调用View.scrollBy()办法,将当时View的内容移动对应的滑动距离。onScroll 的回调值 distanceX 便是手指在x轴(水平)上滑动的距离。

这儿注意scrollByscrollTo的差异和特色:

  • scrollBy是在当时的方位基础上,相对滑动必定的距离,而scrollTo是直接滑动到指定的绝对方位。
  • scrollBy实践上是调用了scrollTo办法,它的参数是滑动的增量,而scrollTo的参数是滑动的方针方位。
  • scrollByscrollTo都是移动View的内容,而不是View自身。关于一个ViewGroup,它的内容是它的一切子View;关于一个TextView,它的内容是它的文本。

这一步中,咱们完结了接触滑动。可是当中止滑动时,刻度尺会立即停下,用户体会比较差,所以还需进一步完结惯性滑动。

惯性滑动

惯性滑动是一种在用户在屏幕上滑动页面后,页面不会马上停下,而是持续保持必定时刻的翻滚作用的手势操作。它能够进步用户的交互体会,让页面的翻滚更加滑润和自然。

Android中的OverScroller是一个用于完结View滑润翻滚的辅佐类,它能够依据用户的手势操作或许指定的参数来核算出每一时刻View的方位和速度,并提供了一些办法来操控翻滚的开端、完毕、中止等状况。

OverScroller.fling()办法能够完结,从指定方位滑动一段方位然后停下。滑动作用只与离手速度以及滑动鸿沟有关,不能设置惯性滑动距离、时刻和插值器。

private val touchGestureListener = object : GestureDetector.SimpleOnGestureListener() {
    // ...
override fun onFling(
    e1: MotionEvent,
    e2: MotionEvent,
    velocityX: Float,
    velocityY: Float
    ): Boolean {
    // 发动翻滚器,设置翻滚的开端方位,速度,规模和回弹距离
    scroller.fling(
        scrollX, 0,
        -velocityX.toInt() / 2, 0,
        -viewWidth / 2, (scaleCount - 1) * scaleSpace - viewWidth / 2,
        0, 0,
        viewWidth / 4, 0
    )
    invalidate()
    return true
    }
}

fling办法参数的意义如下:

  • startX, startY:表明滑动的开端方位的x和y坐标。
  • velocityX, velocityY:表明滑动的初始速度的x和y分量,单位是像素/秒。
  • minX, maxX, minY, maxY:表明滑动的鸿沟规模,假如滑动超过这个规模,就会触发OverScroll作用。
  • overX, overY:表明OverScroll的最大距离,即滑动超过鸿沟后,还能持续滑动的距离

computeScroll()是一个用于操控View的滑动作用的办法,它会在View的draw()办法中被调用,用于核算View在每一时刻的方位和状况。

    override fun computeScroll() {
        super.computeScroll()
        // 假如翻滚器正在翻滚,更新翻滚的方位,并依据需求批改方位
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            invalidate()
        }
    }

RE:从零开始的车载Android HMI(四) - 收音机刻度尺

OverScroller不只能够完结惯性滑动,还能够完结以下几种翻滚作用:

  • startScroll:从指定方位翻滚一段指定的距离然后停下,翻滚作用与设置的翻滚距离、翻滚时刻、插值器有关,跟离手速度没有关系。一般用于操控View翻滚到指定的方位。

  • springBack:从指定方位回弹到指定方位,一般用于完结拖拽后的回弹作用,不能指定回弹时刻和插值器。

好了,惯性滑动完结了,可是当时的刻度尺还有很多瑕疵,例如:滑动时会越界,滑动中止后可能停在两个刻度之间,接下来咱们还需求进一步批改这些瑕疵。

约束滑动规模

首要,咱们需求约束滑动的规模,确保滑动时不能越界。

重写View的scrollTo办法,在这儿咱们能够监听View每次的滑动坐标,当滑动坐标越界时,及时批改滑动坐标,就能够避免越界。

// 翻滚办法
override fun scrollTo(x: Int, y: Int) {
    Log.e("TAG", "scrollTo: ")
    // 约束翻滚的规模,避免越界
    var x = x
    // 当x坐标小于可视区域的一半时,设置x坐标为可视区域的一半
    if (x < -viewWidth / 2) {
        x = -viewWidth / 2
    }
    // 当x坐标大于最大翻滚距离时,设置x坐标为最大翻滚距离
    if (x > (scaleCount) * scaleSpace - viewWidth / 2) {
        x = (scaleCount) * scaleSpace - viewWidth / 2
    }
    // 调用父类的翻滚办法
    super.scrollTo(x, y)
    // 保存当时选中的值 和x坐标
    currentValue = (x + viewWidth / 2) / scaleSpace + scaleMinValue
    currentX = x
    // 触发重绘,更新视图
    invalidate()
}

scrollTo中咱们核算出了当时的实践刻度值,这儿保存好备用。

RE:从零开始的车载Android HMI(四) - 收音机刻度尺

方位批改

computeScroll()中判别翻滚事情是否现已完毕,假如完毕,就开端批改滑动坐标。

override fun computeScroll() {
    // 假如翻滚器正在翻滚,更新翻滚的方位,并依据需求批改方位
    if (scroller.computeScrollOffset()) {
        scrollTo(scroller.currX, scroller.currY)
        if (scroller.isFinished) {
            correctPosition()
        }
        invalidate()
    }
}

批改坐标根本思路是,首要核算出当时刻度数值对应的x坐标,因为刻度值在核算时现已取整了,所以必定是不等于当时的实践x坐标,两者的差值便是偏移量。当偏移量大于刻度距离的一半时,向前滑动,否则向后滑动。


// 批改方位,核算当时选中值距离最近的刻度的偏移量,并依据偏移量进行滑润翻滚到正确的方位
private fun correctPosition() {
    // 刻度值对应的x坐标
    val scaleX: Int = (currentValue - scaleMinValue) * scaleSpace - viewWidth / 2
    // 偏移值 = 刻度值对应的x坐标-当时x坐标 的绝对值
    val offset = (scaleX - currentX).absoluteValue
    if (offset == 0) {
        return
    }
    // 大于距离
    if (offset > scaleSpace / 2) {
        smoothScrollBy(scaleSpace - offset)
    } else {
        smoothScrollBy(-offset)
    }
}
// 滑润翻滚办法
private fun smoothScrollBy(dx: Int) {
    // 发动翻滚器,设置翻滚的开端方位,距离,时刻和插值器
    scroller.startScroll(scrollX, 0, dx, 0, 200)
    invalidate()
}

因为computeScroll()只有在滑动时才会有回调,所以还需求在手指抬起时批改一次方位,避免遗失拖动事情。

override fun onTouchEvent(event: MotionEvent?): Boolean {
    // 当手指抬起时,校准方位
    if (event?.action == MotionEvent.ACTION_UP || event?.action == MotionEvent.ACTION_CANCEL) {
        correctPosition()
        // 将刻度值通过回调传出去.
    }
    return gestureDetector.onTouchEvent(event!!)
}

RE:从零开始的车载Android HMI(四) - 收音机刻度尺

通过,以上这几步咱们就完结了一个收音机刻度,最后咱们进行收尾。

收尾

刻度尺需求支撑外部传入数据,并移动相应的方位,所以暴露一个setCurrentValue办法,然后重写onLayout办法,在其中核算出刻度值的滑动坐标,运用scrollTo滑动到对应的x坐标即可。

// 注意,因为会主动调用requestLayout(),所以不能复写kotlin的set办法。
fun setCurrentValue(value: Int) {
    // 约束值的规模,避免越界
    var value = value
    if (value < scaleMinValue) {
        value = scaleMinValue
    }
    if (value > scaleMaxValue) {
        value = scaleMaxValue
    }
    currentValue = value
    // 更新当时选中值,并重新布局
    requestLayout()
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    super.onLayout(changed, left, top, right, bottom)
    // 依据当时选中值核算翻滚的距离 = (当时选中值 - 最小值) * 刻度距离 - 控件宽度的一半
    val scrollX: Int = (currentValue - scaleMinValue) * scaleSpace - viewWidth / 2
    scrollTo(scrollX, 0)
}

运用一段测试代码,就能够完结收音机搜台作用了。

val handler = Handler(Looper.getMainLooper())
for (i in 0..155) {
    handler.postDelayed({
        findViewById<ScaleView>(R.id.scaleView).setCurrentValue(i)
    }, 150 * i.toLong())
}

RE:从零开始的车载Android HMI(四) - 收音机刻度尺

总结

本文介绍了怎么编写一个刻度尺View,不过本文的比如不要直接运用在你的项目中,以我个人经验而言,收音机刻度尺改变较多,互联网上很少有View能不做批改直接运用在项目中的,所以更应该重视完结的原理,方便咱们在需求时进行批改和界说。

源码地址:github.com/linxu-link/…

好,以上便是本文的一切内容,感谢你的阅览,希望对你有所协助。