Реализация линейного графика с использованием UIKit

В этой статье мы увидим, как интерактивные линейные диаграммы могли быть реализованы в iOS до того, как в 2022 году были представлены диаграммы SwiftUI.

Давайте представим вполне реальный сценарий, когда владелец продукта требует от вашей команды создания минималистичной линейной диаграммы, которая:

  • показать плавную кривую с градиентом;
  • интерактивно отображать данные на графике в ответ на жест пользователя;
  • текущее значение должно быть отмечено вертикальной линией и отображено в пузыре рядом с дополнительной информацией;
  • было бы неплохо поддерживать несколько графиков в одной области;
  • должна быть минимальная разница между окончательным внешним видом iOS и Android.

Это довольно подробное объяснение, но, пытаясь визуализировать его, вы заметите, что видели его много раз в различных вариациях в таких приложениях, как Apple Stocks, банковское дело или приложение для отслеживания фитнеса.

Пойдем

Разработка любого продукта начинается с исследований, и легко обнаружить, что нет необходимости изобретать велосипед — уже есть библиотека, которая соответствует нашим потребностям. Первоначально он был написан для Android (MPAndroidChart от Philipp Jahoda), а его версия для iOS называется Charts (написана Daniel Cohen Gindi).

Среди его преимуществ хотелось бы отметить следующее:

  • поддерживает множество типов диаграмм;
  • доступно множество вариантов настройки;
  • соответствует своему внешнему виду версии для Android;
  • установка с CocoaPods, Carthage, SPM.

Чтобы визуализировать требования к конечному продукту, нам нужно получить следующий виджет:

Экспериментировать с UI-компонентами и сторонними библиотеками удобнее всего в отдельном проекте. Давайте создадим пустой проект и установим Charts с вашим любимым менеджером зависимостей. Я предпочитаю SPM, все доступные варианты перечислены на Странице Charts Github.

Не буду описывать первоначальный процесс создания проекта. Если вы хотите получить окончательную версию проекта и работать с учебным пособием, оно доступно на моей странице GitHub.

Прежде всего, давайте создадим очень простую линейную диаграмму. Это поможет нам понять общую логику библиотеки Charts и, самое главное, что нужно изменить, чтобы добиться желаемого результата.

Добавьте следующий код в viewDidLoad() нашего контроллера представления (не забудьте import 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)
    }
}

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

Мы видим, что сама область диаграммы является потомком UIView, и мы должны установить в нее данные. Тип диаграммы — это именно линейная диаграмма, для гистограмм и других вариантов есть другие специальные типы представлений.

Кроме того, тип данных диаграммы должен соответствовать типу представления LineChartData. Этот тип данных имеет конструктор, который принимает некоторые наборы данных (на этом этапе мы можем заметить, что есть также конструктор, который принимает массив наборов данных, это ключ к реализации нескольких диаграмм в одной области, как мы помним, владелец продукта попросил попробовать и поддержите эту функцию).

Тип набора данных, в свою очередь, должен соответствовать типу линейной диаграммы LineChartDataSet, который представляет собой абстракцию над массивом записей данных (в конечном итоге это точки в области диаграммы). Каждая запись имеет координаты X и Y, довольно просто.

Давайте создадим и запустим наш проект, чтобы посмотреть, что нарисовано на экране:

О нет, это абсолютно не похоже на то, что бизнес хочет, чтобы мы реализовали. Тогда составим план изменений:

  • Изменить цвет линии графика
  • Удалить точки с графика и их аннотации
  • Добавьте сглаживание к нашей кривой
  • Добавить градиент под кривой
  • Удалить аннотации осей
  • Удалить легенду
  • Удалить сетку.

Настройте это

Некоторые из этих настроек относятся к области диаграммы, а некоторые — к набору данных (это связано с тем, что в одной области может отображаться несколько диаграмм, каждая из которых имеет свои собственные настройки).

Настройки области диаграммы:

// disable grid
chart.xAxis.drawGridLinesEnabled = false
chart.leftAxis.drawGridLinesEnabled = false
chart.rightAxis.drawGridLinesEnabled = false
chart.drawGridBackgroundEnabled = false
// disable axis annotations
chart.xAxis.drawLabelsEnabled = false
chart.leftAxis.drawLabelsEnabled = false
chart.rightAxis.drawLabelsEnabled = false
// disable legend
chart.legend.enabled = false
// disable zoom
chart.pinchZoomEnabled = false
chart.doubleTapToZoomEnabled = false
// remove artifacts around chart area
chart.xAxis.enabled = false
chart.leftAxis.enabled = false
chart.rightAxis.enabled = false
chart.drawBordersEnabled = false
chart.minOffset = 0
// setting up delegate needed for touches handling
chart.delegate = self

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

