SwiftUIで画面表示開始時にAPIからデータ取得を行ったり、デバイスの状態を変更したりするようなことはよくあるかと思いますが、画面表示開始時に実行される処理に関して、サスペンド対策を講じていないことで不具合が発生してしまうシチュエーションがいくつかありました。例えば...
- アプリをサスペンド後、しばらく時間が経過して戻ってきた場合にもAPIに依存したデータが更新されない。
- 画面表示中のみAPIから継続的にデータを購読(ポーリング)するタスクを、画面表示時に
onAppear
で開始、非表示時にonDisappear
で停止するように用意していると、アプリがバックグラウンドの間にも継続的にタスクが動いてしまう。 - 特定の画面を表示したときに
onAppear
で画面の明るさを上げる処理をいれていると、アプリをサスペンドしたときに明るさが戻らない。
SwiftUIのonAppear
・onDisappear
はアプリのサスペンド(iOSホーム画面に戻る等)やアプリへの復帰は検知してくれません。上記不具合も対処のためいろいろと試してみましたが、Viewの表示・非表示だけでなくサスペンド切り替えでも処理が動いてくれるモディファイアを作るといい感じでした。
実装
1. ScenePhase変更時に処理を実行するモディファイアを作成する
SwiftUIでは@Environment(\.scenePhase)
を使うとアプリのサスペンドと復帰を検知することができます。モディファイアも@Environment
の恩恵を受けることができるため、コールバック実行用のモディファイアを作成します。
ステートのisPresented
は一見不要そうなのですが、NavigationViewでの遷移先でもサスペンド復帰時にonChangeが動いてしまうということがあったため、その対処に入れています。
/// アプリサスペンド時にコールバックを実行する
/// - Parameter onSuspend: アプリサスペンド時の処理
/// - Parameter onResume: アプリフォアグラウンド復帰時の処理
struct ScenePhaseReader: ViewModifier {
/// アプリサスペンド時の処理
var onSuspend: (() -> Void)? = nil
/// アプリフォアグラウンド復帰時の処理
var onResume: (() -> Void)? = nil
/// アプリのアクティブ状態
@Environment(\.scenePhase) private var scenePhase
/// アプリがサスペンド状態か
@State private var isSuspended = false
/// 要素が表示中か
@State private var isPresented = false
func body(content: Content) -> some View {
content
.onChange(of: scenePhase) { newScenePhase in
if !isPresented {
return
}
switch newScenePhase {
case .active:
if scenePhase == .inactive && isSuspended {
// アプリフォアグラウンド時
isSuspended = false
onResume?()
}
case .inactive, .background:
if scenePhase == .active && !isSuspended {
// アプリサスペンド時
isSuspended = true
onSuspend?()
}
default: break
}
}
.onAppear {
isPresented = true
}
.onDisappear {
// Viewが非表示になったときに処理を実行しない
isPresented = false
}
}
}
2. Viewのエクステンションとして定義する
そのままでもモディファイアとして使えるのですが、使い回しやすいように一連のハンドラをまとめてエクステンションにします。
extension View {
/// View表示時の処理をアプリサスペンドにも紐づける
/// - Parameter onActive: 画面表示時・アプリフォアグラウンド復帰時の処理
/// - Parameter onInactive: 画面非表示時・アプリサスペンド時の処理
func onActive(_ onActive: @escaping () -> Void, onInactive: (() -> Void)? = nil) -> some View {
return self
.onAppear(perform: onActive)
.onDisappear(perform: onInactive)
.modifier(Component.ScenePhaseReader(onSuspend: onInactive, onResume: onActive))
}
}
3. Viewへの組み込み
これでサスペンド時も含めてViewが画面に表示されたとき、消えたときに処理を実行するモディファイアを使うことができます。
Text("表示を検知したいView")
.onActive {
print("文章表示")
} onInactive: {
print("文章非表示")
}
(蛇足) onAppear
の実行順序を調整したい場合
SwiftUIのonAppear
は画面表示後に処理が実行されるため、ナビゲーション遷移時に前ViewのonDisappear
と次ViewのonAppear
の実行順序が時により前後することがあるようです。これにより、画面遷移に紐づいてグローバルな画面要素の表示を切り替えるなどの操作を行うと、想定しない動作となってしまうことがあります。
実行順を固定するため、UIKitのライフサイクルイベントで処理を実行できるようにUIViewControllerRepresentable
を用意します。
こちらの記事のコードをそのまま流用させていただきました。
1-B. UIKitのライフサイクルで処理を実行するUIViewControllerを作成する
SwiftUIのonAppear
は画面表示後に処理が実行されるため、ナビゲーション遷移時に前ViewのonDisappear
と次ViewのonAppear
の実行順序が時により前後することがあるようです。画面遷移に紐づいて要素をグローバルな画面要素の表示を切り替えるなどの操作を行うと、想定しない動作となることがあります。
実行順を固定するため、UIKitのライフサイクルイベントで処理を実行できるようにUIViewControllerRepresentableを用意します。コードはこちらの記事のコードを利用させていただきました。
/// Viewの表示前・非表示前に処理を実行する
/// ライフサイクルイベントに紐づいた処理を実行するViewController
/// - Parameter onWillAppear: View表示前の処理
private struct UIKitLifeCycleHandler: UIViewControllerRepresentable {
typealias UIViewControllerType = UIViewController
let onWillAppear: () -> Void
init(onWillAppear: @escaping () -> Void) {
self.onWillAppear = onWillAppear
}
func makeCoordinator() -> Self.Coordinator {
Coordinator(onWillAppear: onWillAppear)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> UIViewController {
context.coordinator
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<Self>) {
}
class Coordinator: UIViewController {
let onWillAppear: () -> Void
init(onWillAppear: @escaping () -> Void) {
self.onWillAppear = onWillAppear
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
onWillAppear()
}
}
}
3-B. Viewのエクステンションとして定義する
こちらのタイミングを使う場合はExtensionの定義はこのような感じになります。
extension View {
/// View表示時の処理をアプリサスペンドにも紐づける
/// - Parameter onActive: 画面表示時・アプリフォアグラウンド復帰時の処理
/// - Parameter onInactive: 画面非表示時・アプリサスペンド時の処理
func onActive(_ onActive: @escaping () -> Void, onInactive: (() -> Void)? = nil) -> some View {
return self
.background(onActive != nil ? UIKitLifeCycleHandler(onWillAppear: onActive) : nil)
.onDisappear(perform: onInactive)
.modifier(Component.ScenePhaseReader(onSuspend: onInactive, onResume: onActive))
}
}
奥付
SwiftUIはまだまだナビゲーション周りや、ちょっとしたタイミングなど、おおらかだったりあやしい挙動があったりしますね・・。Viewの構造などによっては想定通り動かないこともあるかもしれないので、使う場合は要テストということでお願いします。