Recreating the iOS Battery Graph using Swift Charts
Duncan Kent
Posted on July 8, 2022
Apple's Implementation
The battery level / usage graph in iOS provides a visual, graphical overview of your device's battery information in a couple of ways:
- The % amount of battery used on a given day, over a 10 day period
- The battery level of the device over the previous 24 hour time period
Main Components
The main features that both designs boast:
- Chart title describing content
- Toggle to switch between graph views
- Horizontal grid marks at 0, 25, 50 , 75 and 100 percentages
- Y-axis labels for the 0, 50 and 100 percentages
Creating the data points for our graphs
In this small project, I used an enum to store all battery data in a single location, that can be easily accessed for prototyping purposes. Within this, I created a basic struct that would act as a single data point for a plot on the graph. This also conformed to Identifiable
to ensure that this could be iterated on by a SwiftUI view seamlessly.
struct BatteryUsageData: Identifiable {
let batteryDate: Date
let percentageUsed: Float
var id: Date { batteryDate }
}
I extended enum to provide two arrays of data, using the below custom Date initialiser with the device's current calendar.
func date(year: Int, month: Int, day: Int = 1, hour: Int = 0, minutes: Int = 0, seconds: Int = 0) -> Date {
Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minutes, second: seconds)) ?? Date()
}
Function to create date for graph data points
extension BatteryData.BatteryUsageData {
static var previous10days: [Self] = [
.init(batteryDate: date(year: 2022, month: 6, day: 6, hour: 0, minutes: 1), percentageUsed: Float.random(in: 0.2...1.0)),
.init(batteryDate: date(year: 2022, month: 6, day: 5, hour: 0, minutes: 1), percentageUsed: Float.random(in: 0.2...1.0))
...
}
Previous 10 days battery % usage data
extension BatteryData.BatteryUsageData {
static var previous24hours: [Self] = [
.init(batteryDate: date(year: 2022, month: 6, day: 6, hour: 0, minutes: 0), percentageUsed: Float.random(in: 0.9...1.0)),
.init(batteryDate: date(year: 2022, month: 6, day: 6, hour: 0, minutes: 20), percentageUsed: Float.random(in: 0.9...1.0))
...
}
Previous 24 hours battery % data
Setting up the graph picker view
In order to change between the two graph types, we will follow Apple's design using a Picker view.
We will need an @State
variable for the property to be observed and updated by the view.
@State private var currentBatteryDataTime: BatteryDataTimes = .day10
I used an enum to store the separate cases and strings used in the interface:
enum BatteryDataTimes: String, CaseIterable {
case hour24, day10
var batteryDataText: String {
switch self {
case .hour24: return "Last 24 Hours"
case .day10: return "Last 10 Days"
}
}
var batteryGraphTitle: String {
switch self {
case .hour24: return "Battery Level"
case .day10: return "Battery Usage"
}
}
}
Note: Conforming to the String
and CaseIterable
protocols in order to loop over these cases, so that it would be easy to add expand this selection later.
Using a Picker
and iterating over the cases, we can create our picker now. Ensure to pass in the binding to the current value of our @State
property that is storing the current selected graph.
VStack {
Picker("Battery Graph Time", selection: $currentBatteryDataTime) {
ForEach(BatteryDataTimes.allCases, id: \.self) { time in
Text(time.batteryDataText)
}
}
.pickerStyle(.segmented)
.padding(.bottom)
VStack(spacing: 2) {
Text(currentBatteryDataTime.batteryGraphTitle.uppercased())
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
}
.padding(.horizontal, 8)
The produced view:
Displaying different graph views
We can add a switch statement between our graph title Text
and the Spacer
in our VStack
in order to switch between views dependent on the currently selected timeframe in our picker. A frame
modifier provides a maximum height for the graph.
switch currentBatteryDataTime {
case .day10:
// Graph showing 10 day battery use
case .hour24:
// Graph showing 24 hour battery level
}
.frame(height: 180)
24 Hour Battery Level Graph
Plotting the data points is relatively straightforward for this bar graph. You provide the data source for the chart and iterate over each data point assigning an x and y value. In this graph, I have also opted to provide a width for the bar to ensure there is space between values.
Chart(BatteryData.BatteryUsageData.previous24hours) { batteryData in
BarMark (
x: .value("Date", batteryData.batteryDate),
y: .value("Battery Percent", batteryData.percentageUsed),
width: .fixed(3.5)
)
.foregroundStyle(Color.green.gradient)
}
Axes
The x-axis of this graph has intervals every 3 hours. To achieve this, we will add AxisMarks
with values that stride by the hour.
We convert the value back to a date using the current calendar, and extract the hour date component from it. We can then add labels where hour % 3
gives no remainder.
.chartXAxis {
AxisMarks(values: .stride(by: .hour)) { value in
if let date = value.as(Date.self) {
let calendar = Calendar.current
let currentHour = calendar.dateComponents([.hour], from: date)
if currentHour.hour! % 3 == 0 {
AxisGridLine()
AxisTick()
AxisValueLabel {
Text("\(date, format: .dateTime.hour())")
.padding(.top, 16)
}
}
}
}
}
We can take a similar approach when creating the y-axis using stride, however this time we stride from 0 to 1 in increments of 0.25. This will allow us to match Apple's implementation of having an AxisGridLine
every 25%
By converting the value back to a Float
we can check if the truncatingRemainder
when dividing by 0.5 is 0. This will allow us to add an AxisValueLabel
at 0%, 50% and 100% only.
.chartYAxis {
AxisMarks(values: .stride(by: 0.25)) { value in
AxisGridLine()
if let percentage = value.as(Float.self) {
if percentage.truncatingRemainder(dividingBy: 0.5) == 0 {
AxisValueLabel("\(percentage, format: .percent)")
}
}
}
}
These implementations give us the following view:
Note: You could also use a DateFormatter
to match the X axis labels exactly to Apple's implementation (e.g. 15 instead of 3 PM)
Previous 10 DaysBattery Usage Graph
Plotting the values for the graph is very similar to the previous example, however, the unit used for the x value in this scenario is .day
instead of .hour
Chart(BatteryData.BatteryUsageData.previous10days) { batteryData in
BarMark(
x: .value("Date", batteryData.batteryDate, unit: .day),
y: .value("Percent Used", batteryData.percentageUsed)
)
.foregroundStyle(Color.green.gradient)
}
Axes
The implementation for the y-axis can match the previous graph, the x-axis is implemented slightly differently however.
We stride through values using the .day
modifier instead of .hour
. We then convert this value to a Date
type and extract the DateComponents
[.weekday, .day, .month]
.
.chartXAxis {
AxisMarks(values: .stride(by: .day)) { value in
AxisGridLine()
if let day = value.as(Date.self) {
let calendar = Calendar.current
let currentDay = calendar.dateComponents([.weekday, .day, .month], from: day)
}
}
}
Apple has a slightly different axis label for Mondays. They use an AxisTick
with a filled line rather than a dotted line, and also add extra information such as the day of the month and current month.
After retrieving the current day, we can then add the following code to check if the current weekday value is 2 (Monday).
if currentDay.weekday == 2 {
if let d = currentDay.day {
AxisTick(stroke: .some(.init(lineWidth: 1)))
AxisValueLabel {
VStack(alignment: .leading) {
Text(day, format: .dateTime.weekday(.narrow))
Text("\(d)")
.offset(y: 2)
Text("\(day, format: .dateTime.month(.abbreviated))")
.offset(y: 2)
}
}
}
} else {
AxisTick()
AxisValueLabel(format: .dateTime.weekday(.narrow))
}
All days have an AxisValueLabel
that contains the first letter of the day.
After implementing the axes, we have the following resulting graph:
Note: the x-axis differs slightly from Apple's implementation as the date becomes truncated if the day of the month is 2 digits - haven't found a great solution for this just yet!
Posted on July 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024