Подходы к созданию линейных графиков для iOS-приложений на базе фреймворка SwiftUI

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

Хотя SwiftUI оснащен мощным набором инструментов для создания экранов и пользовательских интерфейсов, до iOS 16 Apple не предоставляла нативного фреймворка для работы с графиками. Конечно, это не означает, что отсутствовала возможность создавать приложения с графиками и диаграммами. Существовало два пути: можно было создавать графики нативно (с помощью структуры Shapes) или использовать сторонние фреймворки.

Вот несколько способов, с помощью которых реализовывали графики до iOS 16.

  1. Разработка графических представлений нативно с использованием структуры Shapes (или Path) позволяет создавать фигуры и объекты с любыми геометрическими свойствами, настраивать анимацию графических объектов и разделять пользовательский интерфейс на множество мелких компонентов. Однако этот вариант имеет свои недостатки: создание сложных форм может оказаться сложным процессом, требующим значительного объема кода, что потенциально усложняет разработку. Path и Shapes могут не обладать всей функциональностью стандартных графических фреймворков, поэтому некоторые функции придется реализовывать отдельно.
  2. Использование сторонних фреймворков позволяет сэкономить время разработки и получить относительно безопасную и проверенную кодовую базу (ведь код уже много раз использовался в других проектах). Однако и здесь есть свои минусы: зависимость от стороннего фреймворка и его последующее обновление после обнаружения критических ошибок или выхода новых версий iOS; зависимость одних фреймворков от других и их взаимная совместимость; значительное увеличение размера программы.

Рассмотрим различные варианты создания линейных графиков. Для примера возьмем формат обычной линейной диаграммы. На изображении ниже показан график ломаной линии с круглыми точками текущих значений, где по горизонтали отмечены дни недели, а по вертикали  —  варианты настроения (Excellent (отличное), Good (хорошее), Usual (обычное), Terrible (ужасное) по дням.

Нам нужно разработать линейный график с помощью фреймворка SwiftUI (с поддержкой версии iOS 15 и выше). Также нужно минимизировать использование сторонних фреймворков. Учитывая то, что специализированный фреймворк Swift Charts доступен только с версии iOS 16, начнем с нативного способа (через структуру Path).

Метод №1. Фигуры

SwiftUI предоставляет множество мощных инструментов “из коробки”, и Shapes  —  один из них, а инструменты Apple включают Capsule, Circle, Ellipse, Rectangle и RoundedRectangle. Протокол Shape соответствует протоколам Animatable и View, так что у нас есть возможность настраивать их внешний вид и поведение. Но мы также можем создать свою форму, используя структуру Path (контур двумерной фигуры, которую рисуем сами). Протокол Shape содержит важный метод func path(in: CGRect) -> Path: после его реализации мы должны вернуть Path с описанием структуры только что созданной Shape (фигуры).

Начнем с создания структуры LineView, которая принимает массив опциональных значений типа Double? и использует Path для построения графика от каждого предыдущего значения массива к последующему.

struct LineView: View {
let dataPoints: [Double?]
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
Path { path in
path.move(to: CGPoint(x: 0, y: height * self.ratio(for: 0)))

for index in 0..<dataPoints.count {
path.addLine(to: CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: height * self.ratio(for: index)))
}
}
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineJoin: .round))
}
.padding()
}
private func ratio(for index: Int) -> Double {
return 1 - ((dataPoints[index] ?? 0) / 3)
}
}

Для определения размеров границ графика и вычисления соотношений используем GeometryReader, который позволит получить значения высоты и ширины для супервью. Эти значения вместе с методом func ratio(for index: Int) -> Double вычисляют расположение каждой точки на линии путем умножения высоты на отношение отдельной точки данных к самой высокой точке (func ratio(for index: Int)).

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

enum MoodCondition: Double, CaseIterable {
case terrible = 0
case usual
case good
case excellent
var name: String {
switch self {
case .terrible: "Terrible"
case .usual: "Usual"
case .good: "Good"
case .excellent: "Excellent"
}
}
static var statusList: [String] {
return MoodCondition.allCases.map { $0.name }
}
}

Используя перечисление MoodCondition, определим переменную let selectedWeek, которая будет хранить состояния MoodCondition для всех дней недели:

dataPoints = [
MoodCondition.excellent.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.usual.rawValue,
MoodCondition.excellent.rawValue
]

