iOS16よりホーム画面にウィジェットを置けるようになったので、バッテリーの残量をパーセントで表示するアプリを作ってみました。
完成イメージ
追記: AppStoreでアプリを公開しました。(2022/10/4)
アプリ審査時にiOSの標準のバッテリーアプリとインターフェイスが似ているということでリジェクトされてしまったので、デフォルトのデザインを少し変更して審査を通過しました。アプリの設定画面からデザインを選択できるようにしましたので、この記事のようなデザインで表示することもできます。
作り方
環境
- Xcode 14.0
- iOS 16.0
Xcode プロジェクトの設定
-
Widget Extensionターゲットを追加
ルート画面のプラスボタンからWidget Extensionを追加します。(File > New > Targetでも同様)
通常のWidgetKitのターゲット追加と同じで手順なのでロック画面特有なものはありません。
-
ターゲット名を設定
Product NameはなんでもOKです。メインターゲットの名前の末尾にWidgetを付けるとわかりやすと思います。
作成ポイント
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 を使用してバッテリーレベルと充電状態を取得します。
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の値を合わせておきます。
// ウィジェットの更新通知
WidgetCenter.shared.reloadTimelines(ofKind: "com.sample.Battery.BatteryWidget")
表示例
Xcode 14よりプレビュー画面でウィジェットファミリーを一度に表示できるようになりました。これは便利ですね。
サンプルコード
GitHubにプロジェクト一式を置いてありますので参考にしてください。
https://github.com/yuppejp/battery-ios/
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
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
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のバッテリー表示の横に置いておくと便利かも。
おしまい!
参考サイト
各記事を参考にさせていただきました。