DEV Community

Duncan Kent
Duncan Kent

Posted on

Recreating the iOS Battery Graph using Swift Charts

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

IMG_7C36B9B7D908-1.jpeg

IMG_392B57DD5699-1.jpeg

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 }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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))
...
}
Enter fullscreen mode Exit fullscreen mode

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))
...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The produced view:

Image.png

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)

            }
          }
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

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)")
          }
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

These implementations give us the following view:

Image.png

Image.png

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)
    }
Enter fullscreen mode Exit fullscreen mode

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)
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

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))
          }
Enter fullscreen mode Exit fullscreen mode

All days have an AxisValueLabel that contains the first letter of the day.

After implementing the axes, we have the following resulting graph:

Image.png

Image.png

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!

Top comments (0)