Как стать автором
Обновить

Эффект скрэтч-карты в Jetpack Compose

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров262
Автор оригинала: Sunday1990

Тема создания эффекта скрэтч-карты уже не нова, и существует множество статей, в которых описан процесс реализации этого эффекта на основе двух перекрывающихся слоёв: слоя покрытия и слоя контента. Однако автор решил отойти от привычных схем и сделать так, чтобы слой контента мог быть любым макетом, а не только изображением. В результате он написал собственное решение, которое выглядит следующим образом:

Реализация

Идея реализации достаточно проста:

  1. Отслеживаем жесты и генерируем путь касания.

  2. На основе этого пути создаём область выреза на слое покрытия.

  3. Отслеживаем процент выреза покрытия, и когда он достигает заданного порога, очищаем слой.

Отслеживание жестов и генерация пути

Это решается достаточно просто. Вот пример кода:

private fun Modifier.scratchcardGesture(
  path: Path,
): Modifier = pointerInput(path) {
  detectDragGestures(
    onDragStart = { offset ->
      // Начало перетаскивания
      path.moveTo(offset.x, offset.y)
    },
    onDrag = { _, dragAmount ->
      // В процессе перетаскивания, dragAmount — это смещение
      path.relativeLineTo(dragAmount.x, dragAmount.y)
    },
  )
}

Логика проста, код уже прокомментирован, поэтому не будем повторяться.

Вырезание области покрытия

Эффект вырезания области можно реализовать с помощью функционала, аналогичного примеру из официальной документации. Основной код для реализации выглядит следующим образом:

Modifier
  .graphicsLayer {
    // 1. Устанавливаем стратегию композиции в Offscreen
    compositingStrategy = CompositingStrategy.Offscreen
  }
  .drawWithContent {
    val dotSize = size.width / 8f
    drawCircle(
      Color.Black,
      radius = dotSize,
      center = Offset(
        x = size.width - dotSize,
        y = size.height - dotSize
      ),
      // 2. Устанавливаем режим смешивания Clear
      blendMode = BlendMode.Clear
    )
  }

Как видно из комментариев в коде, важно установить обе опции: compositingStrategy = CompositingStrategy.Offscreen и blendMode = BlendMode.Clear. Это обеспечит необходимый эффект вырезания.

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

Если не установить CompositingStrategy.Offscreen, результат будет следующим:

Проблема вырезания и расчёт процента вырезанной области

Если не установить CompositingStrategy.Offscreen, результат будет таким: область вокруг красного круга станет чёрной.

Расчёт процента вырезанной области

После того как пользователь стирает определённую часть покрытия, необходимо очистить его, чтобы показать содержимое, расположенное под ним. Это значит, что нужно вычислить процент вырезанной области от общей площади.

Поскольку траектория касания имеет произвольную форму, вычисление площади может быть сложным. Однако, можно подойти с другой стороны: вычислить процент вырезанных пикселей от общего числа пикселей.

Для этого нужно сделать снимок покрытия и получить данные о пикселях. Как это сделать? Ответ можно найти в официальной документации.

Пример кода:

val coroutineScope = rememberCoroutineScope()
val graphicsLayer = rememberGraphicsLayer()
Box(
  modifier = Modifier
    .drawWithContent {
      // Записываем содержимое в графический слой
      graphicsLayer.record {
        // Рисуем содержимое в графический слой
        this@drawWithContent.drawContent()
      }
      // Отображаем графический слой на экране
      drawLayer(graphicsLayer)
    }
    .clickable {
      coroutineScope.launch {
        val bitmap = graphicsLayer.toImageBitmap()
        // Делаем что-то с полученным изображением
      }
    }
    .background(Color.White)
) {
  Text("Hello Android", fontSize = 26.sp)
}

В приведённом коде показаны основные шаги:

  1. rememberGraphicsLayer() — создание графического слоя.

  2. Использование GraphicsLayer в drawWithContent.

  3. Получение скриншота с помощью метода GraphicsLayer.toImageBitmap().

Получение снимка экрана и подсчёт прозрачных пикселей

Полученный объект скриншота — это ImageBitmap, который предоставляет метод для чтения пикселей:

fun readPixels(
    buffer: IntArray,
    startX: Int = 0,
    startY: Int = 0,
    width: Int = this.width,
    height: Int = this.height,
    bufferOffset: Int = 0,
    stride: Int = width
)

По умолчанию достаточно передать массив типа IntArray, в который будут записаны пиксели. Посмотрим, как это используется в коде для вычисления процента вырезанных пикселей:

