Подходы к созданию линейных графиков для iOS-приложений на базе фреймворка SwiftUI.
Важным компонентом разработки мобильных приложений является создание информативных графиков и диаграмм. Визуальное представление данных имеет решающее значение для простого и лаконичного донесения сложной информации до пользователей.
Хотя SwiftUI оснащен мощным набором инструментов для создания экранов и пользовательских интерфейсов, до iOS 16 Apple не предоставляла нативного фреймворка для работы с графиками. Конечно, это не означае
Подходы к созданию линейных графиков для iOS-приложений на базе фреймворка SwiftUI...
Важным компонентом разработки мобильных приложений является создание информативных графиков и диаграмм. Визуальное представление данных имеет решающее значение для простого и лаконичного донесения сложной информации до пользователей.
Хотя SwiftUI оснащен мощным набором инструментов для создания экранов и пользовательских интерфейсов, до iOS 16 Apple не предоставляла нативного фреймворка для работы с графиками. Конечно, это не означает, что отсутствовала возможность создавать приложения с графиками и диаграммами. Существовало два пути: можно было создавать графики нативно (с помощью структуры Shapes) или использовать сторонние фреймворки.
Вот несколько способов, с помощью которых реализовывали графики до iOS 16.
- Разработка графических представлений нативно с использованием структуры Shapes (или Path) позволяет создавать фигуры и объекты с любыми геометрическими свойствами, настраивать анимацию графических объектов и разделять пользовательский интерфейс на множество мелких компонентов. Однако этот вариант имеет свои недостатки: создание сложных форм может оказаться сложным процессом, требующим значительного объема кода, что потенциально усложняет разработку. Path и Shapes могут не обладать всей функциональностью стандартных графических фреймворков, поэтому некоторые функции придется реализовывать отдельно.
- Использование сторонних фреймворков позволяет сэкономить время разработки и получить относительно безопасную и проверенную кодовую базу (ведь код уже много раз использовался в других проектах). Однако и здесь есть свои минусы: зависимость от стороннего фреймворка и его последующее обновление после обнаружения критических ошибок или выхода новых версий 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 и получаем график ломаной линии с точками для каждого значения:
Важно отображать оси координат 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)
}
}
Получаем следующий результат:
Итак, мы протестировали 3 различных варианта построения диаграмм в среде SwiftUI. Такая простая задача, как создание графика в SwiftUI, требует тщательного анализа с учетом:
- минимальной версии iOS;
- сложности дизайна;
- количества графиков;
- времени, отведенного на разработку;
- возможности частых изменений дизайна в будущем и т. д.
Мы создали примитивную диаграмму, но даже такой простой дизайн дает представление о сложностях, которые могут возникнуть в будущем у iOS-разработчиков при построении диаграмм с использованием фреймворка SwiftUI.