Видео-сообщение как в Telegram. Часть третья — Контролы и раскрытое состояние
Часто ли вы пользуетесь Telegram?
Если да, то скорее всего вы хотя бы раз отправляли «кружочки». В этой серии статьей мы напишем небольшой проект с отображением списка видео-сообщений.
Для отображения будем использовать ExoPlayer, настроим сохранение видео в кеш, а также напишем свой TimeBar для управления видео.
Оглавление
Введение
Вначале каждой части я прикреплю git-ветку, в которой будут изменения, описанные в статье. Вы можете либо сразу скачать ее и запустить проект, либо самостоятельно пошагово писать код.
Также можете взять git-ветку предыдущей части и сразу понять контекст.
Git-ветка этой части.
Контролы и раскрытое состояние
По аналогии с Telegram’ом, хочется при тапе по сообщению увеличивать его и показывать кнопки плей/паузы и перемотки.
Раскрытие/сжатие видео
Добавим размеры для «кружочка» в ресурсы.
bubble.xml
230dp
260dp
15dp
3dp
6dp
Добавим цвета.
colors.xml
#FFFFFFFF
#4debebf5
Добавим методы раскрытия и сжатия сообщения.
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
) : RecyclerView.Adapter() {
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 контролов
Давайте подменим эти кнопки на свои. Для этого переопределяем параметр controller_layout_id у PlayerView.
li_bubble.xml
...
Создадим bubble_view_controller, сохранив исходные id у view, чтобы PlayerView корректно использовал их для управления видео.
bubble_view_controller.xml
Создадим 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(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 секунде (½ длины видео) длина линии прогресса должна быть ½ от длины линии буффера. Думаю, тут все просто.
Записываем длительность видео и текущую позицию просмотра.
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()
...
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))
}
}
Заключение
Поздравляю! Мы сделали видео-сообщения. Соберите и посмотрите на результат.