Однажды бизнес попросил меня создать минималистичный график, который будет отображать сглаженную кривую с градиентом под ней. По этому графику можно перемещаться между значениями, водя пальцем. При этом за пальцем должна следовать вертикальная линия, а текущее значение должно отображаться в выноске — «баблике» с дополнительной информацией. В будущем хотелось бы заложить возможность поддержки нескольких графиков в одной координатной сетке. Версии iOS и Android должны быть максимально похожи. Примерно такие графики есть в системном приложении «Акции», в финансовых приложениях и фитнес-трекерах.
В этой статье я расскажу о библиотеке Charts и покажу на реальном примере, как создать свой первый график.
Поиск готовых решений
После непродолжительного ресёрча становится ясно, что в этой задаче всё придумано за нас и изобретать велосипед не нужно. Для Android существует библиотека MPAndroidChart (автор Philipp Jahoda), её аналог на iOS называется Charts (автор Daniel Cohen Gindi).
К плюсам Charts относятся:
соответствие отображению на Android;
поддержка обширной разновидности графиков;
много возможностей кастомизации отображения данных;
поддержка CocoaPods, Carthage, SPM.
Минусы:
отсутствие подробной документации (я пользовался документацией для Android);
нехватка туториалов, чтобы на примерах понять общий подход к работе.
Итак, в итоге нужно будет прийти к следующему интерфейсу:
Экспериментировать с UI и сторонними библиотеками удобнее в отдельном проекте. К примеру, можно создать пустой проект и подключить в него Charts любым удобным способом. Я предпочитаю SPM, все доступные способы описаны на странице Charts в Github. Объяснять начальный процесс создания проекта я не буду: думаю, все с этим знакомы. Итоговая версия проекта доступна в Github.
Практика
Попробуем создать первый простейший линейный график. Это нужно для понимания в общих чертах логики устройства Charts и того, что потребуется изменить, чтобы приблизиться к изначальным макетам.
Добавим такой код во viewDidLoad() нашего контроллера (не забываем про импорт Charts в заголовке файла):
override func viewDidLoad() {
super.viewDidLoad()
let lineChartEntries = [
ChartDataEntry(x: 1, y: 2),
ChartDataEntry(x: 2, y: 4),
ChartDataEntry(x: 3, y: 3),
]
let dataSet = LineChartDataSet(entries: lineChartEntries)
let data = LineChartData(dataSet: dataSet)
let chart = LineChartView()
chart.data = data
view.addSubview(chart)
chart.snp.makeConstraints {
$0.centerY.width.equalToSuperview()
$0.height.equalTo(300)
}
}
Для понимания логики можно двигаться по коду с конца. Область графика (chart) — это вьюшка, куда нужно передать какие-то данные. Вью имеет тип именно линейного графика, для столбчатых и иных диаграмм потребуется другой тип.
Передаваемые данные должны соответствовать типу графика, а именно — LineChartData. Этот тип принимает в конструктор некоторый датасет. Причём есть конструктор, где можно передать массив датасетов. Это важно, так как нам нужно заложить возможность поддержки нескольких графиков в одной координатной сетке.
Отлично, датасет тоже должен соответствовать типу линейного графика (LineChartDataSet). Наш датасет является некоторой абстракцией над массивом точек (entries), которые мы хотим отобразить. В базовом варианте каждая точка в свою очередь задаётся координатами X и Y — всё как в школе. С логикой разобрались, теперь посмотрим, что отобразилось на экране.
Понятно, что это совсем не похоже на рисунок дизайнера. Составим план изменений:
поменять цвет графика;
убрать точки на графике и их подписи к ним;
добавить сглаживание;
добавить градиент под графиком;
убрать подписи на осях;
убрать легенду;
убрать координатную сетку.
Часть этих настроек относится к области графика, часть — к датасету. Это связано с тем, что в одной области может быть несколько графиков, и каждый — со своими настройками.
Область графика:
// отключаем координатную сетку
chart.xAxis.drawGridLinesEnabled = false
chart.leftAxis.drawGridLinesEnabled = false
chart.rightAxis.drawGridLinesEnabled = false
chart.drawGridBackgroundEnabled = false
// отключаем подписи к осям
chart.xAxis.drawLabelsEnabled = false
chart.leftAxis.drawLabelsEnabled = false
chart.rightAxis.drawLabelsEnabled = false
// отключаем легенду
chart.legend.enabled = false
// отключаем зум
chart.pinchZoomEnabled = false
chart.doubleTapToZoomEnabled = false
// убираем артефакты вокруг области графика
chart.xAxis.enabled = false
chart.leftAxis.enabled = false
chart.rightAxis.enabled = false
chart.drawBordersEnabled = false
chart.minOffset = 0
// устанавливаем делегата, нужно для обработки нажатий
chart.delegate = self
Фабрика датасетов
Для настроек датасета перейдём на шаг вперёд. Мы сразу создадим фабрику датасетов на тот случай, когда в одной области может быть несколько графиков.
/// Фабрика подготовки датасета для графика
struct ChartDatasetFactory {
func makeChartDataset(
colorAsset: DataColor,
entries: [ChartDataEntry]
) -> LineChartDataSet {
var dataSet = LineChartDataSet(entries: entries, label: nil)
// общие настройки графика
dataSet.setColor(colorAsset.color)
dataSet.lineWidth = 3
dataSet.mode = .cubicBezier // сглаживание
dataSet.drawValuesEnabled = false // убираем значения на графике
dataSet.drawCirclesEnabled = false // убираем точки на графике
dataSet.drawFilledEnabled = true // нужно для градиента
addGradient(to: &dataSet, colorAsset: colorAsset)
return dataSet
}
}
private extension ChartDatasetFactory {
func addGradient(
to dataSet: inout LineChartDataSet,
colorAsset: DataColor
) {
let mainColor = colorAsset.color.withAlphaComponent(0.5)
let secondaryColor = colorAsset.color.withAlphaComponent(0)
let colors = [
mainColor.cgColor,
secondaryColor.cgColor,
secondaryColor.cgColor
] as CFArray
let locations: [CGFloat] = [0, 0.79, 1]
if let gradient = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: colors,
locations: locations
) {
dataSet.fill = .fillWithLinearGradient(gradient, angle: 270)
}
}
}
Работа с цветом
DataColor — это элементарная абстракция над UIColor, потому что требуется получать данные для построения графика из вьюмодели, но при этом не нужно, чтобы UIKit протекал в слой вьюмодели.
/// Абстракция над цветами UIColor
enum DataColor {
case first
case second
case third
var color: UIColor {
switch self {
case .first:
return UIColor(
red: 56/255,
green: 58/255,
blue: 209/255,
alpha: 1
)
case .second:
return UIColor(
red: 235/255,
green: 113/255,
blue: 52/255,
alpha: 1
)
case .third:
return UIColor(
red: 52/255,
green: 235/255,
blue: 143/255,
alpha: 1
)
}
}
Запустим проект и посмотрим, что получилось:
Затронуто всё, кроме обработки нажатий. Сейчас выделенное значение подсвечивается перекрестием жёлтого цвета. Теперь посмотрим, что можно настроить из коробки, а что придётся дописать вручную.
Для начала вернёмся в фабрику датасета и добавим эти настройки:
// оформление, связанное с выбором значения на графике
dataSet.drawHorizontalHighlightIndicatorEnabled = false // оставляем только вертикальную линию
dataSet.highlightLineWidth = 2 // толщина вертикальной линии
dataSet.highlightColor = colorAsset.color // цвет вертикальной линии
Теперь график должен отвечать на выбор значения вот так:
Остальное придётся создавать вручную:
круг, которым выделяется значение;
баблик для отображения атрибутов точки (дата, значение, легенда цвета).
Доработки
Тут пригодятся два свойства области графика.
Во-первых, у неё есть делегат
Во-вторых, она умеет отображать маркеры. Для этого необходимо создать свой маркер с наследованием признаков базового класса MarkerView:
/// Круговой маркер для отображения выбранной точки на графике
final class CircleMarker: MarkerView {
override func draw(context: CGContext, point: CGPoint) {
super.draw(context: context, point: point)
context.setFillColor(UIColor.white.cgColor)
context.setStrokeColor(UIColor.blue.cgColor)
context.setLineWidth(2)
let radius: CGFloat = 8
let rectangle = CGRect(
x: point.x - radius,
y: point.y - radius,
width: radius * 2,
height: radius * 2
)
context.addEllipse(in: rectangle)
context.drawPath(using: .fillStroke)
}
}
Что касается баблика, примем простое решение: создадим кастомную view. Код для логики Charts не так важен. Реализацию можно посмотреть в итоговом проекте (ChartInfoBubbleView).
Из макета видно, что там должна отображаться дата, цветовая легенда данных и значение по оси Y. Важно: если графиков несколько, легенда и значения отображаются для каждого. Для соблюдения точности наборы данных для построения графиков должны иметь одинаковую размерность, потому что в них есть только дискретные данные и нет функции. Мы не можем подставить X и получить Y в произвольном месте.
Далее создадим обёртку над областью графика. Обёртка будет хранить саму область, маркер и выноску.
/// Вьюшка графика
final class ChartView: UIView {
private let chart = LineChartView()
private let circleMarker = CircleMarker()
private let infoBubble = ChartInfoBubbleView()
var viewModel: ChartViewModelProtocol? {
didSet {
updateChartDatasets()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
}
Вернёмся к делегату, добавим для этого класса соответствие протоколу ChartViewDelegate. Тут нам интересны два метода:
func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight) — здесь мы получаем точку из датасета (entry). Данные из неё отправятся в баблик, а параметр highlight даст нам координаты точки на графике. Важный нюанс: тут нужно использовать свойства .xPx и .yPx, а не .x и .y, в которых не будет данных по координатам;
func chartValueNothingSelected(_ chartView: ChartViewBase) — здесь мы скрываем маркеры.
Добавление поддержки маркеров:
// маркеры
chart.drawMarkers = true
circleMarker.chartView = chart
chart.marker = circleMarker
Итоговый результат
На выходе получается следующее: нажатие отработалось методом делегата, мы показали круглый маркер на графике и баблик сбоку от него. При нажатии вне графика, но в его области, маркер и выноска скрываются. Чтобы выноска не выходила за края области графика, понадобится написать нехитрую логику, которая проверяет, помещается ли баблик на экран рядом с точкой или её нужно перенести на другую сторону от вертикальной черты и подвинуть по высоте. Пример такой реализации можно посмотреть в итоговом проекте:
Внимательный читатель заметит, что мы начинали с точек c координатами X и Y. И если X у нас соответствует просто порядковому номеру элемента в датасете, а Y — непосредственно значение измеряемой величины, то откуда взялась дата? Тут всё просто. Для ChartDataEntry существует несколько инициализаторов, один из которых — @objc public convenience init(x: Double, y: Double, data: Any?). В поле data мы передали дату, которая соответствует дате наблюдаемого значения, и достали её в коллбэке делегата при обработке нажатия.
Заключение
Библиотека Charts даёт гибкие возможности для кастомизации графика, сохраняя единообразие между платформами iOS и Android. Это доказал простой пример, в котором мы прошли путь от дефолтной визуализации набора данных до реального кейса.
В рамках следующих шагов можно поразмышлять, как отобразить два, три, энное количество графиков в одной области и какие проблемы могут при этом возникнуть.