18
5

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

Last updated at Posted at 2023-12-01

qnote Advent Calendar 2023 の2日目です。

はじめに

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

※Dynamic Islandはこんな感じ
38f1a46c5b3ab31bb0e23574e3d87669.png

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

出来上がったもの

DynamicIslandRunner.gif
恐らくアプリが終了するまでは動き続けるのかと思います。
シミュレーターだとカクついたり実機の画面収録だと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はアプリの簡易的な情報を表示するだけなものだと思うので無駄に動いていて実用性はないのでネタ記事になります。
もしどうしてもカスタムアニメーションを表示させたいという方の参考にでもなれば

18
5
0

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
18
5