Recreating the iOS Battery Graph using Swift Charts
Published: 2022-07-08 12:29:17

## 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](https://res.craft.do/user/full/5574b7b0-0074-1658-a796-9f69587e08c1/doc/55353A9A-8F39-4277-B6CF-2EB4B4B9FE9F/070E0677-1B80-4357-8D88-94EE05F9EA9F_2/FSCeq5n9lMxbxQycXiun8mcGuZBsqzqKTAdhy1hKLZsz/IMG_7C36B9B7D908-1.jpeg)

![IMG_392B57DD5699-1.jpeg](https://res.craft.do/user/full/5574b7b0-0074-1658-a796-9f69587e08c1/doc/55353A9A-8F39-4277-B6CF-2EB4B4B9FE9F/02CC2B39-FFA1-4AF1-B78F-D4587182C1E3_2/tDcSX04II2S76F3GITBykfkPL0qG4VUCdtved6o0jgQz/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.

```swift
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.

```swift
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*

```swift
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*

```swift
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.

```swift
@State private var currentBatteryDataTime: BatteryDataTimes = .day10
```

I used an enum to store the separate cases and strings used in the interface:

```swift
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.

```swift
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:*

![Image.png](https://res.craft.do/user/full/5574b7b0-0074-1658-a796-9f69587e08c1/doc/55353A9A-8F39-4277-B6CF-2EB4B4B9FE9F/26051296-035A-4792-AA86-E0F8AEC8371E_2/BlxmhJRXlrK5yKucB6y98PasbeESx0BSxG3nU8xM44oz/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.

```swift
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.

```swift
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.

```swift
.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.

```swift
.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:

![Image.png](https://res.craft.do/user/full/5574b7b0-0074-1658-a796-9f69587e08c1/doc/55353A9A-8F39-4277-B6CF-2EB4B4B9FE9F/19330E46-729C-4F11-9F41-08D129D249E9_2/rb9vUM0dYODSFrELK7daMfEW6U8ZMsWQfamGS5ixSX4z/Image.png)

![Image.png](https://res.craft.do/user/full/5574b7b0-0074-1658-a796-9f69587e08c1/doc/55353A9A-8F39-4277-B6CF-2EB4B4B9FE9F/B8576B32-75FE-4BAA-9ECB-5852EA258788_2/u2myAk1edPRrAKsWPWN7AOTERNLs1SihyY3AMKokrPYz/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`

```swift
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]`.

```swift
.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).

```swift
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 A`xisValueLabel` that contains the first letter of the day.

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

![Image.png](https://res.craft.do/user/full/5574b7b0-0074-1658-a796-9f69587e08c1/doc/55353A9A-8F39-4277-B6CF-2EB4B4B9FE9F/714A6022-DA98-427A-8391-3CEB1BC62F07_2/4CyGb4BtA2C6UJGM6TJC60E0d6Hcou0Bymxf9i7oBcMz/Image.png)

![Image.png](https://res.craft.do/user/full/5574b7b0-0074-1658-a796-9f69587e08c1/doc/55353A9A-8F39-4277-B6CF-2EB4B4B9FE9F/15D99359-FB54-4497-8667-E5C64CDD6AC9_2/X1M3dKCjtOcaFyCbex2rYxZQy15Fw0wfzCrYpSjiKU0z/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!*