private suspend fun calculateProgress(): Float = withContext(Dispatchers.Default) {
  // Получаем снимок экрана
  val bitmap = graphicsLayer.toImageBitmap()
  // Общее количество пикселей
  val totalCount = bitmap.width * bitmap.height
  if (totalCount > 0) {
    IntArray(totalCount)
      // Считываем пиксели
      .also { bitmap.readPixels(it) }
      // Подсчитываем количество прозрачных пикселей
      .fold(0) { acc, pixel -> if (pixel == 0) acc + 1 else acc }
      // Вычисляем процент вырезанной области
      .let { it.toFloat() / totalCount }
  } else {
    0f
  }
}

В этом фрагменте кода мы выполняем следующие шаги:

  1. Сначала получаем снимок экрана с помощью метода toImageBitmap().

  2. Считаем общее количество пикселей на снимке.

  3. Используем метод readPixels() для чтения данных о пикселях в массив.

  4. Подсчитываем количество прозрачных пикселей, где пиксель имеет значение 0.

  5. Рассчитываем процент вырезанных пикселей относительно общего числа.

Обратите внимание, что такая возможность для скриншотов через GraphicsLayer появилась только с версии Compose 1.7.0.

Проблемы

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

Проблема 1

Как и в большинстве случаев с обёртками в Compose, нужно определить состояние в виде класса ScratchcardState, в котором будет храниться путь Path:

class ScratchcardState {
  // Путь для выемки
  val path = Path()

  // Начало перетаскивания жеста
  fun onDragStart(value: Offset) {
    // Обновление пути
    path.moveTo(value.x, value.y)
  }

  // Перетаскивание жеста
  fun onDragAmount(value: Offset) {
    // Обновление пути
    path.relativeLineTo(value.x, value.y)
  }
}

Далее, для отрисовки этого пути используем следующий код в методе draw:

Modifier.drawWithContent {
  drawContent()
  drawPath(
    // Рисуем путь
    path = state.path,
    color = Color.Black,
    style = Stroke(
      width = thicknessPx,
      cap = StrokeCap.Round,
      join = StrokeJoin.Round,
    ),
    blendMode = BlendMode.Clear,
  )
}

Однако, этот код не работает, потому что изменения в объекте Path не отслеживаются Compose, что означает, что перерисовка не произойдёт.

Чтобы решить эту проблему, можно использовать MutableState для создания дополнительной переменной redraw, которая будет изменяться при каждом изменении пути, таким образом, инициируя перерисовку компонента:

class ScratchcardState {
  val path = Path()

  // Переменная, триггерящая перерисовку
  var redraw by mutableIntStateOf(0)

  fun onDragAmount(value: Offset) {
    path.relativeLineTo(value.x, value.y)
    // Изменяем переменную для триггера перерисовки
    redraw++
  }
}

В методе draw теперь читаем переменную redraw:

drawWithContent {
  // Чтение переменной redraw
  state.redraw
  // ...
}

Когда redraw изменяется, это приводит к перерисовке компонента.

Некоторые могут задаться вопросом, почему не использовать mutableStateOf, чтобы напрямую сохранить объект Path, а затем создать новый с помощью метода copy. Причина заключается в производительности. Поскольку жесты перетаскивания происходят часто, использование целочисленного инкремента оказывается менее ресурсоёмким и более эффективным с точки зрения производительности.

Проблема 2

Когда следует вычислять процент выемки? В идеале, это должно происходить в процессе перетаскивания, однако, если вычисления будут происходить на каждом изменении, это окажет большое влияние на производительность, так как потребуется обходить каждый пиксель.

Для решения этой проблемы можно использовать Flow.sample, чтобы вычисления происходили с интервалами. Рассмотрим упрощённый вариант кода:

kotlinКопировать кодfun startCalculate() {
  if (_calculateJob == null) {
    _calculateJob = coroutineScope.launch {
      snapshotFlow { redraw }
        // Ограничиваем выполнение не чаще чем раз в 500 миллисекунд
        .sample(500)
        .collect {
          val progress = calculateProgress()
          if (progress >= getClearThreshold()) {
            // Если прогресс выемки превышает порог, очищаем слой
            clear()
          }
        }
    }
  }
}

В предыдущем вопросе мы определили переменную redraw, а с помощью snapshotFlow { redraw } можем отслеживать её изменения. Используя Flow.sample, мы ограничиваем частоту вычислений, чтобы они происходили не чаще, чем раз в 500 миллисекунд. Таким образом, вычисления будут происходить во время перетаскивания, но не слишком часто, что помогает избежать частых перерасходов ресурсов.

