Connect with us

Разработка

Марширующие муравьи — делаем кастомный модификатор для границ

Граница в виде «марширующих муравьев» — распространенный элемент пользовательского интерфейса, используемый для обозначения «выбранного» элемента, но предоставляемый 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)

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: