Привет, Хабр! Меня зовут Валентин Страздин, я — ведущий iOS-разработчик в команде мобильной разработки «Лаборатории Касперского», где мы создаем решения для защиты мобильных устройств и для родительского контроля детских устройств.
Недавно мы обновляли наше приложение родительского контроля и мониторинга Kaspersky Safe Kids — и в ходе редизайна пришли к выводу, что «родительские» графики активности детского устройства стали громоздкими и неудобными. Нам же требовался виджет, в котором была бы видна актуальная информация о времени использования устройства, интервалах блокировок и еще ряд полезных деталей.
В статье расскажу, как мы быстро решили эту проблему благодаря open-source-библиотеке Charts: в чем ее особенности и нюансы, как мы ей пользуемся и как смогли локализовать такую систему даже для арабских пользователей.

Текст будет полезен iOS-разработчикам, которые хотят использовать готовое решение для визуализации графиков и которым нужен пример кастомной настройки Charts. В частности, расскажу про кастомный рендеринг данных, отрисовку на графике за пределами области данных. Также покажу, как можно развернуть график справа налево в случае необходимости арабской локализации.
Итак, как я уже говорил, у нас имелись графики с отчетами об использовании детских устройств, но они занимали целый экран и для их просмотра требовался зум; короче говоря, такой мониторинг был не всегда удобен.

Нам же нужен был виджет-таймлайн, на котором были бы показаны:
На «Графике работы устройства» требовалось показывать следующую информацию:
Чтобы быстрее реализовать данную задачу, мы решили посмотреть имеющиеся open-source-библиотеки для iOS. Одна из наиболее популярных iOS-библиотек — это Charts. Другая альтернативная библиотека, Swift Charts от Apple, нам не подошла — для ее реализации необходимо использовать SwiftUI и iOS версии 16 как минимум, а у нас в проекте все интерфейсы использовали UIKit, и мы все еще поддерживали iOS 15.
Для того чтобы попробовать возможности библиотеки Charts, я создал тестовый проект, в котором нарисовал график с фиксированными тестовыми интервалами и легендами под графиком.

Приложение родителя получает от сервера массив данных [TimelineInterval], который и надо показать на графике. У каждого интервала есть начало и конец (в минутах с начала текущих суток), а также тип: активное использование, блокировка, предупреждение или дополнительное время. Разные типы интервалов мы показываем на графике различными цветами.
DeviceWorkTimeline.ViewModel — вью-модель для нашего экрана
Сначала нарисуем на графике только интервалы, отметку текущего времени добавим позже. На обычном графике по горизонтальной оси мы откладываем аргумент, а по вертикальной — значение. В нашем случае и аргумент, и значение откладываются по горизонтальной оси, поэтому нам понадобится кастомный рендеринг для отображения графика.
Обычно ChartDataEntry обозначает точку на графике с координатами (x, y). В нашем случае TimelineChartDataEntry — это прямоугольник на графике, у которого левый верхний угол находится в точке с координатами (x, y), длина — length в минутах, высота — height, цвет заливки — color и опционально массив цветов градиента — gradientColors.
TimelineChartRenderer — объект, отвечающий за кастомный рендеринг данных, умеет обрабатывать массив TimelineChartDataEntry.
TimelineChartView — основной объект, отвечающий за отрисовку графика, в нем хранится renderer, xAxisRenderer, он является dataProvider’ом для графика.
TimelineMinuteValueFormatter — объект, отвечающий за форматирование подписей по оси времени.
DeviceWorkTimelineViewImpl — экран в приложении, в который мы добавляем график. Я не буду вдаваться в подробности, как мы добавляем все вью и настраиваем констрейнты. Это можно посмотреть в примере. Что здесь важно — мы создаем скролл-вью, добавляем на него график с интервалами, задаем масштаб и отступы на графике с п��мощью констрейнтов.

Теперь нам нужно задать положение отметки текущего времени timelinePosition в минутах на момент обновления данных. Мы можем попробовать использовать TimelineChartRenderer для отрисовки отметки текущего времени, но при таком подходе верхняя часть отметки обрежется, потому что она находится за пределами области данных.

