Всем привет! Новый год уже совсем близко, значит, самое время добавить новогодней атмосферы.
![](https://habrastorage.org/getpro/habr/upload_files/bbf/9b6/8ac/bbf9b68ac4c24c3968fe6650ab8c8df4.png)
Мы в Dodo стараемся делать наши приложения в первую очередь качественными, но и не забываем добавлять фановых фич для клиентов. Так, например, мы реализовали анимацию «Летающая Пицца», игру «Хвостики», а в канун Нового года решили сделать праздничную зимнюю анимацию под названием «Изморозь».
![](https://habrastorage.org/getpro/habr/upload_files/78c/1f5/91c/78c1f591c5a71c46940174bec06a911f.gif)
При при запуске над контентом приложения появляется слой изморози, как будто экран замёрз и пользователь может стереть её пальцем.
В этой статье хочу поделится технической стороной анимации: как добиться эффекта стирания картинки. Сделать её можно за несколько шагов. Не верите? Смотрите!
Что будем делать? Конечно же, рисовать на Canvas.
![](https://habrastorage.org/getpro/habr/upload_files/56b/fc1/3e4/56bfc13e4e3c0216f233a26880edc056.png)
Let it snow!
Нам понадобятся две картинки:
изморозь, картинка с прозрачностью. Чем ближе к центру, тем прозрачнее;
обрамление в виде снежинок.
![Слева — изморозь, справа — обрамление. Слева — изморозь, справа — обрамление.](https://habrastorage.org/getpro/habr/upload_files/11e/af6/499/11eaf6499ba01dbbd1f2d0b2d5ecbc0a.png)
Первым делом создаём кастомный класс изморози:
class RimeView constructor(context: Context) : View(context) {
// почти готово
}
Переводим две картинки в 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)
}
Рисуем две картинки по очереди в методе 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, в который будем записывать результат стирания.
Определяем буферный 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)
}
Отрисовываем в 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](https://habrastorage.org/getpro/habr/upload_files/8cf/907/e5c/8cf907e5ce43ed8a6afa8587d4c36be9.jpg)
Давайте нарисуем их по очереди:
//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);
Определяем дефолтную кисточку.
Рисуем Destination image.
Определяем конфигурацию PorterDuff.Mode.
Задаём вышеуказанный мод для кисточки.
Рисуем Source image.
Исходя из того, какую конфигурацию PorterDuff.Mode мы задали для кисточки, у нас получаются разные композиции:
![](https://habrastorage.org/getpro/habr/upload_files/4a5/311/4a5/4a53114a5e33c86f87be028acd8094eb.png)
Нам подходит PorterDuff.Mode = Destination Out, то есть накладываемая сверху картинка должна обрезать область накладывания.
Теперь нужно отследить траекторию движения пальца по фону. Для этого мы создаём объект Path(), в который будем записывать путь:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
if (path == null) {
path = Path()
}
}
И переопределяем 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 — это дефолтная кисточка.
Определим некий контейнер и добавим в него наш 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)
И у нас получается вот такой предварительный результат:
![](https://habrastorage.org/getpro/habr/upload_files/d1e/18d/ccc/d1e18dccc4eeeecf6db785a24525d720.gif)
Добавим отрисовку по ходу ведения пальца:
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 пикселей в одну из сторон (значение получено опытным путём), для того чтобы был эффект закругления. И получаем вот такой конечный результат:
![](https://habrastorage.org/getpro/habr/upload_files/704/2e6/f2a/7042e6f2a9606b9fcadecec23d065d01.gif)
Вот и всё!
Если вам всё ещё не верится, что мы сделали эту анимацию за несколько шагов, то вспомните, что в канун нового года случаются чудеса. =)
Всех с наступающим Новым годом!