Разработка
Марширующие муравьи — делаем кастомный модификатор для границ
Граница в виде «марширующих муравьев» — распространенный элемент пользовательского интерфейса, используемый для обозначения «выбранного» элемента, но предоставляемый 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 недели назад
Видео и подкасты о мобильной разработке 2025.22
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.24
-
Вовлечение пользователей4 недели назад
Небольшое изменение в интерфейсе Duolingo, которое меняет все
-
Маркетинг и монетизация4 недели назад
Институциональные покупки: понимание и обнаружение