Рисуем интерактивный линейный график на iOS с помощью Charts

от автора

Однажды бизнес попросил меня создать минималистичный график, который будет отображать сглаженную кривую с градиентом под ней. По этому графику можно перемещаться между значениями, водя пальцем. При этом за пальцем должна следовать вертикальная линия, а текущее значение должно отображаться в выноске — «баблике» с дополнительной информацией. В будущем хотелось бы заложить возможность поддержки нескольких графиков в одной координатной сетке. Версии 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *