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

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

bbf9b68ac4c24c3968fe6650ab8c8df4.png

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

78c1f591c5a71c46940174bec06a911f.gif

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

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

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

56bfc13e4e3c0216f233a26880edc056.png

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 мы задали для кисточки, у нас получаются разные композиции:

4a53114a5e33c86f87be028acd8094eb.png

Нам подходит 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


val container = findViewById(R.id.container)
val rimeView = RimeView(this)
rimeView.layoutParams = 
FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
container.addView(rimeView)

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

d1e18dccc4eeeecf6db785a24525d720.gif

  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 пикселей в одну из сторон (значение получено опытным путём), для того чтобы был эффект закругления. И получаем вот такой конечный результат:

7042e6f2a9606b9fcadecec23d065d01.gif

Вот и всё!

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

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

© Habrahabr.ru