Часто ли вы пользуетесь Telegram?
Если да, то скорее всего вы хотя бы раз отправляли "кружочки". В этой серии статьей мы напишем небольшой проект с отображением списка видео-сообщений.
Для отображения будем использовать ExoPlayer, настроим сохранение видео в кеш, а также напишем свой TimeBar для управления видео.

Оглавление

Введение

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

Также можете взять git-ветку предыдущей части и сразу понять контекст.

Git-ветка этой части.

Контролы и раскрытое состояние

По аналогии с Telegram'ом, хочется при тапе по сообщению увеличивать его и показывать кнопки плей/паузы и перемотки.

Раскрытие/сжатие видео

Добавим размеры для "кружочка" в ресурсы.

bubble.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--  размер сообщения в сжатом состоянии  -->
    <dimen name="bubble_initial_size">230dp</dimen>
    <!--  размер сообщения в развернутом состоянии  -->
    <dimen name="bubble_expanded_size">260dp</dimen>
    <!--  отступ линии прогресса просмотра от краев view  -->
    <dimen name="bubble_timebar_buffer_path_padding">15dp</dimen>
    <!--  ширина линии прогресса просмотра  -->
    <dimen name="bubble_timebar_buffer_path_stroke">3dp</dimen>
    <!--  радиус badge перемотки  -->
    <dimen name="bubble_timebar_badge_radius">6dp</dimen>
</resources>

Добавим цвета.

colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="progress_color">#FFFFFFFF</color>
    <color name="buffer_color">#4debebf5</color>
</resources>

Добавим методы раскрытия и сжатия сообщения.

BubbleViewHolder.kt

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {
    ...
    private var changeSizeAnimator: ValueAnimator? = null
    var isActive = false
    ...

    // сжимает видео когда оно пропадает с экрана
    fun makeInactiveImmediately() {
        isActive = false
        val initialSize = itemView.context.resources.getDimensionPixelSize(R.dimen.bubble_initial_size)
        playerView.updateLayoutParams {
            height = initialSize
            width = initialSize
        }
    }
    
    // АНИМИРОВАННО сжимает видео при раскрытии другого видео
    fun makeInactiveAnimated() {
        isActive = false
        val initialSize = itemView.context.resources.getDimensionPixelSize(R.dimen.bubble_initial_size)
        // отменяем предыдущую анимацию если она еще не закончилась
        changeSizeAnimator?.cancel()
        changeSizeAnimator = ValueAnimator.ofInt(playerView.height, initialSize).apply {
            duration = 500L
            interpolator = PathInterpolatorCompat.create(0.33F, 0F, 0F, 1F)
            addUpdateListener {
                playerView.updateLayoutParams {
                    height = it.animatedValue as Int
                    width = it.animatedValue as Int
                }
            }
        }
        changeSizeAnimator?.start()
    }

    // АНИМИРОВАННО раскрывает видео при тапе по нему
    fun makeActive() {
        isActive = true
        val expandedSize = itemView.context.resources.getDimensionPixelSize(R.dimen.bubble_expanded_size)
        // отменяем предыдущую анимацию если она еще не закончилась
        changeSizeAnimator?.cancel()  
        changeSizeAnimator = ValueAnimator.ofInt(playerView.height, expandedSize).apply {
            duration = 500L
            interpolator = PathInterpolatorCompat.create(0.33F, 0F, 0F, 1F)
            addUpdateListener {
                playerView.updateLayoutParams {
                    height = it.animatedValue as Int
                    width = it.animatedValue as Int
                }
            }
        }
        changeSizeAnimator?.start()
    }
    ...
}

Добавим вызов методов viewHolder'а в BubbleAdapter

BubbleAdapter.kt

package com.example.videobubble

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class BubbleAdapter(
    private val items: List<BubbleModel>
) : RecyclerView.Adapter<BubbleViewHolder>() {

    private var recyclerView: RecyclerView? = null
    ...
    
    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        ...
        this.recyclerView = recyclerView
    }

    override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
        ...
        this.recyclerView = null
    }

    override fun onBindViewHolder(holder: BubbleViewHolder, position: Int) {
        ...
        holder.itemView.setOnClickListener {
            // если раскрыто другое сообщение, то сжимаем его
            findActiveViewHolder()?.makeInactiveAnimated()
            holder.makeActive()
        }
    }

    // поиск уже раскрытого сообщения
    private fun findActiveViewHolder(): BubbleViewHolder? {
        recyclerView?.let { rv ->
            val layoutManager = rv.layoutManager as LinearLayoutManager
            val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
            val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()

            for (i in firstVisiblePosition ..  lastVisiblePosition) {
                val viewHolder = rv.findViewHolderForAdapterPosition(i) as? BubbleViewHolder
                if (viewHolder?.isActive == true) {
                    return viewHolder
                }
            }
        }
        return null
    }

    // сжимаем сообщение, когда оно исчезает с экрана
    override fun onViewDetachedFromWindow(holder: BubbleViewHolder) {
        super.onViewDetachedFromWindow(holder)
        holder.makeInactiveImmediately()
    }
}

Готово! Запускаем и проверяем результат.

Контролы

В PlayerView уже реализованы дефолтные кнопки управления. Сейчас они не отображаются потому что мы их скрыли (параметр app:use_controller="false" в PlayerView).

Если зайти в папку layout внутри исходников ExoPlayer'а, то можно найти файл exo_player_control_view.xml. Внутри мы увидим те самые реализованные кнопки.

Исходный layout контролов
Исходный layout контролов

Давайте подменим эти кнопки на свои. Для этого переопределяем параметр controller_layout_id у PlayerView.

li_bubble.xml

    ...
    <com.google.android.exoplayer2.ui.PlayerView
        ...
        // Созданный нами layout контролов
        app:controller_layout_id="@layout/bubble_view_controller"
        // Обновление timebar'а каждые 30мс
        app:time_bar_min_update_interval="30"
        // Отключаем таймер на скрытие timebar'а
        app:show_timeout="0"
        // Отключаем скрывание timebar'а по нажатию
        app:hide_on_touch="false"
    ...

Создадим bubble_view_controller, сохранив исходные id у view, чтобы PlayerView корректно использовал их для управления видео.

bubble_view_controller.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.videobubble.BubbleTimeBar
        android:id="@id/exo_progress"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <FrameLayout
        android:id="@id/exo_play"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_margin="60dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <ImageButton style="@style/ExoMediaButton.Play"
            android:id="@+id/exo_play_icon"
            android:layout_height="36dp"
            android:layout_width="36dp"
            android:scaleType="fitCenter"
            android:clickable="false"
            android:layout_gravity="center"/>

    </FrameLayout>

    <View android:id="@+id/exo_pause"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Создадим BubbleTimeBar, который будет отображать прогресс просмотра, а также badge перемотки.

BubbleTimeBar.kt

class BubbleTimeBar
@JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr), TimeBar {

    // отступ линии буффера от края view
    private val padding = resources.getDimensionPixelSize(R.dimen.bubble_timebar_buffer_path_padding).toFloat()
    // ширина линии буффера
    private val bufferPathStroke = resources.getDimensionPixelSize(R.dimen.bubble_timebar_buffer_path_stroke).toFloat()
    // сущность для отрисовки линии буффера
    private val playerBufferDrawer = PlayerBufferDrawer(
        padding = padding,
        bufferPathStroke = bufferPathStroke,
        context = context
    )

    init {
        // тк BubbleTimeBar-это viewGroup, то для вызова onDraw необходимо прописать это
        setWillNotDraw(false)
    }

    // уведомляем PlayerBufferDrawer об изменениях размера view
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        playerBufferDrawer.onViewSizeChanged(w, h)
    }

    // просим PlayerBufferDrawer отрисовать линию буффера
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        playerBufferDrawer.drawBuffer(canvas)
    }

    override fun addListener(listener: TimeBar.OnScrubListener) = Unit
    override fun removeListener(listener: TimeBar.OnScrubListener) = Unit
    override fun setPosition(position: Long) = Unit
    override fun setDuration(duration: Long) = Unit
    override fun setKeyTimeIncrement(time: Long) = Unit
    override fun setKeyCountIncrement(count: Int) = Unit
    override fun setAdGroupTimesMs(adGroupTimesMs: LongArray?, playedAdGroups: BooleanArray?, adGroupCount: Int) = Unit
    override fun setBufferedPosition(bufferedPosition: Long) = Unit
    override fun getPreferredUpdateDelay() = 0L
}