Чтобы верхняя часть отметки не обрезалась, надо использовать TimelineChartXAxisRenderer — объект, отвечающий за кастомный рендеринг горизонтальной оси. Он позволяет рисовать за пределами области данных, кроме того, отметка текущего времени будет нарисована поверх линий координатной сетки.
Для того чтобы задать кастомный xAxisRenderer, надо внести изменения в коде TimelineChartView, DeviceWorkTimelineViewImpl. Затем добавляем TimelineChartViewEventsHandler, чтобы проскроллить скролл-вью до отметки текущего времени.
Наши продукты уникальны тем, что распространяются в более чем 100 странах на 34 языках (подробнее об том, как мы минимальными усилиями раскатываем наши продукты на такое количество локалей, — в этой хабростатье). А значит, нам нужна в том числе и арабская локализация, главная особенность которой — написание справа налево. И именно в этом направлении нам и нужно развернуть график.
В отличие от элементов UIKit, которые автоматически поддерживают ориентацию справа налево, здесь нам придется действовать самостоятельно. Сделаем простое преобразование — поменяем знак у всех границ интервалов с плюса на минус.
Если у нас был набор интервалов в промежутке от 0 до 24 часов, мы получим симметричный набор в промежутке от –24 часов до 0 часов. У отметки текущего времени тоже изменим знак с плюса на минус. А чтобы на координатной оси не появлялись отрицательные значения, возьмем абсолютное значение для форматирования подписей по горизонтальной оси. У градиента заливки прямоугольников также надо поменять направление.
И в итоге получаем такую картинку!

Для поддержки ориентации справа налево задаем extension для UIView. Также надо внести изменения в коде TimelineInterval, TimelineChartRenderer, TimelineChartView, TimelineMinuteValueFormatter, DeviceWorkTimelineViewImpl.
Ура, теперь родители могут следить за временем использования девайса своими детьми даже на арабском языке :)
На этом все. Использование open-source-решения позволило нам не отвлекаться на многие сопутствующие детали (линии координатной сетки, подписи по оси). Таким образом мы быстро справились с поставленной задачей!
Финальная версия проекта уже доступна на моей странице GitHub — для тех, кто испытывает сложности с визуализациями и масштабированием. Ну а если у вас есть свой не менее интересный опыт работы с UI-библиотеками (возможно, вы тоже успели поработать с Charts или выбрали Swift Charts от Apple) — жду ваших историй в комментариях!
Недавно мы обновляли наше приложение родительского контроля и мониторинга Kaspersky Safe Kids — и в ходе редизайна пришли к выводу, что «родительские» графики активности детского устройства стали громоздкими и неудобными. Нам же требовался виджет, в котором была бы видна актуальная информация о времени использования устройства, интервалах блокировок и еще ряд полезных деталей.
В статье расскажу, как мы быстро решили эту проблему благодаря open-source-библиотеке Charts: в чем ее особенности и нюансы, как мы ей пользуемся и как смогли локализовать такую систему даже для арабских пользователей.

Текст будет полезен iOS-разработчикам, которые хотят использовать готовое решение для визуализации графиков и которым нужен пример кастомной настройки Charts. В частности, расскажу про кастомный рендеринг данных, отрисовку на графике за пределами области данных. Также покажу, как можно развернуть график справа налево в случае необходимости арабской локализации.
Что было раньше?
Итак, как я уже говорил, у нас имелись графики с отчетами об использовании детских устройств, но они занимали целый экран и для их просмотра требовался зум; короче говоря, такой мониторинг был не всегда удобен.