/// Factory preparing dataset for a single chart
struct ChartDatasetFactory {
    func makeChartDataset(
        colorAsset: DataColor,
        entries: [ChartDataEntry]
    ) -> LineChartDataSet {
        var dataSet = LineChartDataSet(entries: entries, label: "")

        // chart main settings
        dataSet.setColor(colorAsset.color)
        dataSet.lineWidth = 3
        dataSet.mode = .cubicBezier // curve smoothing
        dataSet.drawValuesEnabled = false // disble values
        dataSet.drawCirclesEnabled = false // disable circles
        dataSet.drawFilledEnabled = true // gradient setting

        // settings for picking values on graph
        dataSet.drawHorizontalHighlightIndicatorEnabled = false // leave only vertical line
        dataSet.highlightLineWidth = 2 // vertical line width
        dataSet.highlightColor = colorAsset.color // vertical line color

        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 = LinearGradientFill(gradient: gradient, angle: 270)
        }
    }
}

DataColor — это абстракция над UIColor, так как мы планируем получать данные диаграммы из модели представления и не хотим, чтобы UIKit просачивалось в слой модели представления.

/// Abstraction above 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 и добавим следующие настройки:

// selected value display settings
dataSet.drawHorizontalHighlightIndicatorEnabled = false // leave only vertical line
dataSet.highlightLineWidth = 2 // vertical line width
dataSet.highlightColor = colorAsset.color // vertical line color

Теперь наша диаграмма должна реагировать на касания следующим образом:

Остальное мы должны реализовать:

  • кружок выбранного значения
  • всплывающее окно с дополнительными информационными атрибутами (дата, значение, цветовая легенда).

Здесь нам помогут две особенности области диаграммы. Во-первых, у него есть делегат, а во-вторых, он может отображать маркеры. Итак, нашим следующим шагом будет создание пользовательского маркера, наследующего базовый класс MarkerView:

/// Marker for highlighting selected value on graph
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)
    }
}

Что касается информационного пузыря, то давайте просто сделаем собственное представление, его реализация не очень важна для логики диаграммы, вы можете найти пример реализации в финальном проекте (ChartInfoBubbleView). Из макета дизайна видно, что он должен содержать дату, цветовую легенду и значение Y.

Примечание: Для случаев с несколькими строками легенда и значение должны соответствовать каждой строке, чтобы это работало, наборы данных должны быть нормализованы к одному и тому же размеру на X. Другими словами, у нас нет функции, которая дает X и получает Y для случайного места, у нас есть предопределенные наборы дискретных значений, и поэтому эти X должны совпадать.

Давайте затем создадим оболочку вокруг области диаграммы, в которой будет храниться сама область, маркер и информационный пузырь.

/// Chart view
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) — здесь мы получаем запись набора данных, его данные будут использоваться в информационном пузыре, его свойство подсветки предоставит координаты точки на графике. Важной деталью является использование выделенных свойств .xPx и .yPx, но не .x и .y, что может показаться немного запутанным на первый взгляд, но именно так оно и работает;
  • func chartValueNothingSelected(_ chartView: ChartViewBase) — здесь мы спрячем наши маркеры.

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

// markers
chart.drawMarkers = true
circleMarker.chartView = chart
chart.marker = circleMarker

Когда мы это сделали, работает следующая логика: прикосновение пользователя обрабатывается методом делегата, и мы показываем маркер в виде круга и информационный пузырь. При касании за пределами линии, но в пределах области графика маркер и пузырь скрываются.

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

Отлично, теперь у нас есть готовая функция в соответствии с требованиями продукта:

Внимательный читатель мог заметить, что мы начали с точек с координатами XY, где X — просто номер элемента в наборе данных, а Y — значение, так откуда же взялись данные? Это довольно просто, ChartDataEntry имеет несколько инициализаторов, один из которых — @objc public convenience init(x: Double, y: Double, data: Any?), где data — это любой дополнительный атрибут, который мы хотим включить, поэтому мы добавили туда нашу календарную дату и получили ее обратно в обратном вызове делегата, обрабатывающем касание.

Последние мысли

Библиотека Charts имеет широкие возможности настройки, сохраняя согласованность между платформами iOS и Android, что часто требуется владельцам продуктов. Мы доказали это на простом примере, пройдя весь путь от визуализации по умолчанию до более или менее реальной индивидуальной реализации.

Куда пойти отсюда? Попробуйте подумать о реализации двух, трех и N линий в одной области графика и о том, какие проблемы могут возникнуть в связи с этим.