Всем привет, меня зовут Николай Широбоков, я — 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 }
}
}
}
Изменения в функции Canvas.
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-разработку.
Всем добра и крутых экранов с Compose.