Разработка
Марширующие муравьи — делаем кастомный модификатор для границ
Граница в виде «марширующих муравьев» — распространенный элемент пользовательского интерфейса, используемый для обозначения «выбранного» элемента, но предоставляемый Compose модификатор border() не справляется с этой задачей.
Граница в виде «марширующих муравьев» — распространенный элемент пользовательского интерфейса, используемый для обозначения «выбранного» элемента, но предоставляемый Compose модификатор border() не справляется с этой задачей.
Поэтому давайте создадим свой собственный!
И по ходу дела мы многое узнаем о Paths, измерении путей, рисовании путей и эффектах!
Информация
В этом и других постах я опускаю шаблон для кастомных модификаторов, обсуждая только реализации Modifier.Node(). Подробности о кастомных модификаторах посмотрите в официальной документации.
Воспроизведение граничного модификатора
Начнем с воссоздания существующей функции модификатора границы. Нам нужно будет взять некоторые параметры и реализовать DrawModifierNode.
private class MarchingAntsBorderNode(
var shape: Shape,
var width: Dp,
var brush: Brush,
) : Modifier.Node(), DrawModifierNode {
fun ContentDrawScope.draw() {
drawContent()
drawOutline(
outline = shape.createOutline(
size = size,
layoutDirection = layoutDirection,
density = this,
),
brush = brush,
style = Stoke(
// TODO(reader): Handle `Dp.Hairline`, if you need it
width = width.toPx(),
)
)
}
}
Если сравнить это с border() в Compose, можно заметить, что наша граница рисуется по центру фигуры, тогда как граница в Compose находится внутри нее. Если копнуть глубже в исходный код, то можно увидеть, что много работы уходит на обработку всех случаев для различных вариаций Outline (я подозреваю, что именно поэтому и существует Outline, чтобы можно было обрабатывать типичные случаи).
Поскольку мы все, вероятно, хотим, чтобы муравьи центрировались по границе фигуры, в этой статье мы не будем рассматривать вопрос о вставке внутрь нее.
Если вы посмотрите исходный код, то заметите, что в нем используется CacheDrawModifierNode. Мы тоже можем это сделать! Но правило номер один оптимизации гласит: сначала заставьте все работать, а потом сделайте его быстрым/маленьким/и т. д.
Муравьи
Теперь, когда у нас есть модификатор, который рисует границу, давайте добавим тире из муравьев. Поняли? Ладно, я перестану.
Compose предоставляет нам PathEffect.dashPathEffect! Он принимает массив интервалов, так что давайте подключим его к нашему аргументу Stroke.
private class MarchingAntsBorderNode(
// ...
var dashLength: Dp,
var gapLength: Dp,
) : Modifier.Node(), DrawModifierNode {
fun ContentDrawScope.draw() {
drawContent()
drawOutline(
// ...
style = Stoke(
width = width.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(
dashLength.toPx(),
gapLength.toPx(),
),
)
)
)
}
}
Это было легко, слишком легко.
Если вы посмотрите на то, что у нас получилось, то, скорее всего, заметите, что одна из черточек слишком длинная, а промежуток между ними слишком короткий. Это потому, что dashPathEffect не является интеллектуальным, он просто выводит черточки, пока не закончится путь.
Но мы можем это исправить. С помощью математики!
Все, что нам нужно сделать, это немного уменьшить наши тире и пробелы, чтобы весь интервал был кратным длине пути. И мы можем получить эту длину с помощью PathMeasure. Но для этого нам нужно обновить наш код, чтобы получить Path из Shape.
fun Path.length(): Float =
PathMeasure()
.apply { setPath(this@length, forceClosed = true) }
.length
// We could coerce externally, but that's a bigger foot-gun than
// always returnin a non-zero length.
.coerceAtLeast(0.1f)
fun Shape.toPath(size: Size, layoutDirection: LayoutDirection, density: Density): Path =
when (val outline = createOutline(size, layoutDirection, density)) {
is Outline.Generic -> outline.path
is Outline.Rectangle -> Path().apply { addRect(outline.rect) }
is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) }
}
// Extension function for discoverability!
fun PathEffect.Companion.perfectDashPathEffect(
pathLength: Float,
desiredDashLength: Float,
desiredGapLength: Float,
phaseFraction: Float = 0f,
): PathEffect {
val desiredInterval = desiredDashLength + desiredGapLength
// TODO(reader): play with other rounding fuctions, just make sure it's not zero!
val interval = pathLength / ceil(pathLength / desiredInterval)
val dash = interval * (desiredDashLength / desiredInterval)
return dashPathEffect(
intervals = floatArrayOf(dash, interval - dash),
phase = phaseFraction * interval,
)
}
private class MarchingAntsBorderNode : Modifier.Node(), DrawModifierNode {
fun ContentDrawScope.draw() {
drawContent()
val path = shape.toPath(
size = size,
layoutDirection = layoutDirection,
density = this,
)
val pathLength = path.length() // TODO(soon): expensive! cache this
drawPath(
path = path,
brush = brush,
style = Stoke(
width = width.toPx(),
pathEffect = PathEffect.perfectDashPathEffect(
pathLength = pathLength,
desiredDashLength = dashLength.toPx(),
desiredGapLength = gapLength.toPx(),
)
)
)
}
}
Отлично! Теперь, когда у нас есть муравьи, давайте научим их маршировать!
Марш
Дополнительным параметром dashPathEffect, который мы также поддерживаем с помощью perfectDashPathEffect, является фаза. Если вы знакомы с чем-либо цикличным, «фаза» — это то, на каком этапе цикла вы находитесь. Таким образом, все, что нам нужно сделать, это анимировать параметр фазы, и наши штрихи будут двигаться!
Анимации в Modifier.Nodes, особенно бесконечные анимации, могут быть сложными, поскольку у нас нет доступа к композабл функциям более высокого уровня. Нам нужно самостоятельно управлять анимациями, используя coroutineScope, доступный в Node.
private class MarchingAntsBorderNode : Modifier.Node(), DrawModifierNode {
private val phase = mutableFloatStateOf(0f)
override fun onAttach() {
// You could pass this animation spec in, or some other construct,
// but make sure to handle it changing by cancelling the launched job!
val spec = infiniteRepeatable<Float>(
animation = tween(easing = LinearEasing),
)
// Vectorizing is how Compose's animation system works under the hood.
// It's a little overkill for a linearly-eased float,
// but really useful for other easings or specs!
val v = spec.vectorize(Float.VectorConverter)
val zero = Float.VectorConverter.convertToVector(0f)
val one = Float.VectorConverter.convertToVector(1f)
coroutineScope.launch { // Cancels automatically onDetach!
while (isActive) {
withInfiniteAnimationFrameNanos {
phase.floatValue = Float.VectorConverter.convertFromVector(
v.getValueFromNanos(it, zero, one, zero)
)
}
}
}
}
}
После этого нам нужно просто передать phase.floatValue в perfectDashPathEffect, и смотреть, как наши муравьи маршируют!
Оптимизация
Наш модификатор в его текущем виде выполняет много работы на этапе рисования. В частности, мы измеряем путь при каждом перерисовке, что может быть дорогостоящим. Мы можем делегировать CacheDrawModifierNode поведение, как модификатор drawWithCache.
private class MarchingAntsBorderNode(
// ...
) : DelegatingNode() { // NOTE: we changed our supers!
// ... phase an onAttach stay the same
private val delegate = delegate(
CacheDrawModifierNode {
val path = shape.toPath(
size = size,
layoutDirection = layoutDirection,
density = this,
)
val pathLength = path.length()
onDrawWithContent {
drawContent()
drawPath(
// ...
)
}
}
)
}
Но теперь, когда мы используем кэширование, нам нужно убедиться, что мы правильно аннулируем данные! Вы можете сделать это в методе ModifierNodeElement.update, или мы можем изъять страницу из модификатора границ и использовать сеттеры свойств.
private class MarchingAntsBorderNode(
width: Dp
// ...
) : DelegatingNode() {
// ... Also do this for the other parameters!
var width = width
set(value) {
if (field != value) {
field = value
delegate.invalidateDrawCache()
}
}
private val delegate = // ...
}
Все вместе
Наконец, давайте соединим все это вместе:
class MarchingAntsBorderNode(
width: Dp,
brush: Brush,
shape: Shape,
var dashLength: Dp,
var gapLength: Dp,
) : DelegatingNode() {
private val phase = mutableFloatStateOf(0f)
override fun onAttach() {
val spec = infiniteRepeatable<Float>(
animation = tween(easing = LinearEasing),
)
val v = spec.vectorize(Float.VectorConverter)
val zero = Float.VectorConverter.convertToVector(0f)
val one = Float.VectorConverter.convertToVector(1f)
coroutineScope.launch {
while (isActive) {
withInfiniteAnimationFrameNanos {
phase.floatValue = Float.VectorConverter.convertFromVector(
v.getValueFromNanos(it, zero, one, zero)
)
}
}
}
}
var width = width
set(value) {
if (field != value) {
field = value
delegate.invalidateDrawCache()
}
}
var brush = brush
set(value) {
if (field != value) {
field = value
delegate.invalidateDrawCache()
}
}
var shape = shape
set(value) {
if (field != value) {
field = value
delegate.invalidateDrawCache()
}
}
private val delegate = delegate(
CacheDrawModifierNode {
val path = shape.toPath(
size = size,
layoutDirection = layoutDirection,
density = this,
)
val pathLength = path.length()
onDrawWithContent {
drawContent()
drawPath(
path = path,
brush = brush,
style = Stroke(
width = width.toPx(),
pathEffect = PathEffect.perfectDashPathEffect(
pathLength = pathLength,
desiredDashLength = dashLength.toPx(),
desiredGapLength = gapLength.toPx(),
phaseFraction = phase.floatValue,
)
)
)
}
}
)
}
fun PathEffect.Companion.perfectDashPathEffect(
pathLength: Float,
desiredDashLength: Float,
desiredGapLength: Float,
phaseFraction: Float = 0f,
): PathEffect {
val desiredInterval = desiredDashLength + desiredGapLength
val interval = pathLength / ceil(pathLength / desiredInterval)
val dash = interval * (desiredDashLength / desiredInterval)
return dashPathEffect(
intervals = floatArrayOf(dash, interval - dash),
phase = phaseFraction * interval,
)
}
fun Shape.toPath(size: Size, layoutDirection: LayoutDirection, density: Density): Path =
when (val outline = createOutline(size, layoutDirection, density)) {
is Outline.Generic -> outline.path
is Outline.Rectangle -> Path().apply { addRect(outline.rect) }
is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) }
}
fun Path.length(minLength: Float = 1f): Float =
PathMeasure()
.apply { setPath(this@length, forceClosed = true) }
.length
.coerceAtLeast(minLength)
-
Аналитика магазинов3 недели назад
Мобильный рынок Ближнего Востока: исследование Bidease и Sensor Tower выявляет драйверы роста
-
Видео и подкасты для разработчиков3 недели назад
Разбор кода: iOS-приложение для управления личными финансами на Swift. Часть 1
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.47
-
Разработка4 недели назад
100 уроков о том, как я довёл своё приложение до продажи за семизначную сумму





