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

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



Вот и всё!

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

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

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


  1. quaer
    30.12.2022 11:02
    +1

    Сколько человеко-часов затрачено на написание ТЗ, программирование, тестирование, публикацию, напсиание статьи? Сколько копий приложения при средней цене приложения на маркете в 3$ надо было бы продать чтобы окупить такую работу (у вас приложение наверно бесплатное, поэтому сколько пицц)?


    1. Serov_George
      30.12.2022 23:40

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


    1. Levitanus
      31.12.2022 02:13

      Ну, выглядит, как рабочий день на прототип + тестирование-доводка в рамках спринта.

      • статья — ещё часа 4.

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


  1. quaer
    31.12.2022 00:43

    Всё, что вы перечислили, сделано по рациональным причинам и из расчёта.