PlayerBufferDrawer.kt

class PlayerBufferDrawer(
    private val padding: Float,
    private val bufferPathStroke: Float,
    context: Context
) {

    // path по которому будет отрисовываться круг таймлайна
    private val bufferPath = Path()
    private val paint = Paint().apply {
        // цвет круга
        color = ContextCompat.getColor(context, R.color.buffer_color)
        // отрисовываем только контур от bufferPath (иначе отрисовывается закрашенный круг)
        style = Paint.Style.STROKE
        // ширина круга
        strokeWidth = bufferPathStroke
    }

    // обновляем Path при изменении размеров view
    fun onViewSizeChanged(width: Int, height: Int) {
        bufferPath.reset()
        val centerX = width.toFloat() / 2
        val centerY = height.toFloat() / 2
        val radius = centerX - padding
        bufferPath.addCircle(centerX, centerY, radius, Path.Direction.CW)
    }

    // отрисовываем Path
    fun drawBuffer(canvas: Canvas) {
        canvas.drawPath(bufferPath, paint)
    }
}

Добавим анимированное появление BubbleTimeBar.

FadeAnimation.kt

fun View.fadeIn() = createFadeAnimator(0F, 1F)

fun View.fadeOut() = createFadeAnimator(1F, 0F)

private fun View.createFadeAnimator(from: Float, to: Float): ObjectAnimator {
    return ObjectAnimator.ofFloat(this, View.ALPHA, from, to).apply {
        this.duration = 500L
    }
}

BubbleViewHolder.kt

import com.google.android.exoplayer2.ui.R as exoPlayerR

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {
    
    private val playerViewTimeBar = itemView.findViewById<BubbleTimeBar>(exoPlayerR.id.exo_progress)
    ...

    // сжимает видео когда оно пропадает с экрана
    fun makeInactiveImmediately() {
        ...
        changeControllerVisibility(false)
    }

    // АНИМИРОВАННО сжимает видео при раскрытии другого видео
    fun makeInactiveAnimated() {
        changeControllerVisibility(false)
        ...
        playerViewTimeBar.fadeOut().start()
    }

    // АНИМИРОВАННО раскрывает видео при тапе по нему
    fun makeActive() {
        ...
        playerViewTimeBar.fadeIn().apply {
            doOnStart { changeControllerVisibility(true) }
        }.start()
    }
    
    private fun changeControllerVisibility(isVisible: Boolean) {
        when (isVisible) {
            true -> {
                playerView.useController = true
                playerView.showController()
            }
            false -> {
                playerView.useController = false
                playerView.hideController()
            }
        }
    }
}

Отрисовка кнопок и буффера готова! Запускаем и проверяем результат.

Результат
Результат

Теперь добавим отображение прогресса просмотра.

Добавление линии прогресса просмотра

Длина линии будет пропорциональна просмотренной части видео.
Например, видео идет 10 секунд. На 5 секунде (1/2 длины видео) длина линии прогресса должна быть 1/2 от длины линии буффера. Думаю, тут все просто.

Записываем длительность видео и текущую позицию просмотра.

BubbleTimeBar.kt

class BubbleTimeBar
@JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr), TimeBar {
    ...
    private var currentPlayPosition: Float = 0F
    var videoDuration: Long = 0
    ...

    override fun setPosition(position: Long) {
       currentPlayPosition = position.toFloat()
       // перерисовываем timeBar при обновлении позиции
       invalidate()
    }

    override fun setDuration(duration: Long) {
       videoDuration = duration
    }
    ...
}

Чтобы соотнести текущую позицию просмотра с длиной линии прогресса будем использовать PathMeasure, который будет замерять длину круга буффера.

PlayerBufferDrawer.kt

class PlayerBufferDrawer(
    ...
) {
    ...
    private var pathMeasure = PathMeasure(bufferPath, false)

    fun onViewSizeChanged(width: Int, height: Int) {
        bufferPath.reset()
        val centerX = width.toFloat() / 2
        val centerY = height.toFloat() / 2
        val radius = centerX - padding
        bufferPath.addCircle(centerX, centerY, radius, Path.Direction.CW)
        // Замеряем длину круга буффера
        pathMeasure = PathMeasure(bufferPath, false)
    }

    fun getBufferPathMeasure(): PathMeasure {
        return pathMeasure
    }

    ...
}

Отлично! Теперь у нас есть все для соотношения прогресса с длиной линии.
Добавим сущность, которая будет отрисовывать линию прогресса.

PlayerProgressDrawer.kt

class PlayerProgressDrawer(
    private val bufferPathStroke: Float,
    context: Context
) {

    // Path для отрисовки линии прогресса
    private val playProgressPath = Path()
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = ContextCompat.getColor(context, R.color.progress_color)
        strokeWidth = bufferPathStroke
        style = Paint.Style.STROKE
        strokeCap = Paint.Cap.ROUND
    }

    fun drawProgress(canvas: Canvas, progress: Float, bufferPathMeasure: PathMeasure) {
        playProgressPath.reset()
        // получаем длину линии относительно прогресса просмотра
        val segmentLength = bufferPathMeasure.length * progress
        // Получаем часть круга буффера, равную длине линии прогресса
        bufferPathMeasure.getSegment(0F, segmentLength, playProgressPath, true)
        // Отрисовываем линию прогресса
        canvas.drawPath(playProgressPath, paint)
    }
}

Добавляем PlayerProgressDrawer в BubbleTimeBar.

BubbleTimeBar.kt

class BubbleTimeBar
@JvmOverloads constructor(
    ...
) : FrameLayout(context, attrs, defStyleAttr), TimeBar {
    
    ...
    private val playerProgressDrawer = PlayerProgressDrawer(
      bufferPathStroke = bufferPathStroke,
       context = context
    )
    ...
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        playerBufferDrawer.drawBuffer(canvas)
        val bufferPathMeasure = playerBufferDrawer.getBufferPathMeasure()
        val playProgress = runCatching { currentPlayPosition / videoDuration }.getOrDefault(0F)
        playerProgressDrawer.drawProgress(
            canvas = canvas,
            progress = playProgress,
            bufferPathMeasure = bufferPathMeasure
        )
    }
    ...
}

Запускаем и видим, что появилась отрисовка прогресса. Но начинается она не сверху, как нам хотелось.

Давайте повернем на 90 градусов path, который отрисовывает прогресс. Для этого создаем матрицу, поворачиваем ее на -90 градусов и применяем к path.

PlayerBufferDrawer.kt

class PlayerBufferDrawer(
    ...
) {
    ...
    fun onViewSizeChanged(width: Int, height: Int) {
        bufferPath.reset()
        val centerX = width.toFloat() / 2
        val centerY = height.toFloat() / 2
        val radius = centerX - padding
        bufferPath.addCircle(centerX, centerY, radius, Path.Direction.CW)
        val matrix = Matrix()
        matrix.postRotate(-90F, centerY, centerY)
        bufferPath.transform(matrix)
        pathMeasure = PathMeasure(bufferPath, false)
    }
    ...
}

Badge перемотки

Добавим стейт с говорящим isVideoPlaying для того чтобы скрывать или показывать badge.

BubbleTimeBar.kt

class BubbleTimeBar
@JvmOverloads constructor(
    ...
) : FrameLayout(context, attrs, defStyleAttr), TimeBar, Player.Listener {

    ...
    var isVideoPlaying: Boolean = false
    private val badgeDrawer = TimeBarBadgeDrawer(context)
    ...

   override fun onIsPlayingChanged(isPlaying: Boolean) {
       super.onIsPlayingChanged(isPlaying)
       isVideoPlaying = isPlaying
   }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        ...
        if (!isVideoPlaying) {
            badgeDrawer.drawBadge(
                canvas = canvas,
                progress = playProgress,
                bufferPathMeasure = bufferPathMeasure
            )
        }
    }

    ...
}

Добавим логику отрисовки badge на конце линии прогресса.

TimeBarBadgeDrawer.kt

class TimeBarBadgeDrawer(context: Context) {

