iOS16のロック画面にバッテリー残量を表示するウィジェット

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/d2b134da-07af-bb8e-5f14-b6e963ed5619.png)
iOS16よりホーム画面にウィジェットを置けるようになったので、バッテリーの残量をパーセントで表示するアプリを作ってみました。

# 完成イメージ
![Simulator Screen Shot - iPhone 13 mini - 2022-09-17 at 16.10.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/5563c1da-586e-c9a5-b8b2-d38ed92c58cf.png)

追記: AppStoreでアプリを公開しました。(2022/10/4)
アプリ審査時にiOSの標準のバッテリーアプリとインターフェイスが似ているということでリジェクトされてしまったので、デフォルトのデザインを少し変更して審査を通過しました。アプリの設定画面からデザインを選択できるようにしましたので、この記事のようなデザインで表示することもできます。
[![Download_on_the_App_Store_Badge_JP_blk_100317.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/5b3a3261-3e4e-d52a-d64b-2d7805e856b7.png)](https://apps.apple.com/jp/app/battery-widget-%E3%83%90%E3%83%83%E3%83%86%E3%83%AA%E3%83%BC%E8%A1%A8%E7%A4%BA%E3%82%A6%E3%82%A3%E3%82%B8%E3%82%A7%E3%83%83%E3%83%88/id6443512681)


# 作り方

## 環境
- Xcode 14.0
- iOS 16.0

## Xcode プロジェクトの設定
1. Widget Extensionターゲットを追加 
ルート画面のプラスボタンからWidget Extensionを追加します。（File > New > Targetでも同様）
通常のWidgetKitのターゲット追加と同じで手順なのでロック画面特有なものはありません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/b9d921be-b17f-f111-16c3-e87980c6286e.png)
    
1. ターゲット名を設定
Product NameはなんでもOKです。メインターゲットの名前の末尾にWidgetを付けるとわかりやすと思います。
![スクリーンショット 2022-09-17 8.29.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/a9f9eb85-6818-f52f-59ee-469abf3c7427.png)

## 作成ポイント
Xcodeで作成されたスケルトンコードを基本に修正していきます。

### iOS16を判定してホーム画面のウィジェットを有効化 
ホーム画面のウィジェットはiOS16からしか使えないので、iOSのバージョンを判定してサポートファミリーを切り替えます。

```swift
struct BatteryWidget: Widget {
    let kind: String = "BatteryWidget"
    var supportedFamilies: [WidgetFamily] = []
    
    init() {
        if #available(iOSApplicationExtension 16.0, *) {
            supportedFamilies = [.systemSmall, .accessoryCircular, .accessoryRectangular, .accessoryInline]
        } else {
            supportedFamilies = [.systemSmall]
        }
    }
    
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            BatteryWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Bettery Widget")
        .description("This is an example widget.")
        .supportedFamilies(supportedFamilies)
    }
}
```

### WidgetFamilyに応じてビューの形状を判定
環境変数に入っているWidgetFamilyを見てビューの形状を決定します。ロック画面では、accessoryCircular(丸型)、accessoryRectangular(横長)、accessoryInline(1行)が使用できます。

```swift
struct BatteryWidgetEntryView : View {
    var entry: Provider.Entry
    @Environment(\.widgetFamily) var WidgetFamily

    var body: some View {
        switch WidgetFamily {
        case .systemSmall:
            // ホーム画面の小さいウィジェット
            SmallWidgetView(entry: entry)
        case .accessoryCircular:
            // ロック画面の丸型ウィジェット
            CircularWidgetView(entry: entry)
        case .accessoryRectangular:
            // ロック画面の横長のウィジェット
            RectangularWidgetView(entry: entry)
        case .accessoryInline:
            // ロック画面の時計の上のウィジェット
            InlineWidgetView(entry: entry)
        default:
            HStack {
                SmallWidgetView(entry: entry)
                Text(WidgetFamily.description)
            }
            .padding(2)
        }
    }
}
```

