Dynamic Islandにアニメーション(風のパラパラ漫画)を表示

[qnote Advent Calendar 2023](https://qiita.com/advent-calendar/2023/qnote) の2日目です。

## はじめに
iPhone14ProからDynamic Islandが搭載されてiPhone15では全機種に搭載されています。

※Dynamic Islandはこんな感じ
![38f1a46c5b3ab31bb0e23574e3d87669.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149731/f4ef76d8-f02d-4048-82bb-837aef13fc94.png)

iPhone15に変えてDynamic Islandがあるので何かできないかと思っていたらMacのステータスバーに[Runcat](https://kyome.io/runcat/)なるものがあるのを思い出し似たようなものを表示させたり常駐できないかなと模索してそれっぽいものができたので記事にしました。

## 出来上がったもの
![DynamicIslandRunner.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149731/e89527a6-df19-b6e7-78bf-78bdec080646.gif)
恐らくアプリが終了するまでは動き続けるのかと思います。
シミュレーターだとカクついたり実機の画面収録だとDynamic Islandが消えてしまったのでQuickTimePlayer経由で録画してます。

## プロジェクトファイルの作成

Xcode14.0、iOS17.1.1で確認

Dynamic Islandに表示させるまでのプロジェクトの作成プロセスは[こちら](https://zenn.dev/aki_artisan/articles/3c4548615965a6)のサイトで画像付きでわかりやすく解説してくれているので割愛させて頂きます。

## 実装時で困った点
・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](https://github.com/Kyome22/RunCat_for_windows)からお借りしています。

・メインターゲットのコード
```swift
@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を繰り返し呼ぶようにしています。
処理は[こちら](http://harumi.sakura.ne.jp/wordpress/2019/05/23/ios%E3%83%90%E3%83%83%E3%82%AF%E3%82%B0%E3%83%A9%E3%82%A6%E3%83%B3%E3%83%89%E3%81%A7%E9%95%B7%E6%99%82%E9%96%93ble%E9%80%9A%E4%BF%A1%E7%B6%9A%E3%81%91%E3%82%8B%E6%96%B9%E6%B3%95/)の記事を参考にさせていただきました。

```swift
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)あげるようにしています。

・ウィジェットターゲットのコード

```swift
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はアプリの簡易的な情報を表示するだけなものだと思うので無駄に動いていて実用性はないのでネタ記事になります。  
もしどうしてもカスタムアニメーションを表示させたいという方の参考にでもなれば