qnote Advent Calendar 2023 の2日目です。
はじめに
iPhone14ProからDynamic Islandが搭載されてiPhone15では全機種に搭載されています。
iPhone15に変えてDynamic Islandがあるので何かできないかと思っていたらMacのステータスバーにRuncatなるものがあるのを思い出し似たようなものを表示させたり常駐できないかなと模索してそれっぽいものができたので記事にしました。
出来上がったもの
恐らくアプリが終了するまでは動き続けるのかと思います。
シミュレーターだとカクついたり実機の画面収録だとDynamic Islandが消えてしまったのでQuickTimePlayer経由で録画してます。
プロジェクトファイルの作成
Xcode14.0、iOS17.1.1で確認
Dynamic Islandに表示させるまでのプロジェクトの作成プロセスはこちらのサイトで画像付きでわかりやすく解説してくれているので割愛させて頂きます。
実装時で困った点
・LiveActivityのアニメーション表示1
あまりActivityKitの情報がなかったので最初にWidgetのターゲット内でViewModelを作成してそこで画像を更新していけばアニメーションっぽく表示できるでしょう!(カタカタ)
Runポチッ 実行されてホーム画面に戻る・・・が画像が表示されるが更新されない
どうやらLiveActivityを更新するにはメインターゲットでActivityのupdateメソッドにて更新をするようにしないといけない
・LiveActivityのアニメーション表示2
Widgetの方でUIKit使ってアニメーションしてみよう(カタカタ)
Runポチッ 実行されてホーム画面に戻る・・・がViewが表示されるが更新されない
どうやらSwiftUIのWidgetはSwiftUIのViewしか表示することができないみたいです。
Widgets can’t use UIKit or AppKit views wrapped with UIViewRepresentable or NSViewRepresentable.
https://developer.apple.com/documentation/widgetkit/swiftui-views
・バックグラウンドでのアニメーション処理の継続
アニメーション処理をメインターゲットでTimerを使い定期的にupdateを実行するようにしたがDynamic Islandはアプリがバックグラウンド状態(かAppSwitcherを表示している状態)で表示されるためバックグラウンド状態にすると数秒でアプリの処理が止まってしまう
→beginBackgroundTaskで処理を継続
以下それっぽく動いたコード
画像はRunCat_for_windowsからお借りしています。
・メインターゲットのコード
@main
struct DynamicIslandRunnerApp: App {
@UIApplicationDelegateAdaptor (AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
var backgroundTaskID: UIBackgroundTaskIdentifier?
var oldBackgroundTaskID: UIBackgroundTaskIdentifier?
var timer: Timer?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
NotificationCenter.default.addObserver(self, selector: #selector(willResignActive), name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
return true
}
@objc func willResignActive(_ notification: Notification) {
let application = UIApplication.shared
backgroundTaskID = application.beginBackgroundTask { [weak self] in
guard let self, let backgroundTaskID = self.backgroundTaskID else {
return
}
application.endBackgroundTask(backgroundTaskID)
self.backgroundTaskID = nil
}
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true, block: { [weak self] _ in
guard let self else {
return
}
self.oldBackgroundTaskID = self.backgroundTaskID
// 新しいタスクを登録
self.backgroundTaskID = application.beginBackgroundTask {
guard let backgroundTaskID = self.backgroundTaskID else {
return
}
application.endBackgroundTask(backgroundTaskID)
self.backgroundTaskID = nil
}
// 前のタスクを削除
if let oldBackgroundTaskID = self.oldBackgroundTaskID {
application.endBackgroundTask(oldBackgroundTaskID)
}
})
}
@objc func didBecomeActive(_ notification: Notification) {
timer?.invalidate()
if let backgroundTaskID = self.backgroundTaskID {
UIApplication.shared.endBackgroundTask(backgroundTaskID)
}
}
}
バックグラウンドで動かし続けるためにAppDelegateでbeginBackgroundTaskを繰り返し呼ぶようにしています。
処理はこちらの記事を参考にさせていただきました。
import SwiftUI
import ActivityKit
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
let viewModel: ContentViewModel = ContentViewModel()
var body: some View {
VStack(spacing: 20) {
Button("start", action: {
viewModel.addLiveActivity()
})
Button("stop", action: {
viewModel.stopLiveActivity()
})
}
.onChange(of: scenePhase) { oldValue, newValue in
switch newValue {
case .active:
viewModel.stopTimer()
case .background:
break
case .inactive:
if oldValue == .active {
// バックグラウンドへ遷移前(App Switcher)
if viewModel.enableAnimation {
viewModel.startTimer()
}
} else if oldValue == .background {
// フォアグラウンドへ遷移前(App Switcher)
viewModel.stopTimer()
}
@unknown default:
break
}
}
}
}
class ContentViewModel: ObservableObject {
private var activity: Activity<RunnerWidgetAttributes>?
private var images: [UIImage] = []
private var index: Int = 0
private(set) var enableAnimation: Bool = true
private var timer: Timer?
private var taskIdentifier: UIBackgroundTaskIdentifier?
init() {
self.images = [
getImage(index: 0),
getImage(index: 1),
getImage(index: 2),
getImage(index: 3),
getImage(index: 4)
]
}
func addLiveActivity() {
self.enableAnimation = true
let attributes = RunnerWidgetAttributes()
let initialState = RunnerWidgetAttributes.ContentState(image: getCurrentImage())
let content = ActivityContent(state: initialState, staleDate: nil, relevanceScore: 1.0)
do {
self.activity = try Activity.request(attributes: attributes, content: content)
} catch {
print(error.localizedDescription)
}
}
func updateLiveActivity() async {
guard let activity else {
return
}
await activity.update(.init(state: .init(image: getCurrentImage()), staleDate: nil))
}
func stopLiveActivity() {
self.enableAnimation = false
Activity<RunnerWidgetAttributes>.activities.forEach { activity in
Task {
await activity.end(activity.content)
}
}
}
func getCurrentImage() -> UIImage {
return self.images[self.index]
}
func startTimer() {
taskIdentifier = UIApplication.shared.beginBackgroundTask { [weak self] in
guard let self, let taskIdentifier = self.taskIdentifier else {
return
}
UIApplication.shared.endBackgroundTask(taskIdentifier)
}
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(update), userInfo: nil, repeats: true)
self.timer?.fire()
}
func stopTimer() {
self.timer?.invalidate()
}
private func getImage(index: Int) -> UIImage {
guard let data = NSDataAsset(name: "dark_cat_\(index)"),
let image = UIImage(data: data.data) else {
return UIImage()
}
return image
}
@objc private func update() {
guard enableAnimation else {
return
}
updateIndex()
Task {
await updateLiveActivity()
}
}
private func updateIndex() {
var nextIndex = index + 1
if nextIndex == images.count {
nextIndex = 0
}
self.index = nextIndex
}
}
ScenePhaseでアプリのライフサイクルをハンドリングしてバックグラウンドに行くタイミングでTimerを開始して画像をActivityに渡して(update)あげるようにしています。
・ウィジェットターゲットのコード
import ActivityKit
import WidgetKit
import SwiftUI
struct RunnerWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var image: UIImage
init(image: UIImage) {
self.image = image
}
enum CodingKeys: CodingKey {
case image
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.image.base64String, forKey: .image)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let base64String = try container.decode(String.self, forKey: .image)
self.image = UIImage(base64String: base64String) ?? UIImage()
}
}
}
struct RunnerWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: RunnerWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
EmptyView()
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
EmptyView()
}
DynamicIslandExpandedRegion(.trailing) {
EmptyView()
}
DynamicIslandExpandedRegion(.bottom) {
EmptyView()
}
} compactLeading: {
Image(uiImage: context.state.image)
} compactTrailing: {
EmptyView()
} minimal: {
EmptyView()
}
}
.supportedFamilies([.systemSmall])
}
}
extension UIImage {
convenience init?(base64String: String) {
guard let data = Data(base64Encoded: base64String) else {
return nil
}
self.init(data: data)
}
var base64String: String? {
guard let imageData = self.pngData() else {
return nil
}
return imageData.base64EncodedString()
}
}
メインターゲットのActivityのupdateしたものはActivityConfigurationの引数のcontextに入ってくるので渡された画像を表示するだけになっています。
Timerのインターバルを変えれば再描画速度が変わるのでアニメーションの速度が変わります。
ActivityAttributesのCodableでUIImageをそのままエンコード、デコードできないため一回base64に変換して扱うようにしています。
最後に
基本的にDynamic Islandはアプリの簡易的な情報を表示するだけなものだと思うので無駄に動いていて実用性はないのでネタ記事になります。
もしどうしてもカスタムアニメーションを表示させたいという方の参考にでもなれば