Как стать автором
Обновить
Dodo Engineering
О том, как разработчики строят IT в Dodo

Изморозь на пицце: делаем новогоднюю анимацию в Android-приложении

Время на прочтение 5 мин
Количество просмотров 4.2K

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

Мы в Dodo стараемся делать наши приложения в первую очередь качественными, но и не забываем добавлять фановых фич для клиентов. Так, например, мы реализовали анимацию «Летающая Пицца», игру «Хвостики», а в канун Нового года решили сделать праздничную зимнюю анимацию под названием «Изморозь».

При при запуске над контентом приложения появляется слой изморози, как будто экран замёрз и пользователь может стереть её пальцем.

В этой статье хочу поделится технической стороной анимации: как добиться эффекта стирания картинки. Сделать её можно за несколько шагов. Не верите? Смотрите!

Что будем делать? Конечно же, рисовать на Canvas.

Let it snow!

Нам понадобятся две картинки:

  • изморозь, картинка с прозрачностью. Чем ближе к центру, тем прозрачнее;

  • обрамление в виде снежинок.

Слева — изморозь, справа — обрамление.
Слева — изморозь, справа — обрамление.
  1. Первым делом создаём кастомный класс изморози:

  class RimeView constructor(context: Context) : View(context) {
    // почти готово
  }
  1. Переводим две картинки в bitmap в соответствии с размерами экрана в методе onSizeChanged, так как он вызывается в тот момент, когда определяются размеры кастомного вью:

  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
  super.onSizeChanged(w, h, oldw, oldh)

  //rime
  rimeBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
  val rimeCanvas = Canvas(rimeBitmap)
  val rimeDrawable = ContextCompat.getDrawable(context, R.drawable.bg)
  rimeDrawable?.setBounds(0, 0, w, h)
  rimeDrawable?.draw(rimeCanvas)

  //snow
  snowBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
  val snowCanvas = Canvas(snowBitmap)
  val snowDrawable = ContextCompat.getDrawable(context, R.drawable.snow)
  snowDrawable?.setBounds(0, 0, w, h)
  snowDrawable?.draw(snowCanvas)
  }
  1. Рисуем две картинки по очереди в методе onDraw:

  val paint = Paint()
  
  override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  //rime
  canvas.drawBitmap(rimeBitmap, 0f, 0f, paint)
	
  //snow
  canvas.drawBitmap(snowBitmap, 0f, 0f, paint)
  
  }

Хочу обратить внимание на кисточку: она у нас пока просто дефолтная paint = Paint()

Теперь нужен третий «буферный» bitmap, в который будем записывать результат стирания.

  1. Определяем буферный bitmap в onSizeChanged, далее выносим переменную scratchCanvas в поле класса, так как будем на ней рисовать стирание:

  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
  super.onSizeChanged(w, h, oldw, oldh)

  //buffer bitmap
  scratchBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
  scratchCanvas = Canvas(scratchBitmap)

  //rime
  rimeBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
  val rimeCanvas = Canvas(rimeBitmap)
  val rimeDrawable = ContextCompat.getDrawable(context, R.drawable.bg)
  rimeDrawable?.setBounds(0, 0, w, h)
  rimeDrawable?.draw(rimeCanvas)

  //snow
  snowBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
  val snowCanvas = Canvas(snowBitmap)
  val snowDrawable = ContextCompat.getDrawable(context, R.drawable.snow)
  snowDrawable?.setBounds(0, 0, w, h)
  snowDrawable?.draw(snowCanvas)

  }
  1. Отрисовываем в onDraw:

canvas.drawBitmap(raimBitmap, 0f, 0f, paint)

canvas.drawBitmap(snowBitmap, 0f, 0f, paint)

//buffer bitmap
canvas.drawBitmap(scratchBitmap, 0f, 0f, paint)

Тут важный момент — конфигурация наших кисточек,

  override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)
  
  paint.xfermode = srcOverPorterDuffMode

  canvas.drawBitmap(rimeBitmap, 0f, 0f, paint)

  canvas.drawBitmap(snowBitmap, 0f, 0f, paint)

  paint.xfermode= dstOutPorterDuffMode

  canvas.drawBitmap(scratchBitmap, 0f, 0f, paint)

  }

в котором

private val srcOverPorterDuffMode = PorterDuffXfermode(PorterDuff.Mode.SRC_OVER)
private val dstOutPorterDuffMode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)

это как раз та самая магия стирания.

Давайте разберём чуть подробнее, как это работает.

В документации Android указано, что при наложении двух картинок друг на друга мы можем задать разную композицию.

Например, есть две картинки Destination image и Source image:

зеленый - Destination Image, голубой - Source image
зеленый - Destination Image, голубой - Source image

Давайте нарисуем их по очереди:

//1 
val paint = Paint()
//2 
canvas.drawBitmap(destinationImage, 0f, 0f, paint)
//3 
val mode = // choose a PorterDuff.Mode 
//4 
paint.xfermode = PorterDuffXfermode(mode)
//5 
canvas.drawBitmap(sourceImage, 0f, 0f, paint);
  1. Определяем дефолтную кисточку.

  2. Рисуем Destination image.

  3. Определяем конфигурацию PorterDuff.Mode.

  4. Задаём вышеуказанный мод для кисточки.

  5. Рисуем Source image.

Исходя из того, какую конфигурацию PorterDuff.Mode мы задали для кисточки, у нас получаются разные композиции:

Нам подходит PorterDuff.Mode = Destination Out, то есть накладываемая сверху картинка должна обрезать область накладывания.

  1. Теперь нужно отследить траекторию движения пальца по фону. Для этого мы создаём объект Path(), в который будем записывать путь:

  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
  super.onSizeChanged(w, h, oldw, oldh)

  ...


    if (path == null) {
      path = Path()
    }
  }
  1. И переопределяем onTouchEvent, в котором берём координаты в момент нажатия пальцем на экран и в момент убирания пальца и рисуем линию между ними:

  override fun onTouchEvent(event: MotionEvent): Boolean {

  val currentTouchX = event.x
  val currentTouchY = event.y
		
  when (event.action) {
    MotionEvent.ACTION_DOWN -> {
        path?.reset()
        path?.moveTo(event.x, event.y)
    }

    MotionEvent.ACTION_UP -> {
        path?.lineTo(currentTouchX, currentTouchY)
    }

    MotionEvent.ACTION_MOVE -> {
		//пока пусто, мы определим его чуть ниже
	}
    }
    scratchCanvas?.drawPath(path, innerPaint)
    mLastTouchX = currentTouchX
    mLastTouchY = currentTouchY
    invalidate()
    return true
  }

innerPaint — это дефолтная кисточка.

  1. Определим некий контейнер и добавим в него наш RimeView

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#9C3333"
    tools:context=".MainActivity"
    />
val container = findViewById<FrameLayout>(R.id.container)
val rimeView = RimeView(this)
rimeView.layoutParams = 
FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
container.addView(rimeView)

И у нас получается вот такой предварительный результат:

  1. Добавим отрисовку по ходу ведения пальца:

MotionEvent.ACTION_MOVE -> {
  val dx =abs(currentTouchX - mLastTouchX)
  val dy =abs(currentTouchY - mLastTouchY)
  if (dx >= 4 || dy >= 4) {
    val x1 = mLastTouchX
    val y1 = mLastTouchY
    val x2 = (currentTouchX + mLastTouchX) / 2
    val y2 = (currentTouchY + mLastTouchY) / 2
    mPath?.quadTo(x1, y1, x2, y2)
  }
}

Здесь мы рисуем квадратичную кривую Безье, если палец прошёл более 4 пикселей в одну из сторон (значение получено опытным путём), для того чтобы был эффект закругления. И получаем вот такой конечный результат:



Вот и всё!

Если вам всё ещё не верится, что мы сделали эту анимацию за несколько шагов, то вспомните, что в канун нового года случаются чудеса. =)

Всех с наступающим Новым годом!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Были ли вы уже знакомы с PorterDuff.Mode?
51.16% Да 22
48.84% Нет 21
Проголосовали 43 пользователя. Воздержались 5 пользователей.
Теги:
Хабы:
+26
Комментарии 6
Комментарии Комментарии 6

Публикации

Информация

Сайт
dodo.dev
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия