Pull to refresh
76.53
ПСБ
ПСБ – один из десяти крупнейших банков страны

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

Reading time7 min
Views3.4K

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

В рамках следующих шагов можно поразмышлять, как отобразить два, три, энное количество графиков в одной области и какие проблемы могут при этом возникнуть.

Tags:
Hubs:
Total votes 5: ↑5 and ↓0+5
Comments3

Articles

Information

Website
www.psbank.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия
Representative
Наталья Низкоус