    private val badgeCoordinates = floatArrayOf(0f, 0f)
    private val badgeRadius = context.resources.getDimensionPixelSize(R.dimen.bubble_timebar_badge_radius)
    private val paint = Paint().apply { color = Color.WHITE }

    fun drawBadge(canvas: Canvas, progress: Float, bufferPathMeasure: PathMeasure) {
        // Записываем координаты края линии прогресса в badgeCoordinates
        bufferPathMeasure.getPosTan(bufferPathMeasure.length * progress, badgeCoordinates, null)
        val circleX = badgeCoordinates[0]
        val circleY = badgeCoordinates[1]
        // Отрисовываем badge на конце линии прогресса
        canvas.drawCircle(circleX, circleY, badgeRadius.toFloat(), paint)
    }
}

Добавляем BubbleTimeBar как lister плеера.

BubbleViewHolder.kt

import com.google.android.exoplayer2.ui.R as exoPlayerR

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {
    ...
    private val player = ExoPlayer.Builder(itemView.context).build().apply {
        ...
        addListener(playerViewTimeBar)
    }
    ...
}

Перемотка по нажатию

Для обработки нажатия реализуем TimeBar.OnScrubListener

TimeBarScrubListener.kt

class TimeBarScrubListener(
    private val playerView: PlayerView
) : TimeBar.OnScrubListener {

    override fun onScrubStart(timeBar: TimeBar, position: Long) {
        // Останавливаем видео вначале перемотки
        playerView.player?.pause()
        // Перемотка видео
        playerView.player?.seekTo(position)
    }

    override fun onScrubMove(timeBar: TimeBar, position: Long) {
        // Перемотка видео
        playerView.player?.seekTo(position)
    }

    override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
        // Воспроизводим видео после перемотки
        playerView.player?.play()
    }
}

Добавляем TimeBarScrubListener в BubbleViewHolder

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {
    ...
    init {
        playerViewTimeBar.addListener(TimeBarScrubListener(playerView))
        ...
    }
    ...
}

Теперь добавим в BubbleTimeBar реализацию методов addListener/removeListener

BubbleTimeBar.kt

class BubbleTimeBar
@JvmOverloads constructor(
    ...
) : FrameLayout(context, attrs, defStyleAttr), TimeBar, Player.Listener {
    ...
    var listeners = mutableListOf<TimeBar.OnScrubListener>()
    ...
    override fun addListener(listener: TimeBar.OnScrubListener) {
        listeners.add(listener)
    }

    override fun removeListener(listener: TimeBar.OnScrubListener) {
        listeners.remove(listener)
    }
    ...
}

Добавим класс, который будет обрабатывать нажатия.

TimeBarTouchListener.kt