Нам же нужен был виджет-таймлайн, на котором были бы показаны:
- временная шкала;
- отметка текущего времени;
- все периоды использования, блокировок, предупреждений и дополнительного времени.
Ставим задачу
На «Графике работы устройства» требовалось показывать следующую информацию:
- скроллируемую временную шкалу с подписями;
- все интервалы использования, блокировок, предупреждений и дополнительного времени;
- отметку текущего времени;
- легенды с описанием типов интервалов.
Чтобы быстрее реализовать данную задачу, мы решили посмотреть имеющиеся open-source-библиотеки для iOS. Одна из наиболее популярных iOS-библиотек — это Charts. Другая альтернативная библиотека, Swift Charts от Apple, нам не подошла — для ее реализации необходимо использовать SwiftUI и iOS версии 16 как минимум, а у нас в проекте все интерфейсы использовали UIKit, и мы все еще поддерживали iOS 15.
Для того чтобы попробовать возможности библиотеки Charts, я создал тестовый проект, в котором нарисовал график с фиксированными тестовыми интервалами и легендами под графиком.

Модель данных
Приложение родителя получает от сервера массив данных [TimelineInterval], который и надо показать на графике. У каждого интервала есть начало и конец (в минутах с начала текущих суток), а также тип: активное использование, блокировка, предупреждение или дополнительное время. Разные типы интервалов мы показываем на графике различными цветами.
DeviceWorkTimeline.ViewModel — вью-модель для нашего экрана
Типы интервалов
public struct TimelineInterval: Comparable, Equatable { // MARK: - Public Types // тип интервала public enum IntervalType { case active case additionalTime case overtime case block } public typealias Minute = Double // MARK: - Public Properties // начало интервала в минутах с начала суток public let start: Minute // конец интервала в минутах с начала суток public let end: Minute public let type: IntervalType // MARK: - Internal Properties // длина интервала в минутах var length: Minute { end - start } // MARK: - Init public init(start: Minute, end: Minute, type: IntervalType = .active) { self.start = start self.end = end self.type = type } // MARK: - Protocol Comparable // сравнение двух интервалов public static func < (lhs: TimelineInterval, rhs: TimelineInterval) -> Bool { lhs.start < rhs.start } } enum DeviceWorkTimeline { struct ViewModel { let intervals: [TimelineInterval] // отметка текущего времени в минутах с начала суток let timelinePosition: CGFloat init( intervals: [TimelineInterval] = [], timelinePosition: CGFloat ) { self.intervals = intervals self.timelinePosition = timelinePosition } } }
Кастомный рендеринг
Сначала нарисуем на графике только интервалы, отметку текущего времени добавим позже. На обычном графике по горизонтальной оси мы откладываем аргумент, а по вертикальной — значение. В нашем случае и аргумент, и значение откладываются по горизонтальной оси, поэтому нам понадобится кастомный рендеринг для отображения графика.
Обычно ChartDataEntry обозначает точку на графике с координатами (x, y). В нашем случае TimelineChartDataEntry — это прямоугольник на графике, у которого левый верхний угол находится в точке с координатами (x, y), длина — length в минутах, высота — height, цвет заливки — color и опционально массив цветов градиента — gradientColors.
TimelineChartRenderer — объект, отвечающий за кастомный рендеринг данных, умеет обрабатывать массив TimelineChartDataEntry.
TimelineChartView — основной объект, отвечающий за отрисовку графика, в нем хранится renderer, xAxisRenderer, он является dataProvider’ом для графика.
TimelineMinuteValueFormatter — объект, отвечающий за форматирование подписей по оси времени.
DeviceWorkTimelineViewImpl — экран в приложении, в который мы добавляем график. Я не буду вдаваться в подробности, как мы добавляем все вью и настраиваем констрейнты. Это можно посмотреть в примере. Что здесь важно — мы создаем скролл-вью, добавляем на него график с интервалами, задаем масштаб и отступы на графике с п��мощью констрейнтов.

Кастомный рендеринг
public final class TimelineChartDataEntry: ChartDataEntry { private enum Constant { static let minimumEntryLength: Minute = 1 } // MARK: - Types public typealias Minute = Double // MARK: - Properties public let length: Minute public let height: Double public let color: UIColor public let gradientColors: [UIColor]? // MARK: - Init & deinit public init(x: Double, y: Double, length: Minute, height: Double, color: UIColor, gradientColors: [UIColor]?) { self.length = length self.height = height self.color = color self.gradientColors = gradientColors super.init(x: x, y: y) } public convenience init(timelineInterval: TimelineInterval) { let length = timelineInterval.length let height = 0.5 let x = timelineInterval.start let y = (1 - height) / 2 let color: UIColor var gradientColors: [UIColor]? // цвет и градиент задаются в зависимости от типа интервала self.init(x: x, y: y, length: length, height: height, color: color, gradientColors: gradientColors) } public required convenience init() { self.init(x: 0, y: 0, length: 0, height: 0, color: .clear, gradientColors: nil) } var entryRect: CGRect { .init(x: x, y: y, width: max(length, Constant.minimumEntryLength), height: height) } } final class TimelineChartRenderer: BarLineScatterCandleBubbleRenderer { // MARK: - Private Properties private weak var dataProvider: BarLineScatterCandleBubbleChartDataProvider? // MARK: - Init & deinit init(dataProvider: BarLineScatterCandleBubbleChartDataProvider, animator: Animator, viewPortHandler: ViewPortHandler) { self.dataProvider = dataProvider super.init(animator: animator, viewPortHandler: viewPortHandler) } // MARK: - Overrides override func drawData(context: CGContext) { // получаем массив данных guard let dataProvider = dataProvider, let data = dataProvider.data, let dataSets = data.dataSets as? [BarLineScatterCandleBubbleChartDataSet], let dataSet = dataSets.first(where: { $0.isVisible }), !dataSet.isEmpty else { return } let transformer = dataProvider.getTransformer(forAxis: dataSet.axisDependency) for entryIndex in 0..<dataSet.count { // если данные в массиве правильного типа, то мы рисуем их в контексте guard let entry = dataSet[entryIndex] as? TimelineChartDataEntry else { continue } drawInterval(for: entry, with: transformer, in: context) } } override func drawExtras(context: CGContext) { /* Not supported */ } override func drawValues(context: CGContext) { /* Not supported */ } override func drawHighlighted(context: CGContext, indices: [Highlight]) { /* Not supported */ } // MARK: - Private private func drawInterval(for entry: TimelineChartDataEntry, with transformer: Transformer, in context: CGContext) { // очень важно сохранить контекст перед отрисовкой интервала и восстановить после отрисовки context.saveGState() defer { context.restoreGState() } var entryRect = entry.entryRect transformer.rectValueToPixel(&entryRect) if let gradientColors = entry.gradientColors { // если для данного интервала используется градиент, мы создаем градиент let colorSpace = CGColorSpaceCreateDeviceRGB() let colors = gradientColors.map { $0.cgColor } let locations: [CGFloat] = [0.0, 1.0] guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations) else { return } let startPoint = entryRect.bottomLeftCorner let endPoint = entryRect.topRightCorner // заполняем прямоугольник градиентом let options = CGGradientDrawingOptions() context.addRect(entryRect) context.clip() context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: options) } else { // заполняем прямоугольник цветом context.setFillColor(entry.color.cgColor) context.fill(entryRect) } } } здесь мы использовали такой extension для CGRect private extension CGRect { var topLeftCorner: CGPoint { origin } var topRightCorner: CGPoint { .init(x: origin.x + size.width, y: origin.y) } var bottomLeftCorner: CGPoint { .init(x: origin.x, y: origin.y + size.height) } var bottomRightCorner: CGPoint { .init(x: origin.x + size.width, y: origin.y + size.height) } } final class TimelineChartView: BarLineChartViewBase { static let axisLabelWidth: CGFloat = 24 // MARK: - Private types private enum Constant { static let bottomOffset: CGFloat = 1 static let gridLineWidth: CGFloat = 1 static let axisYOffset: CGFloat = 4 static let axisMinimum: Double = 0 // 0 hours static let axisMaximum: Double = 24 * 60 // 24 hours static var labelsCount: Int { Int((axisMaximum - axisMinimum) / 15) + 1 // every 15 minutes } } // MARK: - Init & deinit override init(frame: CGRect) { super.init(frame: frame) setup() } // MARK: - Private private func setup() { setupChart() setupXAsix() setupYAxis() } // MARK: - Chart private func setupChart() { // задаем кастомный рендеринг данных self.renderer = TimelineChartRenderer(dataProvider: self, animator: chartAnimator, viewPortHandler: viewPortHandler) // настраиваем видимость элементов, отступы, жесты highlightPerTapEnabled = false highlightPerDragEnabled = false doubleTapToZoomEnabled = false legend.enabled = false setScaleEnabled(false) extraLeftOffset = Self.axisLabelWidth / 2 extraRightOffset = Self.axisLabelWidth / 2 extraBottomOffset = Constant.bottomOffset } private func setupXAsix() { // настраиваем видимость элементов на горизонтальной оси, шрифты, отступы xAxis.labelPosition = .bottom xAxis.drawGridLinesEnabled = true xAxis.drawGridLinesBehindDataEnabled = false xAxis.drawAxisLineEnabled = false xAxis.labelFont = .systemFont(ofSize: 6.5, weight: .semibold) xAxis.labelHeight = 15.0 xAxis.labelTextColor = .textSecondary xAxis.yOffset = Constant.axisYOffset xAxis.axisMinimum = Constant.axisMinimum xAxis.axisMaximum = Constant.axisMaximum // используем кастомное форматирование подписей по горизонтальной оси xAxis.valueFormatter = TimelineMinuteValueFormatter() xAxis.axisMaxLabels = Constant.labelsCount xAxis.setLabelCount(Constant.labelsCount, force: true) xAxis.gridColor = .standardTertiary.withAlphaComponent(0.5) xAxis.gridLineWidth = Constant.gridLineWidth } private func setupYAxis() { // настраиваем видимость элементов на вертикальной оси, шрифты, масштаб leftAxis.drawGridLinesEnabled = false leftAxis.drawLabelsEnabled = false leftAxis.drawAxisLineEnabled = false leftAxis.xOffset = 0 leftAxis.yOffset = 0 leftAxis.axisMinimum = 0 leftAxis.axisMaximum = 1 leftAxis.inverted = true leftAxis.enabled = false rightAxis.enabled = false } } final class TimelineMinuteValueFormatter: NSObject, AxisValueFormatter { // MARK: - Private Properties private let formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .positional formatter.zeroFormattingBehavior = .pad return formatter }() // MARK: - Formatting func stringForValue(_ value: Double, axis: AxisBase?) -> String { // Значение границ интервалов задаются в минутах с начала суток, мы их переводим в секунды let timeInterval = value.rounded(.up) * 60 return formatter.string(from: timeInterval) ?? "" } } final class DeviceWorkTimelineViewImpl: UIViewController { // MARK: - Private Types private typealias TimelineChartDataSet = BarLineScatterCandleBubbleChartDataSet private typealias ViewModel = DeviceWorkTimeline.ViewModel private enum Constant { static let twoHours: CGFloat = 2 * 60 static let twentyFourHours: CGFloat = 24 * 60 } private lazy var chartScrollView: UIScrollView = { let scrollView = UIScrollView() scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false scrollView.backgroundColor = .clear scrollView.delegate = self scrollView.isScrollEnabled = true return scrollView }() private lazy var chartView: TimelineChartView = { let chartView = TimelineChartView(frame: view.bounds) chartView.translatesAutoresizingMaskIntoConstraints = false return chartView }() private var chartViewWidth: NSLayoutConstraint? // MARK: - View Life Cycle override func loadView() { ... chartScrollView.addSubview(chartView) setupConstraints() } private func setupConstraints() { let chartViewWidth = chartView.widthAnchor.constraint( equalTo: chartScrollView.widthAnchor ) self.chartViewWidth = chartViewWidth NSLayoutConstraint.activate([ … chartViewWidth, … )] } private func presentViewModel(_ viewModel: ViewModel) { setupChartView(viewModel: viewModel) updateChartViewConstraints() } private func setupChartView(viewModel: ViewModel) { let values: [TimelineChartDataEntry] = viewModel.intervals.map { .init(timelineInterval: $0) } let dataSet = TimelineChartDataSet(values) chartView.data = ChartData(dataSets: [dataSet]) } private func updateChartViewConstraints() { chartViewWidth?.isActive = false // мы показываем 2 часа из 24 let multiplier = Constant.twentyFourHours / Constant.twoHours // нам нужно дополнительное место на скролл вью, чтобы показывать первую и последнюю подпись let extraSpace: CGFloat = multiplier * TimelineChartView.axisLabelWidth chartViewWidth = chartView.widthAnchor.constraint( equalTo: chartScrollView.widthAnchor, multiplier: multiplier, constant: -extraSpace ) chartViewWidth?.isActive = true } }
Отмечаем текущее время
Теперь нам нужно задать положение отметки текущего времени timelinePosition в минутах на момент обновления данных. Мы можем попробовать использовать TimelineChartRenderer для отрисовки отметки текущего времени, но при таком подходе верхняя часть отметки обрежется, потому что она находится за пределами области данных.

Чтобы верхняя часть отметки не обрезалась, надо использовать TimelineChartXAxisRenderer — объект, отвечающий за кастомный рендеринг горизонтальной оси. Он позволяет рисовать за пределами области данных, кроме того, отметка текущего времени будет нарисована поверх линий координатной сетки.
Для того чтобы задать кастомный xAxisRenderer, надо внести изменения в коде TimelineChartView, DeviceWorkTimelineViewImpl. Затем добавляем TimelineChartViewEventsHandler, чтобы проскроллить скролл-вью до отметки текущего времени.
Отметка текущего времени
final class TimelineChartXAxisRenderer: XAxisRenderer { private enum Constant { static let timelineWidth: CGFloat = 2 static let circleRadius: CGFloat = 6 static let pointerHeight: CGFloat = 10 static let pointerWidth: CGFloat = 12 } // MARK: - Properties private let timelinePosition: CGFloat private let timelineColor: UIColor init(timelinePosition: CGFloat, timelineColor: UIColor, viewPortHandler: ViewPortHandler, axis: XAxis, transformer: Transformer?) { self.timelinePosition = timelinePosition self.timelineColor = timelineColor super.init(viewPortHandler: viewPortHandler, axis: axis, transformer: transformer) } // MARK: - Override Methods override func renderGridLines(context: CGContext) { super.renderGridLines(context: context) // здесь мы рисуем линию отметки поверх линий координатной сетки drawTimelinePosition(in: context) } override func renderAxisLine(context: CGContext) { // здесь мы рисуем отметку текущего времени за пределами области данных drawCircle(in: context) drawPointer(in: context) drawTimelinePosition(in: context) } private func drawTimelinePosition(in context: CGContext) { guard let transformer = transformer else { return } context.saveGState() defer { context.restoreGState() } var lineStart = CGPoint(x: timelinePosition, y: 0) var lineEnd = CGPoint(x: timelinePosition, y: 1) transformer.pointValueToPixel(&lineStart) transformer.pointValueToPixel(&lineEnd) context.setStrokeColor(timelineColor.cgColor) context.setFillColor(timelineColor.cgColor) context.setLineWidth(Constant.timelineWidth) // рисуем линию context.move(to: lineStart) context.addLine(to: CGPoint(x: lineEnd.x, y: lineEnd.y - Constant.pointerHeight / 2)) context.strokePath() } private func drawCircle(in context: CGContext) { guard let transformer = transformer else { return } context.saveGState() defer { context.restoreGState() } var lineStart = CGPoint(x: timelinePosition, y: 0) transformer.pointValueToPixel(&lineStart) context.setFillColor(timelineColor.cgColor) context.addEllipse(in: CGRect(x: lineStart.x - Constant.circleRadius, y: lineStart.y - Constant.circleRadius, width: Constant.circleRadius * 2, height: Constant.circleRadius * 2)) context.fillPath() } private func drawPointer(in context: CGContext) { guard let transformer = transformer else { return } context.saveGState() defer { context.restoreGState() } var lineEnd = CGPoint(x: timelinePosition, y: 1) transformer.pointValueToPixel(&lineEnd) context.setFillColor(timelineColor.cgColor) context.move(to: lineEnd) context.addLine(to: CGPoint(x: lineEnd.x - Constant.pointerWidth / 2, y: lineEnd.y - Constant.pointerHeight)) context.addLine(to: CGPoint(x: lineEnd.x + Constant.pointerWidth / 2, y: lineEnd.y - Constant.pointerHeight)) context.addLine(to: lineEnd) context.fillPath() } } public protocol TimelineChartViewEventsHandler: AnyObject { func chartViewDataSetChanged() } final class TimelineChartView: BarLineChartViewBase { … var timelinePosition: CGFloat = 0 { didSet { setupXAsix() } } weak var eventsHandler: TimelineChartViewEventsHandler? override func notifyDataSetChanged() { super.notifyDataSetChanged() eventsHandler?.chartViewDataSetChanged() } … private func setupXAsix() { … let transformer = getTransformer(forAxis: .left) xAxisRenderer = TimelineChartXAxisRenderer(timelinePosition: timelinePosition, timelineColor: .standardSecondary, viewPortHandler: viewPortHandler, axis: xAxis, transformer: transformer) xAxisRenderer.computeSize() } … } final class DeviceWorkTimelineViewImpl: UIViewController { private lazy var chartView: TimelineChartView = { … chartView.eventsHandler = self … }() … private func setupChartView(viewModel: ViewModel) { chartView.timelinePosition = viewModel.timelinePosition … } … private func scrollToCurrentTime() { guard let timelinePosition = viewModel?.timelinePosition else { return } let offsetX = getScrollOffset(for: timelinePosition) chartScrollView.contentOffset = .init(x: offsetX, y: 0) } private func getScrollOffset(for timelinePosition: CGFloat) -> CGFloat { let offsetInMinutes = max(timelinePosition - Constant.twoHours, 0) let maxWidth = chartView.bounds.size.width - TimelineChartView.axisLabelWidth return (offsetInMinutes / Constant.twentyFourHours) * maxWidth } } // MARK: - TimelineChartViewEventsHandler extension DeviceWorkTimelineViewImpl: TimelineChartViewEventsHandler { func chartViewDataSetChanged() { scrollToCurrentTime() } }
Разворачиваем график… справа налево
Наши продукты уникальны тем, что распространяются в более чем 100 странах на 34 языках (подробнее об том, как мы минимальными усилиями раскатываем наши продукты на такое количество локалей, — в этой хабростатье). А значит, нам нужна в том числе и арабская локализация, главная особенность которой — написание справа налево. И именно в этом направлении нам и нужно развернуть график.
В отличие от элементов UIKit, которые автоматически поддерживают ориентацию справа налево, здесь нам придется действовать самостоятельно. Сделаем простое преобразование — поменяем знак у всех границ интервалов с плюса на минус.
Если у нас был набор интервалов в промежутке от 0 до 24 часов, мы получим симметричный набор в промежутке от –24 часов до 0 часов. У отметки текущего времени тоже изменим знак с плюса на минус. А чтобы на координатной оси не появлялись отрицательные значения, возьмем абсолютное значение для форматирования подписей по горизонтальной оси. У градиента заливки прямоугольников также надо поменять направление.
И в итоге получаем такую картинку!

Для поддержки ориентации справа налево задаем extension для UIView. Также надо внести изменения в коде TimelineInterval, TimelineChartRenderer, TimelineChartView, TimelineMinuteValueFormatter, DeviceWorkTimelineViewImpl.
Разворачиваем график
extension UIView { var isRightToLeftUI: Bool { return UIView.userInterfaceLayoutDirection(for: semanticContentAttribute) == .rightToLeft } var isLeftToRightUI: Bool { return UIView.userInterfaceLayoutDirection(for: semanticContentAttribute) == .leftToRight } } public struct TimelineInterval: Comparable, Equatable { … /// инвертированный интервал для арабской локализации var inverted: TimelineInterval { .init(start: -end, end: -start, type: type) } … } final class TimelineChartRenderer: BarLineScatterCandleBubbleRenderer { … // используется для определения арабской локализации private let isLeftToRightUI: Bool init(dataProvider: BarLineScatterCandleBubbleChartDataProvider, isLeftToRightUI: Bool, animator: Animator, viewPortHandler: ViewPortHandler) { self.dataProvider = dataProvider self.isLeftToRightUI = isLeftToRightUI super.init(animator: animator, viewPortHandler: viewPortHandler) } … private func drawInterval(for entry: TimelineChartDataEntry, with transformer: Transformer, in context: CGContext) { … if let gradientColors = entry.gradientColors { … // для арабской локализации мы меняем направление градиента let startPoint = isLeftToRightUI ? entryRect.bottomLeftCorner : entryRect.bottomRightCorner let endPoint = isLeftToRightUI ? entryRect.topRightCorner : entryRect.topLeftCorner // заполняем прямоугольник градиентом … context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: options) } … } } final class TimelineChartView: BarLineChartViewBase { … // MARK: - Chart private func setupChart() { // задаем кастомный рендеринг данных self.renderer = TimelineChartRenderer(dataProvider: self, isLeftToRightUI: isLeftToRightUI, animator: chartAnimator, viewPortHandler: viewPortHandler) … } private func setupXAsix() { … // для арабской локализации мы инвертируем минимальное и максимальное значение по оси xAxis.axisMinimum = isLeftToRightUI ? Constant.axisMinimum : -Constant.axisMaximum xAxis.axisMaximum = isLeftToRightUI ? Constant.axisMaximum : -Constant.axisMinimum … let transformer = getTransformer(forAxis: .left) let actualTimelinePosition = isLeftToRightUI ? timelinePosition : -timelinePosition xAxisRenderer = TimelineChartXAxisRenderer(timelinePosition: actualTimelinePosition, timelineColor: .standardSecondary, viewPortHandler: viewPortHandler, axis: xAxis, transformer: transformer) xAxisRenderer.computeSize() } … } final class TimelineMinuteValueFormatter: NSObject, AxisValueFormatter { … func stringForValue(_ value: Double, axis: AxisBase?) -> String { // для арабской локализации значения отрицательные, поэтому берем абсолютное значение let timeInterval = abs(value.rounded(.up) * 60) return formatter.string(from: timeInterval) ?? "" } } final class DeviceWorkTimelineViewImpl: UIViewController { … private func setupChartView(viewModel: ViewModel) { var intervals = viewModel.intervals if view.isRightToLeftUI { intervals = intervals .map { $0.inverted } .sorted() } let values: [TimelineChartDataEntry] = intervals.map { .init(timelineInterval: $0) } let dataSet = TimelineChartDataSet(values) chartView.data = ChartData(dataSets: [dataSet]) } … private func getScrollOffset(for timelinePosition: CGFloat) -> CGFloat { let offsetInMinutes: CGFloat if view.isLeftToRightUI { offsetInMinutes = max(timelinePosition - Constant.twoHours, 0) } else { offsetInMinutes = min(Constant.twentyFourHours - timelinePosition, Constant.twentyFourHours - Constant.twoHours) } let maxWidth = chartView.bounds.size.width - TimelineChartView.axisLabelWidth return (offsetInMinutes / Constant.twentyFourHours) * maxWidth } }
Ура, теперь родители могут следить за временем использования девайса своими детьми даже на арабском языке :)
На этом все. Использование open-source-решения позволило нам не отвлекаться на многие сопутствующие детали (линии координатной сетки, подписи по оси). Таким образом мы быстро справились с поставленной задачей!
Финальная версия проекта уже доступна на моей странице GitHub — для тех, кто испытывает сложности с визуализациями и масштабированием. Ну а если у вас есть свой не менее интересный опыт работы с UI-библиотеками (возможно, вы тоже успели поработать с Charts или выбрали Swift Charts от Apple) — жду ваших историй в комментариях!
