Как стать автором
Обновить
«Лаборатория Касперского»
Ловим вирусы, исследуем угрозы, спасаем мир

Как мы «рисовали» учет времени на iOS с помощью библиотеки Charts

Уровень сложностиСредний
Время на прочтение15 мин
Количество просмотров633
Привет, Хабр! Меня зовут Валентин Страздин, я — ведущий iOS-разработчик в команде мобильной разработки «Лаборатории Касперского», где мы создаем решения для защиты мобильных устройств и для родительского контроля детских устройств.

Недавно мы обновляли наше приложение родительского контроля и мониторинга Kaspersky Safe Kids — и в ходе редизайна пришли к выводу, что «родительские» графики активности детского устройства стали громоздкими и неудобными. Нам же требовался виджет, в котором была бы видна актуальная информация о времени использования устройства, интервалах блокировок и еще ряд полезных деталей.

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

image

Текст будет полезен 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) — жду ваших историй в комментариях!
Теги:
Хабы:
+5
Комментарии0

Публикации

Информация

Сайт
www.kaspersky.ru
Дата регистрации
Дата основания
Численность
5 001–10 000 человек
Местоположение
Россия

Истории