По аналогии со структурой LineView создадим отдельную структуру LineChartCircleView. Указанная структура также принимает массив опциональных значений (let dataPoints), а также дополнительное значение let radius. Структура рисует отдельные круглые точки с радиусом radius также с помощью Path.

struct LineChartCircleView: View {
let dataPoints: [Double?]
let radius: CGFloat
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
Path { path in
path.move(to: CGPoint(x: 0, y: (height * self.ratio(for: 0)) - radius))
path.addArc(center: CGPoint(x: 0, y: height * self.ratio(for: 0)),
radius: radius,
startAngle: .zero,
endAngle: .degrees(360.0),
clockwise: false)
for index in 1..<dataPoints.count {
let point = CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: height * (dataPoints[index] ?? 0) / 3
)
path.move(to: point)
let center = CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: height * self.ratio(for: index)
)
path.addArc(center: center,
radius: radius,
startAngle: .zero,
endAngle: .degrees(360.0),
clockwise: false)
}
}
.foregroundColor(.green)
}
.padding()
}
private func ratio(for index: Int) -> Double {
return 1 - ((dataPoints[index] ?? 0) / 3)
}
}

Накладываем структуру LineChartCircleView на структуру LineView и получаем график ломаной линии с точками для каждого значения:

Комбинирование LineChartCircleView и LineView

Важно отображать оси координат X и Y вместе с кривыми, поэтому начнем с реализации оси Y, а именно с создания структуры YAxisView:

struct YAxisView: View {
var scaleFactor: CGFloat
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
ForEach(MoodCondition.allCases, id: \.rawValue) { condition in
let index = MoodCondition.allCases.firstIndex(of: condition) ?? 0

HStack {
Spacer()
Text(condition.name.capitalized)
.font(Font.headline)
.lineLimit(1)
}
.offset(y: (height * 0.9) - (CGFloat(index) * scaleFactor))
}
}
}
}

Значение переменной scaleFactor будет передано из родительской структуры LineChartView, а модификатор смещения перечислит все возможные MoodCondition в зависимости от значения каждой величины и высоты графика.

Чтобы построить координату X, создадим структуру XAxisView:

struct XAxisView: View {
var body: some View {
GeometryReader { geometry in
let labelWidth = (geometry.size.width * 0.8) / CGFloat(WeekDay.allCases.count + 1)
HStack {
Rectangle()
.frame(width: geometry.size.width * 0.15)
ForEach(WeekDay.allCases, id: \.rawValue) { item in
Text(item.rawValue.capitalized)
.font(Font.headline)
.frame(width: labelWidth)
}
}
}
}
}

Создадим перечисление WeekDay для отображения всех дней недели на оси XaxisView:

enum WeekDay: String, CaseIterable {
case monday = "Mon"
case tuesday = "Tue"
case wednesday = "Wed"
case thursday = "Thu"
case friday = "Fri"
case saturnday = "Sat"
case sunday = "Sun"
}

Чтобы графиком было удобнее пользоваться, добавим горизонтальные пунктирные линии сетки для оси Y, которые будут соответствовать каждому MoodCondition. Для этого создадим отдельную структуру LinesForYLabel:

struct LinesForYLabel: View {
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
Path { path in
let yStepWidth = height / 3
for index in 0...3 {
let y = CGFloat(index) * yStepWidth
path.move(to: .init(x: 0, y: y))
path.addLine(to: .init(x: width, y: y))
}
}
.stroke(style: StrokeStyle(lineWidth: 1, dash: [4]))
.foregroundColor(Color.gray)
}
.padding(.vertical)
}
}

Важно объединить все Views в одну единственную структуру LineChartView, где они будут содержаться одновременно:

  • оси X и Y;
  • график ломаной линии;
  • точки пересечения;
  • горизонтальные пунктирные линии для оси Y.
struct LineChartView: View {
let dataPoints: [Double?]
init() {
dataPoints = [
MoodCondition.excellent.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.usual.rawValue,
MoodCondition.excellent.rawValue
]
}
var body: some View {
GeometryReader { geometry in
let axisWidth = geometry.size.width * 0.23
let fullChartHeight = geometry.size.height
let scaleFactor = (fullChartHeight * 1.15) / CGFloat(MoodCondition.allCases.count)
VStack {
HStack {
YAxisView(scaleFactor: Double(scaleFactor))
.frame(width: axisWidth, height: fullChartHeight)
ZStack {
LinesForYLabel()
LineView(dataPoints: dataPoints)
LineChartCircleView(dataPoints: dataPoints, radius: 4.0)
}
.frame(height: fullChartHeight)
}
XAxisView()
}
}
.frame(height: 200)
.padding(.horizontal)
}
}