### バッテリー状態の取得
[UIDevice](https://developer.apple.com/documentation/uikit/uidevice) を使用してバッテリーレベルと充電状態を取得します。

```swift:BatteryWidget.swift
struct Provider: IntentTimelineProvider {
    :
    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
        // バッテリー状態の取得
        UIDevice.current.isBatteryMonitoringEnabled = true
        let batteryLevel = UIDevice.current.batteryLevel
        let batteryState = UIDevice.current.batteryState
        UIDevice.current.isBatteryMonitoringEnabled = false
        
        // 15分おきに更新
        let entry = SimpleEntry(date: Date(), configuration: configuration, batteryLevel: batteryLevel, batteryState: batteryState)
        let nextDate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
        let timeline = Timeline(entries: [entry], policy: .after(nextDate))
        completion(timeline)
    }
}
```

### ウィジェットを最新の状態に維持
ウジェットの更新タイミングはTimelineで指定した時刻通になかなか更新されず、実際にはシステム依存で気まぐれにしか更新されません。そこでアプリがフォアグラウンドになったタイミングで強制的にウィジェットを更新するようにWidgetCenterを使用します。reloadTimelinesメソッドのofKindパラメータは、ウジェット生成時のWidgetConfigurationの値を合わせておきます。

```swift:BatteryWidget.swift
    // ウィジェットの更新通知
    WidgetCenter.shared.reloadTimelines(ofKind: "com.sample.Battery.BatteryWidget")
```

## 表示例
Xcode 14よりプレビュー画面でウィジェットファミリーを一度に表示できるようになりました。これは便利ですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/ddbe343f-6fde-32ad-463f-627cd4c9752f.png)

# サンプルコード
GitHubにプロジェクト一式を置いてありますので参考にしてください。
https://github.com/yuppejp/battery-ios/

<details>
<summary>BatteryWidget.swift</summary>

```swift:BatteryWidget.swift
import WidgetKit
import SwiftUI
import Intents

struct Provider: IntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationIntent(), batteryLevel: 0.8, batteryState: .unplugged)
    }
    
    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), configuration: configuration, batteryLevel: 0.8, batteryState: .unplugged)
        completion(entry)
    }
    
    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
        // Battery Status
        UIDevice.current.isBatteryMonitoringEnabled = true
        let batteryLevel = UIDevice.current.batteryLevel
        let batteryState = UIDevice.current.batteryState
        UIDevice.current.isBatteryMonitoringEnabled = false
        
        // debug
        if batteryLevel == -1 {
            let batteryLevel: Float = 0.8
            let batteryState = UIDevice.BatteryState.charging
            let entry = SimpleEntry(date: Date(), configuration: configuration, batteryLevel: batteryLevel, batteryState: batteryState)
            let nextDate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
            let timeline = Timeline(entries: [entry], policy: .after(nextDate))
            completion(timeline)
            return
        }

        let entry = SimpleEntry(date: Date(), configuration: configuration, batteryLevel: batteryLevel, batteryState: batteryState)
        let nextDate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
        let timeline = Timeline(entries: [entry], policy: .after(nextDate))
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
    
    let batteryLevel: Float
    let batteryState: UIDevice.BatteryState
    
    var discription: String {
        let discription: String
        
        switch batteryState {
        case .unknown: discription = "不明"
        case .unplugged: discription = "未接続"
        case .charging: discription = "充電中"
        case .full: discription = "満充電"
        default:  discription = "不明すぎ"
        }
        return discription
    }
}

struct BatteryWidgetEntryView : View {
    var entry: Provider.Entry
    @Environment(\.widgetFamily) var WidgetFamily

    var body: some View {
        switch WidgetFamily {
        case .systemSmall:
            // ホーム画面の小さいウィジェット
            SmallWidgetView(entry: entry)
        case .accessoryCircular:
            // ロック画面の丸型ウィジェット
            CircularWidgetView(entry: entry)
        case .accessoryRectangular:
            // ロック画面の横長のウィジェット
            RectangularWidgetView(entry: entry)
        case .accessoryInline:
            // ロック画面の時計の上のウィジェット
            InlineWidgetView(entry: entry)
        default:
            HStack {
                SmallWidgetView(entry: entry)
                Text(WidgetFamily.description)
            }
            .padding(2)
        }
    }
}

@main
struct BatteryWidget: Widget {
    let kind: String = "com.sample.Battery.BatteryWidget"
    var supportedFamilies: [WidgetFamily] = []
    
    init() {
        if #available(iOSApplicationExtension 16.0, *) {
            supportedFamilies = [.systemSmall, .accessoryCircular, .accessoryRectangular, .accessoryInline]
        } else {
            supportedFamilies = [.systemSmall]
        }
    }
    
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            BatteryWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Bettery Widget")
        .description("This is an example widget.")
        .supportedFamilies(supportedFamilies)
    }
}

@available(iOSApplicationExtension 16.0, *)
struct BatteryWidget_Previews: PreviewProvider {
    static var previews: some View {
        BatteryWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), batteryLevel: 0.8, batteryState: .charging))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}
```

</details>


<details>
<summary>SmallWidgetView.swift</summary>

```swift:SmallWidgetView.swift
import SwiftUI

struct SmallWidgetView : View {
    var entry: Provider.Entry

    var body: some View {
        ZStack {
            RingProgressView(value: Double(entry.batteryLevel))
            VStack {
                Image(systemName: "iphone")
                    .imageScale(.large)
                //Text(entry.batteryLevel, format: FloatingPointFormatStyle.Percent())
                //    .font(.title)
                Text(entry.batteryLevel.percentString)
                    .font(.title)
                Text(entry.date, style: .time)
                    .font(.caption)
            }
        }
        .padding(12)
    }
}

struct CircularWidgetView : View {
    var entry: Provider.Entry

    var body: some View {
        ZStack {
            RingProgressView(value: Double(entry.batteryLevel), lineWidth: 6.0)
            VStack {
                Image(systemName: "iphone")
                    .imageScale(.medium)
                Text(entry.batteryLevel.percentString)
                    .font(.caption2)
            }
        }
    }
}

struct RectangularWidgetView : View {
    var entry: Provider.Entry

    var body: some View {
        HStack {
            ZStack {
                RingProgressView(value: Double(entry.batteryLevel), lineWidth: 6.0)
                VStack {
                    Image(systemName: "iphone")
                        .imageScale(.large)
                }
            }
            VStack {
                Text(entry.batteryLevel.percentString)
                    .font(.body)
                    .frame(maxWidth: .infinity, alignment: .leading)
                Text(entry.discription)
                    .font(.body)
                    .frame(maxWidth: .infinity, alignment: .leading)
                Text(entry.date, style: .time)
                    .font(.caption)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
    }
}

struct InlineWidgetView : View {
    var entry: Provider.Entry

    var body: some View {
        HStack(spacing: 0) {
            Image(systemName: "iphone")
                .imageScale(.large)
            Text(entry.batteryLevel.percentString)
                .font(.caption)
        }
    }
}
```

</details>


<details>
<summary>RingProgressView.swift</summary>

```swift:RingProgressView.swift
import SwiftUI

struct RingProgressView: View {
    var value: Double
    var lineWidth: CGFloat = 12.0
    var monochrome: Bool = false
    var outerRingColor: Color = Color.gray.opacity(0.3)
    var innerRingColor: Color {
        if monochrome {
            return Color.white
        } else {
            if value < 0.1 {
                return Color.red
            } else if value < 0.25 {
                return Color.yellow
            } else {
                return Color.green
            }
        }
    }

    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: lineWidth)
                .foregroundColor(outerRingColor)
            Circle()
                .trim(from: 0, to: min(max(0.001, value), 1.0))
                .stroke(
                    style: StrokeStyle(
                        lineWidth: lineWidth,
                        lineCap: .round,
                        lineJoin: .round
                    )
                )
                .foregroundColor(innerRingColor)
                .rotationEffect(.degrees(-90.0))
        }
        .padding(.all, lineWidth / 2)
    }
}

struct RingProgressView_Previews: PreviewProvider {
    static var previews: some View {
        RingProgressView(value: 0.8, lineWidth: 8.0)
    }
}

extension Float {
    // Textビューの標準フォーマッターだと小数点が出てしまうので整数で表示する
    var percentString: String {
        let f = NumberFormatter()
        f.numberStyle = .percent
        return f.string(from: NSNumber(value: self)) ?? "\(self)"
    }
}
```

</details>

# 使用例
Apple Watchのバッテリー表示の横に置いておくと便利かも。 

![IMG_6664.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/287f0ecf-3e71-629a-6a0e-5c4a6747b8ba.png)

おしまい！

# 参考サイト
各記事を参考にさせていただきました。
- [iOS16新機能ロック画面ウィジェットの対応方法 - Qiita](https://qiita.com/narikei/items/bbcc8573e99da63c8e0e)
- [SwiftUIで作るプログレス - Qiita](https://qiita.com/Masataka-n/items/6f7ca52e5acfe74af453)
- [ウィジェットを最新の状態に維持 - Apple Developper](https://developer.apple.com/jp/documentation/widgetkit/keeping-a-widget-up-to-date/)