Привет, Хабр! Меня зовут Валентин Страздин, я — ведущий 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) — жду ваших историй в комментариях!