С помощью init() инициализируем структуру LineChartView с входными данными для свойства DataPoints посредством MoodCondition для всех дней недели. Расчет значений axisWidth и scaleFactor основан на соотношении значений по оси Y и размере диаграммы; он может меняться в зависимости от особенностей проектирования. Структуры LinesForYLabel(), LineView(dataPoints: dataPoints), LineChartCircleView(dataPoints: dataPoints, radius: 4.0) накладываются друг на друга и помещаются в ZStack. Затем они объединяются с YAxisView(scaleFactor: Double(scaleFactor)) и XAxisView() в HStack/VStack соответственно.

Таким образом, можно разрабатывать любые варианты и комбинации диаграмм. Однако существует взаимозависимость каждого компонента View (представления), например объема кода, сложности поддержки и расширения существующей функциональности.

Метод №2. SwiftUICharts

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

  • Круговые, линейные и столбчатые диаграммы и гистограммы.
  • Различные координатные сетки.
  • Интерактивные метки для отображения текущего значения диаграммы и т. д.

Библиотека доступна в iOS 13 и Xcode 11. Ее можно установить посредством Swift Package Manager или CocoaPods. После добавления SwiftUICharts в проект необходимо импортировать фреймворк с помощью import SwiftUICharts:

import SwiftUI
import SwiftUICharts

struct SwiftUIChartsLibraryView: View {
let chartData: LineChartData

init() {
let dataSet = LineDataSet(
dataPoints: [
LineChartDataPoint(
value: MoodCondition.excellent.rawValue,
xAxisLabel: WeekDay.monday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.terrible.rawValue,
xAxisLabel: WeekDay.tuesday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.good.rawValue,
xAxisLabel: WeekDay.wednesday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.terrible.rawValue,
xAxisLabel: WeekDay.thursday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.good.rawValue,
xAxisLabel: WeekDay.friday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.usual.rawValue,
xAxisLabel: WeekDay.saturnday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.excellent.rawValue,
xAxisLabel: WeekDay.sunday.rawValue.capitalized
)
],
pointStyle: PointStyle(
fillColour: .green,
pointType: .filled,
pointShape: .circle
),
style: LineStyle(
lineColour: ColourStyle(colour: .blue),
lineType: .line
)
)

let gridStyle = GridStyle(
numberOfLines: 4,
lineColour: Color(.lightGray).opacity(0.5),
lineWidth: 1,
dash: [4],
dashPhase: 0
)

let chartStyle = LineChartStyle(
infoBoxPlacement: .infoBox(isStatic: true),
xAxisLabelPosition: .bottom,
xAxisLabelFont: .headline,
xAxisLabelColour: Color.black,
yAxisGridStyle: gridStyle,
yAxisLabelPosition: .leading,
yAxisLabelFont: .headline,
yAxisLabelColour: Color.black,
yAxisNumberOfLabels: 4,
yAxisLabelType: .custom
)

self.chartData = LineChartData(
dataSets: dataSet,
metadata: ChartMetadata(title: "", subtitle: ""),
yAxisLabels: MoodCondition.statusList,
chartStyle: chartStyle
)
}

var body: some View {
LineChart(chartData: chartData)
.pointMarkers(chartData: chartData)
.yAxisGrid(chartData: chartData)
.xAxisLabels(chartData: chartData)
.yAxisLabels(chartData: chartData)
.frame(minWidth: 150,
maxWidth: 350,
minHeight: 100,
idealHeight: 150,
maxHeight: 200,
alignment: .center)
.padding(.horizontal, 24)
}
}

Сначала инициализируем модель let dataSet с входными данными на основе значений из перечисления MoodCondition и перечисления WeekDay. Сразу же настраиваем маркеры точек с помощью pointStyle и модель для управления стилем линий с помощью style. Используем GridStyle с целью настройки представления сетки для диаграммы и LineChartStyle для добавления основных настроек диаграммы.

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

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

Метод №3. Swift Charts

Последний вариант построения диаграммы  —  с фреймворком Swift Charts. Он создает различные типы диаграмм, включая линейные, точечные и столбчатые. Для них автоматически генерируются шкалы и оси, соответствующие исходным данным.

