Однажды бизнес попросил меня создать минималистичный график, который будет отображать сглаженную кривую с градиентом под ней. По этому графику можно перемещаться между значениями, водя пальцем. При этом за пальцем должна следовать вертикальная линия, а текущее значение должно отображаться в выноске — «баблике» с дополнительной информацией. В будущем хотелось бы заложить возможность поддержки нескольких графиков в одной координатной сетке. Версии 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. Это доказал простой пример, в котором мы прошли путь от дефолтной визуализации набора данных до реального кейса.
В рамках следующих шагов можно поразмышлять, как отобразить два, три, энное количество графиков в одной области и какие проблемы могут при этом возникнуть.
ссылка на оригинал статьи https://habr.com/ru/company/psb/blog/669854/
Добавить комментарий