Проблема 3

На некоторых устройствах, например, на телефонах Meizu, при активации функции Aicy для длительного нажатия и затем перетаскивания, возникает ситуация, когда draw не перерисовывается. После отладки выяснилось, что значение redraw изменяется, но перерисовка не происходит. Было решено заранее считать redraw в составе, что корректно инициирует перерисовку. Однако это приводит к излишней нагрузке на производительность, так как перерисовывать нужно только тогда, когда это действительно необходимо.

Для решения этой проблемы вводится флаг forceRecomposition, который будет управлять принудительной перерисовкой. Когда происходит жест перетаскивания, запускается отложенная задача, например, с задержкой в 100 миллисекунд, которая установит флаг в true. В методе draw задача отменяется, и если флаг установлен, то принудительно вызывается перерисовка.

Рассмотрим пример кода:

kotlinКопировать кодclass ScratchcardState {
  // Задача на принудительную перерисовку
  private var _forceRecompositionJob: Job? = null
  // Флаг принудительной перерисовки
  var forceRecomposition by mutableStateOf(false)

  fun onDragStart(value: Offset) {
    // Сбрасываем задачу принудительной перерисовки перед началом перетаскивания
    cancelForceRecomposition()
  }

  fun onDragAmount(value: Offset) {
    // Запускаем задачу принудительной перерисовки во время перетаскивания
    startForceRecomposition()
  }

  // Метод для отчетности о рисовании
  fun reportDraw() {
    // Отмена отложенной задачи
    _forceRecompositionJob?.cancel()
  }

  private fun startForceRecomposition() {
    if (_forceRecompositionJob == null) {
      _forceRecompositionJob = coroutineScope.launch {
        // Задержка перед установкой флага на принудительную перерисовку
        delay(48)
        forceRecomposition = true
      }
    }
  }

  // Отмена задачи принудительной перерисовки
  private fun cancelForceRecomposition() {
    _forceRecompositionJob?.cancel()
    _forceRecompositionJob = null
    forceRecomposition = false
  }
}

Далее, обновим код для композиции:

kotlinКопировать кодfun Modifier.scratchcard(
  state: ScratchcardState,
): Modifier {
  return composed {
    // Если требуется принудительная перерисовка, читаем `redraw` и запускаем перерисовку
    if (state.forceRecomposition) {
      state.redraw
    }

    drawWithContent {
      // Отправляем отчет о рисовании
      state.reportDraw()
      // ...
    }
  }
}

Такой подход позволяет в большинстве случаев полагаться на стандартную перерисовку в draw, а принудительная перерисовка применяется только в случае задержки.

Проблема 4

Для того чтобы контролировать, была ли очищена поверхность, вводится переменная cleared, которая будет указывать, очищена ли она. Если значение cleared равно true, слой не будет отображаться. Для того чтобы сохранить этот статус при пересоздании интерфейса, используется rememberSaveable с кастомным сохранением состояния.

Пример кода:

class ScratchcardState(initialCleared: Boolean = false) {
  var cleared by mutableStateOf(initialCleared)

  companion object {
    internal val Saver = listSaver(
      save = { listOf(it.cleared) },
      restore = { ScratchcardState(initialCleared = it[0]) },
    )
  }
}

@Composable
fun rememberScratchcardState(): ScratchcardState {
  return rememberSaveable(saver = ScratchcardState.Saver) {
    ScratchcardState()
  }
}

Используя rememberSaveable с кастомным сохраняющим объектом, при восстановлении состояния экрана будет правильно восстанавливаться и состояние переменной cleared.

Заключение

В завершение давайте рассмотрим, как использовать библиотеку, упакованную автором. Для этого создадим простой компонент, который использует интерфейс "царапаемой карты" (ScratchcardBox):

@Composable
private fun ContentView() {
  ScratchcardBox(
    overlay = {
      // Слой покрытия
      Box(Modifier.background(Color.Gray))
    },
    content = {
      // Слой содержимого
      Image(
        painter = painterResource(R.drawable.scratchcard_content),
        contentDescription = null,
      )
    },
  )
}

Как видно, использование достаточно простое. Конечно, существуют и другие настройки, которые не были перечислены здесь. Для полного кода вы можете ознакомиться с репозиторием compose-scratchcard.

Теги:
Хабы:
0
Комментарии1

Публикации

Истории

Работа

Ближайшие события