Импортируем фреймворк с помощью import Charts, затем создаем функцию struct Day, которая будет соответствовать определенному дню WeekDay и MoodCondition:

struct Day: Identifiable {
let id = UUID()
let mood: MoodCondition
let day: WeekDay
}

На основе struct Day создадим переменную let currentWeeks, которая будет соответствовать конкретной неделе с соответствующим Day:

let currentWeeks: [Day] = [
Day(mood: .excellent, day: .monday),
Day(mood: .terrible, day: .tuesday),
Day(mood: .good, day: .wednesday),
Day(mood: .terrible, day: .thursday),
Day(mood: .good, day: .friday),
Day(mood: .usual, day: .saturnday),
Day(mood: .excellent, day: .sunday)
]

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

  • LineMark визуализирует данные с помощью последовательности соединенных сегментов.
  • PointMark отображает данные с помощью точек.
struct ChartsView: View {
struct Day: Identifiable {
let id = UUID()
let mood: MoodCondition
let day: WeekDay
}
let currentWeeks: [Day] = [
Day(mood: .excellent, day: .monday),
Day(mood: .terrible, day: .tuesday),
Day(mood: .good, day: .wednesday),
Day(mood: .terrible, day: .thursday),
Day(mood: .good, day: .friday),
Day(mood: .usual, day: .saturnday),
Day(mood: .excellent, day: .sunday)
]
private let weekDayTitle = "Week Day"
private let moodTitle = "Mood"
var body: some View {
VStack {
Chart {
ForEach(currentWeeks) {
LineMark(
x: .value(weekDayTitle, $0.day.rawValue.capitalized),
y: .value(moodTitle, $0.mood.rawValue)
)
PointMark(
x: .value(weekDayTitle, $0.day.rawValue.capitalized),
y: .value(moodTitle, $0.mood.rawValue)
)
.foregroundStyle(.green)
}
}
.chartXAxis {
AxisMarks(preset: .aligned,
position: .bottom
) { value in
AxisValueLabel()
.font(.headline)
.foregroundStyle(.black)
}
}
.chartYAxis {
AxisMarks(preset: .aligned,
position: .leading) { value in
AxisValueLabel {
let day = MoodCondition.statusList[value.index]
Text(day.capitalized)
.font(.headline)
.foregroundColor(.black)
}
AxisGridLine(
stroke: StrokeStyle(
lineWidth: 1,
dash: [4]))
}
}
}
.frame(height: 200)
.padding(.horizontal)
}
}

С помощью ForEach пройдем по всем входным данным currentWeeks и установим значения x, y в LineMark и PointMark соответственно.

В модификаторе .chartXAxis настроим ось с помощью следующих параметров:

  • позиционирование;
  • цвет;
  • Масштаб для оси X.

Это же относится и к chartYAxis, но мы также настраиваем сетку оси Y.

Особенность использования Swift Charts заключается в том, что с помощью различных модификаторов мы можем быстро и без особых усилий создавать множество различных типов диаграмм. Фреймворк прост в использовании, поддерживает анимацию, имеет широкий набор функций для создания и редактирования графиков/диаграмм, а также содержит большое количество материалов по работе с ним.

Сравним варианты построения диаграмм с помощью Shapes, SwiftUIChartsLIbrary и Swift Charts, чтобы провести компаративный анализ:

struct ContentView: View {
var body: some View {
VStack {
VStack {
titleView(title: "A line graph through the Path")
LineChartView()
Spacer()
titleView(title: "A line graph through the SwiftUIChartsLibrary")
SwiftUIChartsLibraryView()
Spacer()
titleView(title: "A line graph through the Charts")
ChartsView()
}
}
}
private func titleView(title: String) -> some View {
Text(title)
.font(Font.headline)
.foregroundColor(.blue)
}
}

Получаем следующий результат:

Создание диаграмм с помощью Shapes, SwiftUIChartsLibrary и Swift Charts

Итак, мы протестировали 3 различных варианта построения диаграмм в среде SwiftUI. Такая простая задача, как создание графика в SwiftUI, требует тщательного анализа с учетом:

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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Oleh Didushok: Approaches to creating line graphs for iOS apps in the SwiftUI framework

Предыдущая статьяЛучшие практики для эффективного кода на Golang. Часть 1
Следующая статьяОчистка операторов импорта TypeScript с помощью псевдонимов путей