RE: Автомобильный Android HMI с нуля (4) - Радиовесы

В последнее время я очень занят, исследование сложных вещей требует много сосредоточенного времени, но у меня его нет, поэтому напишу что-нибудь простое. Существует специальный вид, который практически неизбежен при разработке автомобильных приложений, и который представляет собой масштабную линейку радиоприемника. В этой статье мы изучим, как нарисовать радиошкалу.

Цель этой серии статей — объяснить, как реализовать собственный вид. При чтении обратите внимание на то, как реализовать некоторые общие эффекты, а не ограничивайтесь только тем, как реализовать масштаб, упомянутый в этой статье.

В этой статье рассматриваются следующие точки знаний:

  1. Некоторый здравый смысл при настройке представления, например: как обрабатывать , ;layout_height layout_width
  2. Рисование весов;
  3. Введение в OverScroller и способы реализации инерционного скольжения;
  4. Положение скольжения корректируется для достижения эффекта адсорбции накипи.

Идеи реализации

Основная идея написания масштабного представления в пользовательском интерфейсе радио Android заключается в следующем:

  • Первым шагом является создание собственного класса View, наследование от View и переопределение конструктора и методов onMeasure, onLayout и onDraw по мере необходимости.
  • На втором этапе используйте объекты Canvas и Paint в методе onDraw для рисования различных частей шкалы, включая деления, значения шкалы, индикаторы и т. д.
  • Третий шаг — использовать метод ScrollBy или ScrollTo для достижения эффекта скольжения шкалы и использовать объект Scroller или OverScroller для достижения эффекта инерционного скольжения.
  • Четвертый шаг — определить некоторые интерфейсы или методы обратного вызова в пользовательском классе View для связи и взаимодействия с внешним миром.

Давайте реализуем это один за другим.

Процесс реализации

Определите ширину и высоту представления

Определение ширины и высоты представления означает переопределение метода onMeasure. Помните, что означает режим измерения «Просмотр»? Это не имеет значения, давайте просто вкратце напомним.

  • MeasureSpec.EXACTLY (точный режим)

Когда мы устанавливаем match_parent или определенное значение для layout_heightили в xml , соответствующим режимом измерения будет , что указывает на то, что значение высоты или ширины текущего представления было определено.layout_widthonMeasureEXACTLY

Поэтому EXACTLYмы обычно не изменяем значение, измеренное системой, и напрямую используем его в качестве высоты или ширины текущего представления.

  • MeasureSpec.AT_MOST (максимальный режим)

layout_heightКогда мы устанавливаем или layout_widthустанавливаем значение Wrap_content в xml , onMeasureсоответствующий режим измерения будет AT_MOST, что указывает на то, что система не знает высоту и ширину текущего представления, но существует определенное значение диапазона, если оно не превышает значение задано системой, все будет в порядке.

Поэтому EXACTLYнам нужно вычислить высоту и ширину, необходимые для текущего представления (то есть ширину и высоту представления по умолчанию). Если оно больше или равно значению измерения системы, используйте значение измерения системы; если оно меньше значения измерения системы, используйте наше собственное.

  • MeasureSpec.UNSPECIFIED (неограниченный режим)

Если родительский макет настраиваемого представления относится к типу ScrollView и представление может изменять свой размер в соответствии с размером дочернего представления, соответствующим onMeasureрежимом измерения будет UNSPECIFIED. Метод обработки неопределенен, и, как правило, измеренное значение системы можно использовать напрямую.

В этом примере мы вычисляем только высоту представления, а ширина может быть измерена системой.

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, при измерении высоты необходимо добавить paddingTop и paddingBottom. Затем в onSizeChangedметоде мы получим окончательную ширину и высоту представления и на основе этого рассчитаем высоту длинного указателя и короткого указателя масштабной линейки.

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
}

На этом этапе определенное нами представление может правильно обрабатывать атрибуты layout_heightи layout_widthв xml. Далее мы начинаем рисовать масштабные линейки.

рисовать галочки

Принцип рисования масштабов не сложен, идея заключается в следующем:

  • Используйте максимальное значение — минимальное значение шкалы, чтобы получить общее количество тиков.
  • Зациклите общее количество тиков и используйте 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
    )
}

После выполнения вышеуказанных шагов мы можем увидеть следующий эффект.

На этом этапе мы нарисовали шкалу и нониус шкалы и уже можем видеть базовый прототип шкалы. Далее мы продолжаем реализовывать скользящую шкалу.

сенсорный слайд

Один из традиционных способов реализации сенсорного скольжения — переписать OnTouchEventметод, позволяющий определять, находится ли он в состоянии скольжения, на основе координат сенсорного движения. В этом примере мы используем класс распознавания жестов — GestureDetector.

GestureDetectorЭто класс, используемый для обнаружения жестовых операций пользователя на экране. Он может распознавать некоторые основные жесты, такие как нажатие, подъем, скольжение, длительное нажатие, постукивание, быстрое скольжение и т. д.

Он используется путем создания GestureDetectorэкземпляра и передачи интерфейса GestureDetector.OnGestureListener, который определяет некоторые методы для обработки различных событий жестов. В представлении или действии, которому необходимо обнаруживать жесты, передайте событие касания GestureDetectorметоду onTouchEvent, чтобы 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() для перемещения содержимого текущего представления на соответствующее расстояние скольжения. onScrollЗначение обратного вызова distanceX — это расстояние, на которое палец скользит по оси X (по горизонтали).

Здесь обратите внимание scrollByна scrollToразличия и характеристики:

  • scrollByОн сдвигается на определенное расстояние относительно текущей позиции, scrollToно перемещается непосредственно в указанную абсолютную позицию.
  • scrollByФактически метод называется scrollTo, его параметром является приращение скольжения, а scrollToпараметром — целевая позиция скольжения.
  • scrollByи scrollToоба перемещают содержимое представления, а не само представление. Для ViewGroup его содержимым являются все его подпредставления; для TextView его содержимым является его текст.

На этом этапе мы реализуем сенсорное скольжение. Однако, когда скольжение прекращается, весы немедленно останавливаются, а взаимодействие с пользователем становится относительно плохим, поэтому необходимо дополнительно реализовать инерционное скольжение.

инерционное скольжение

Инерционное скольжение — это жестовая операция, при которой страница не останавливается сразу после того, как пользователь перемещает ее по экрану, а продолжает сохранять эффект прокрутки в течение определенного периода времени. Это может улучшить интерактивное взаимодействие пользователя и сделать прокрутку страницы более плавной и естественной.

AndroidOverScroller — вспомогательный класс, используемый для реализации плавной прокрутки 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 начальной позиции скольжения.
  • скоростьX, скоростьY: представляет компоненты x и y начальной скорости скольжения в пикселях/секунду.
  • minX, maxX, minY, maxY: указывает граничный диапазон скольжения. Если скольжение превышает этот диапазон, срабатывает эффект OverScroll.
  • overX, overY: указывает максимальное расстояние OverScroll, то есть расстояние, на котором можно продолжать скользить после выхода за границу.

computeScroll()Это метод, используемый для управления эффектом скольжения View. Он будет вызываться в методе draw() View и использоваться для расчета положения и статуса View в каждый момент.

    override fun computeScroll() {
        super.computeScroll()
        // 如果滚动器正在滚动,更新滚动的位置,并根据需要修正位置
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            invalidate()
        }
    }

OverScrollerМожно добиться не только инерционного скольжения, но и следующих эффектов прокрутки:

  • startScroll: Прокрутка от указанной позиции на указанное расстояние, а затем остановка.Эффект прокрутки связан с установленным расстоянием прокрутки, временем прокрутки и интерполятором и не имеет ничего общего со скоростью переключения. Обычно используется для управления представлением для прокрутки до указанной позиции.
  • springBack: отскок от указанной позиции к указанной позиции. Обычно используется для достижения эффекта отскока после перетаскивания. Время отскока и интерполятор указать невозможно.

Хорошо, инерционное скольжение реализовано, но текущая шкала все еще имеет много недостатков. Например, она будет пересекать границу при скольжении, и может остановиться между двумя шкалами после остановки скольжения. Далее нам нужно дополнительно исправить эти недостатки.

Ограничить диапазон скольжения

Во-первых, нам нужно ограничить область скольжения, чтобы гарантировать, что оно не сможет пересечь границу при скольжении.

Перепишите scrollToметод View.Здесь мы можем каждый раз отслеживать скользящие координаты 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мы рассчитали текущее фактическое значение шкалы, сохраните его здесь для дальнейшего использования.

коррекция положения

Определите 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!!)
}

Выполнив описанные выше шаги, мы завершили радиошкалу и, наконец, сделали последние штрихи.

окончание

Масштабу необходимо поддерживать внешние входящие данные и перемещать соответствующую позицию, поэтому выставьте метод 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())
}

Подведем итог

В этой статье рассказывается, как написать масштабный вид. Однако примеры в этой статье не следует использовать непосредственно в вашем проекте. По моему личному опыту, радиомасштаб сильно меняется. В Интернете очень мало видов, которые можно использовать непосредственно без изменений в проекте, поэтому нам следует уделять больше внимания принципам реализации, чтобы мы могли изменять и определять их при необходимости.

Адрес исходного кода: https://github.com/linxu-link/FuckView

Ладно, вот и все содержание этой статьи, спасибо за прочтение, надеюсь, она будет вам полезна.

рекомендация

отblog.csdn.net/linkwj/article/details/132467337