class TimeBarTouchListener(
    padding: Float,
    bufferPathStroke: Float
): View.OnTouchListener {

    private val calculator = TimeBarTouchCalculator(padding * 2 + bufferPathStroke)

    override fun onTouch(v: View, e: MotionEvent): Boolean {
        if (v !is BubbleTimeBar) return false
        when (e.action) {
            // начало touch-события
            MotionEvent.ACTION_DOWN -> {
                if (!calculator.isEventInRewindArea(v, e.x, e.y) || v.isVideoPlaying) return false
                v.parent.requestDisallowInterceptTouchEvent(true)
                v.listeners.forEach { it.onScrubStart(v, calculator.getNewScrubPosition(v, e.x, e.y)) }
            }
            // touch-событие движения пальца по экрану
            MotionEvent.ACTION_MOVE -> v.listeners.forEach {
                it.onScrubMove(v, calculator.getNewScrubPosition(v, e.x, e.y))
            }
            // конец touch-событие по системным причинам
            MotionEvent.ACTION_CANCEL -> v.listeners.forEach {
                it.onScrubStop(v, calculator.getNewScrubPosition(v, e.x, e.y), true)
            }
            // конец touch-события
            MotionEvent.ACTION_UP -> v.listeners.forEach {
                it.onScrubStop(v, calculator.getNewScrubPosition(v, e.x, e.y), false)
            }
        }
        return true
    }

    internal class TimeBarTouchCalculator(
        private val rewindAreaSize: Float
    ) {

        /**
         * Вычисляем позицию перемотки
         * Шаги вычисления:
         * 1. Вычслили угол между векторами A и B, где
         *      A - вектор из центра вью ровно вверх (0,-1)
         *      B - вектор из центра вью до точки [MotionEvent]
         * 2. По формуле пропорции полученного угла к 360 градусам получаем новую позицию
         */
        fun getNewScrubPosition(view: BubbleTimeBar, touchX: Float, touchY: Float) : Long {
            val x1 = 0F
            val y1 = -1F

            val x2 = touchX - view.width.toFloat() / 2
            val y2 = touchY - view.height.toFloat() / 2

            val cosAngle = (x1 * x2 + y1 * y2) / (sqrt(x1 * x1 + y1 * y1) * sqrt(x2 * x2 + y2 * y2))
            val angle = when {
                x2 < 0 -> 360F - Math.toDegrees(acos(cosAngle).toDouble())
                else -> Math.toDegrees(acos(cosAngle).toDouble())
            }
            return (view.videoDuration * angle / 360F).toLong()
        }

        /**
         * Проверяем находится ли [MotionEvent] в зоне перемотки
         * Шаги проверки:
         * 1. Вычисляем расстоляние от центра вью до точки касания
         * 2. Сравниваем полученное расстояние с радиусом вью за вычетом зоны перемотки
         */
        fun isEventInRewindArea(view: BubbleTimeBar, touchX: Float, touchY: Float): Boolean {
            val centerX = view.width / 2F
            val centerY = view.height / 2F
            val distance = sqrt((centerX - touchX).pow(2) + (centerY - touchY).pow(2))
            return distance >= centerY - rewindAreaSize
        }
    }
}

Добавим TimeBarTouchListener в BubbleTimeBar.

BubbleTimeBar.kt

class BubbleTimeBar
@JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr), TimeBar, Player.Listener {
    ...
    init {
        ...
        setOnTouchListener(TimeBarTouchListener(padding, bufferPathStroke))
    }
}

Заключение

Поздравляю! Мы сделали видео-сообщения. Соберите и посмотрите на результат.

Комментарии (11)


  1. LeshaRB
    02.12.2023 08:30
    +2

    В одну статью нельзя оформить?
    Если убрать картинки и скрыть код под спойлер

    Тут буквально ничего...


    1. Sazonov
      02.12.2023 08:30

      Я думаю даже AI Assistant из пакета Jetbrains справится с написанием таких статей. Действительно, описано лишь как человек играется с фреймворком.


      1. cookie2727 Автор
        02.12.2023 08:30

        На Хабре сидят разработчики разного уровня. Возможно кому-то эта статья поможет.


        1. Sazonov
          02.12.2023 08:30

          Очень часто звучит этот аргумент. Но на деле всё сводится к тривиальным примерам использования стандартных фреймворков. Статья была бы полезна, если были бы описаны какие-нибудь обобщённые практики программирования или советы по архитектуре.


          1. cookie2727 Автор
            02.12.2023 08:30
            +1

            Не совсем понимаю из-за чего у вас возникли эти ожидания от статьи?
            Я то что написал в описании, то и выполнил. Формат статьи-туториал. Сложность-простая.
            Откуда ожидание обобщённых практик программирования или советов по архитектуре в туториале по написанию ui-компонента и оптимизации его в Recycler'е?


            1. Sazonov
              02.12.2023 08:30

              Ок ок, я просто высказал своё мнение и свои ожидания от статей на Хабре :)

              Судя по положительным оценкам не я один ожидал чего-либо посложнее.


              1. cookie2727 Автор
                02.12.2023 08:30
                +1

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


    1. cookie2727 Автор
      02.12.2023 08:30

      А какие темы стоило бы разобрать?


    1. cookie2727 Автор
      02.12.2023 08:30

      Мне кажется картинки позволяют нагляднее объяснить. Не совсем понимаю что плохого в том, что я разбил на три части.

      Можно было ещё после первой части понять, что тема слишком простая для вас и не читать)


  1. Veygard
    02.12.2023 08:30
    +1

    Хорошие статьи! Не слушай ворчунов. Оформление также отличное.


  1. kacetal
    02.12.2023 08:30

    Интересно насколько проще/сложнее такое сделать на Compose?