App Development
February 9, 2022

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 }
		}
	}
}

Изменения в функции 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.