Custom view на Compose
Custom view на Compose
Всем привет, меня зовут Николай Широбоков, я — Android-разработчик в e-legion.
В июле Google выпустил стабильную версию Compose. Это вызвало большой интерес в сообществе. Все вокруг стали поговаривать, что эта технология захватит Android-разработку, и скоро все будут писать на Compose.
Я принялся за изучение, заглянул на developer.android.com и нашел различные туториалы по использованию этой библиотекой, но не увидел примеров, как можно создавать кастомные view. Поэтому решил попробовать сделать это и поделиться с вами результатом.
В этой статье покажу, как можно реализовать рыночный график со скроллом и зумом на Compose.
Рисуем свечи
Перед тем, как что-то нарисовать, нужно создать модель.
class Candle( val time: LocalDateTime, val open: Float, val close: Float, val high: Float, val low: Float )
Список со свечками я создал, скачав и распарсив файл с Финама. Это не самое интересное, поэтому останавливаться на этом не буду. Если интересно, смотрите код тут.
Для того, чтобы код был чище и лучше читался, сделал класс MarketChartState. В нем буду хранить состояние графика.
class MarketChartState { // общий список свечей private var candles = listOf<Candle>() // видимое количество свечей private var visibleCandleCount by mutableStateOf(60) // размеры области для рисования private var viewWidth = 0f private var viewHeight = 0f // минимальная и максимальная цены видимых свечей private val maxPrice by derivedStateOf { visibleCandles.maxOfOrNull { it.high } ?: 0f } private val minPrice by derivedStateOf { visibleCandles.minOfOrNull { it.low } ?: 0f } // видимые на экране свечи val visibleCandles by derivedStateOf { if (candles.isNotEmpty()) { candles.subList( 0, visibleCandleCount ) } else { emptyList() } } fun setViewSize(width: Float, height: Float) { viewWidth = width viewHeight = height } // отступ от левого края экрана fun xOffset(candle: Candle) = viewWidth * visibleCandles.indexOf(candle).toFloat() / visibleCandleCount.toFloat() // отступ от верхнего края экрана fun yOffset(value: Float) = viewHeight * (maxPrice - value) / (maxPrice - minPrice) }
derivedStateOf() создает State, значение для которого вычисляется лямбде. Это значение кешируется, и каждый новый подписчик получает уже закешированное значение. Если в лямбде используется другой State, то при его изменении значение будет пересчитано.
Пример. В лямбде visibleCandles используется visibleCandleCount. При изменении visibleCandleCount значение visibleCandles пересчитается.
Для рисования графика обратился к Composable функции Canvas. В ней есть доступ к DrawScope, в котором можно рисовать линии, прямоугольники, овалы и другие различные элементы.
Canvas( modifier = Modifier .fillMaxSize() .background(Color(0xFF182028)) ) { // из общей ширины и высоты вычел захардкоженные значения, // чтобы в освободившейся области рисовать дату и цену. val chartWidth = size.width - 128.dp.value val chartHeight = size.height - 64.dp.value state.setViewSize(chartWidth, chartHeight) // горизонтальная линия drawLine( color = Color.White, strokeWidth = 2.dp.value, start = Offset(0f, chartHeight), end = Offset(chartWidth, chartHeight) ) // вертикальная линия drawLine( color = Color.White, strokeWidth = 2.dp.value, start = Offset(chartWidth, 0f), end = Offset(chartWidth, chartHeight) ) // отрисовка свечей state.visibleCandles.forEach { candle -> val xOffset = state.xOffset(candle) drawLine( color = Color.White, strokeWidth = 2.dp.value, start = Offset(xOffset, state.yOffset(candle.low)), end = Offset(xOffset, state.yOffset(candle.high)) ) if (candle.open > candle.close) { drawRect( color = Color.Red, topLeft = Offset(xOffset - 6.dp.value, state.yOffset(candle.open)), size = Size(12.dp.value, state.yOffset(candle.close) - state.yOffset(candle.open)) ) } else { drawRect( color = Color.Green, topLeft = Offset(xOffset - 6.dp.value, state.yOffset(candle.close)), size = Size(12.dp.value, state.yOffset(candle.open) - state.yOffset(candle.close)) ) } } }
График, после запуска приложения:
Линии цен
Добавлю 9 ценовых линий. Линии будут располагаться на равном удалении друг от друга по всему экрану.
val priceLines by derivedStateOf { val priceItem = (maxPrice - minPrice) / 10 mutableListOf<Float>().apply { repeat(10) { if (it > 0) add(maxPrice - priceItem * it) } } }
В функции Canvas нарисую линии и текст. В DrawScope нет возможности рисовать текст, поэтому воспользуюсь расширением drawIntoCanvas, в котором можно получить доступ к Canvas к тому самому, который используется для рисования во View, и уже на нем нарисую текст.
state.priceLines.forEach { value: Float -> val yOffset = state.yOffset(value) val text = decimalFormat.format(value) drawLine( color = Color.White, strokeWidth = 1.dp.value, start = Offset(0f, yOffset), end = Offset(chartWidth, yOffset), pathEffect = PathEffect.dashPathEffect(intervals = floatArrayOf(10f, 20f), phase = 5f) ) drawIntoCanvas { textPaint.getTextBounds(text, 0, text.length, bounds) val textHeight = bounds.height() it.nativeCanvas.drawText( text, chartWidth + 8.dp.value, yOffset + textHeight / 2, textPaint ) } }
Масштабирование
Для этого воспользуюсь готовым решением из библиотеки и добавлю функцию расширение Modifier.transformable к Modifier в Canvas.
Canvas( modifier = Modifier .fillMaxSize() .background(Color(0xFF182028)) .transformable(state.transformableState) )
В стейте вызову функцию TransformableState и передам туда лямбду, в которой буду пользоваться только переменной для зума.
val transformableState = TransformableState { zoomChange, _, _ -> visibleCandleCount = (visibleCandleCount / zoomChange).roundToInt() }
Так как в стейте переменные maxPrice и minPrice зависят от видимых свечек, они сразу пересчитываются и также пересчитываются значения в priceLines.
Временные линии
Теперь буду добавлять на график временные линии и их расположение в зависимости от зума.
// количество свечек между временными линиями private var candleInGrid = Float.MAX_VALUE // временные линии var timeLines by mutableStateOf(listOf<Candle>()) // вычисления линий fun calculateGridWidth() { val candleWidth = viewWidth / visibleCandleCount val currentGridWidth = candleInGrid * candleWidth when { currentGridWidth < MIN_GRID_WIDTH -> { candleInGrid = MAX_GRID_WIDTH / candleWidth timeLines.value = candles.filterIndexed { index, _ -> index % candleInGrid.roundToInt() == 0 } } currentGridWidth > MAX_GRID_WIDTH -> { candleInGrid = MIN_GRID_WIDTH / candleWidth timeLines.value = candles.filterIndexed { index, _ -> index % candleInGrid.roundToInt() == 0 } } } }
state.timeLines.forEach { candle -> val offset = state.xOffset(candle) if (offset !in 0f..chartWidth) return@forEach drawLine( color = Color.White, strokeWidth = 1.dp.value, start = Offset(offset, 0f), end = Offset(offset, chartHeight), pathEffect = PathEffect.dashPathEffect(intervals = floatArrayOf(10f, 20f), phase = 5f) ) drawIntoCanvas { val text = candle.time.format(timeFormatter) textPaint.getTextBounds(text, 0, text.length, bounds) val textHeight = bounds.height() val textWidth = bounds.width() it.nativeCanvas.drawText( text, offset - textWidth / 2, chartHeight + 8.dp.value + textHeight, textPaint ) } }
Скролл
График почти готов, осталось добавить скролл. Снова воспользуюсь готовым решением и добавлю функцию расширение Modifier.scrollable к Modifier в Canvas.
Canvas( modifier = Modifier .fillMaxSize() .background(Color(0xFF182028)) .scrollable(state.scrollableState, Orientation.Horizontal) .transformable(state.transformableState) )
private val scrollOffset by mutableStateOf(0f) val scrollableState = ScrollableState { scrollOffset = if (it > 0) { (scrollOffset - it.scrolledCandles).coerceAtLeast(0f) } else { (scrollOffset - it.scrolledCandles).coerceAtMost(candles.lastIndex.toFloat()) } it } // преобразование проскроленного расстояния в проскроленные свечки private val Float.scrolledCandles: Float get() = this * visibleCandleCount.toFloat() / viewWidth // видимые свечи val visibleCandles by derivedStateOf { if (candles.isNotEmpty()) { candles.subList( scrollOffset.roundToInt().coerceAtLeast(0), (scrollOffset.roundToInt() + visibleCandleCount).coerceAtMost(candles.size) ) } else { emptyList() } }
Сохраняем состояние
График готов, но при повороте экрана стейт пересоздается. Для сохранения состояния обернул стейт в rememberSaveable и написал Saver.
rememberSaveable(saver = MarketChartState.Saver) { MarketChartState.getState(candles) } val Saver: Saver<MarketChartState, Any> = listSaver( save = { listOf(it.candles, it.scrollOffset, it.visibleCandleCount) }, restore = { getState( candles = it[0] as List<Candle>, visibleCandleCount = it[2] as Int, scrollOffset = it[1] as Float ) } )
Код этого примера можно найти по этой ссылке.
Compose оставил у меня только положительные впечатления, и я уверен, что этот подход захватит Android-разработку.