12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-09-17

image.png
iOS16よりホーム画面にウィジェットを置けるようになったので、バッテリーの残量をパーセントで表示するアプリを作ってみました。

完成イメージ

Simulator Screen Shot - iPhone 13 mini - 2022-09-17 at 16.10.33.png

追記: AppStoreでアプリを公開しました。(2022/10/4)
アプリ審査時にiOSの標準のバッテリーアプリとインターフェイスが似ているということでリジェクトされてしまったので、デフォルトのデザインを少し変更して審査を通過しました。アプリの設定画面からデザインを選択できるようにしましたので、この記事のようなデザインで表示することもできます。
Download_on_the_App_Store_Badge_JP_blk_100317.png

作り方

環境

  • Xcode 14.0
  • iOS 16.0

Xcode プロジェクトの設定

  1. Widget Extensionターゲットを追加
    ルート画面のプラスボタンからWidget Extensionを追加します。(File > New > Targetでも同様)
    通常のWidgetKitのターゲット追加と同じで手順なのでロック画面特有なものはありません。
    image.png

  2. ターゲット名を設定
    Product NameはなんでもOKです。メインターゲットの名前の末尾にWidgetを付けるとわかりやすと思います。
    スクリーンショット 2022-09-17 8.29.01.png

作成ポイント

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

iOS16を判定してホーム画面のウィジェットを有効化

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

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行)が使用できます。

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 を使用してバッテリーレベルと充電状態を取得します。

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の値を合わせておきます。

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

表示例

Xcode 14よりプレビュー画面でウィジェットファミリーを一度に表示できるようになりました。これは便利ですね。
image.png

サンプルコード

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

BatteryWidget.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))
    }
}
SmallWidgetView.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)
        }
    }
}
RingProgressView.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)"
    }
}

使用例

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

IMG_6664.PNG

おしまい!

参考サイト

各記事を参考にさせていただきました。

12
7
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?