はじめに
筆者(Lumi Studio)が開発・配信している iOS アプリ「LED Moving Text」には、複数のメッセージを順番に表示する「メッセージシーケンス」という機能があります(コード上の名前は Cue Flow)。1 行に 1 件ずつメッセージを書くと、設定した秒数ごとに次のメッセージへ自動で切り替わります。
この自動切り替えは、次のようなときには止まっているべきです。
- アプリがバックグラウンドにいる、または非アクティブ
- 「視差効果を減らす」(Reduce Motion)がオン
- 低電力モードがオン
- 端末の温度状態(
ProcessInfo.thermalState)がserious以上
この記事では、これらの条件を 1 か所で判定し、SwiftUI の .task(id:) を使って「条件が変わったら待ち時間をやり直す」実装を、アプリの実際のコードから紹介します。
環境
- iOS 17.0 以上(Deployment Target)
- Swift 5 言語モード、
SWIFT_STRICT_CONCURRENCY = complete - 引用したコードは、App Store で配信中のバージョン 1.0.1 のものです
1. 動かしてよいかを 1 つの関数で決める
アニメーションのフレームレートと、そもそも動かしてよいかどうかは、MotionPolicy.resolve で決めています。入力をすべて引数で受け取る static 関数なので、端末の状態を用意しなくてもテストできます。
public static func resolve(
surface: MotionSurface,
isLowPowerModeEnabled: Bool,
isReduceMotionEnabled: Bool,
thermalCondition: ThermalCondition,
sceneState: AppActivityState
) -> MotionFramePolicy {
guard sceneState == .active else { return .stopped }
guard !isLowPowerModeEnabled, !isReduceMotionEnabled else {
return .stopped
}
guard thermalCondition != .serious, thermalCondition != .critical else {
return .stopped
}
let allowsDecorativeEffects = thermalCondition == .nominal
switch surface {
case .preview:
return MotionFramePolicy(
framesPerSecond: thermalCondition == .fair ? 12 : 24,
allowsAnimation: true,
allowsDecorativeEffects: allowsDecorativeEffects
)
case .fullScreen:
return MotionFramePolicy(
framesPerSecond: thermalCondition == .fair ? 30 : 60,
allowsAnimation: true,
allowsDecorativeEffects: allowsDecorativeEffects
)
}
}
結果をまとめると次のとおりです。
| 状態 | 編集画面のプレビュー | 全画面表示 | 装飾エフェクト |
|---|---|---|---|
| 非アクティブ/バックグラウンド | 停止 | 停止 | なし |
| 低電力モード | 停止 | 停止 | なし |
| 視差効果を減らす | 停止 | 停止 | なし |
温度 serious/critical
|
停止 | 停止 | なし |
温度 fair
|
12 fps | 30 fps | なし |
温度 nominal
|
24 fps | 60 fps | あり |
ProcessInfo.ThermalState をアプリ内の ThermalCondition に変換するときは、将来追加されるかもしれない未知の値を、最も厳しい critical として扱っています。
case .critical:
return .critical
@unknown default:
return .critical
2. 状態の変化を購読する
MotionPolicy は ObservableObject で、次の 3 つの通知を受け取ると値を読み直します。
let systemNames: [Notification.Name] = [
.NSProcessInfoPowerStateDidChange,
ProcessInfo.thermalStateDidChangeNotification,
UIAccessibility.reduceMotionStatusDidChangeNotification
]
public func refreshSystemState() {
isLowPowerModeEnabled = processInfo.isLowPowerModeEnabled
isReduceMotionEnabled = UIAccessibility.isReduceMotionEnabled
thermalCondition = Self.mapThermalState(processInfo.thermalState)
}
さらに、アプリがアクティブに戻ったとき(UIApplication.didBecomeActiveNotification)にも 3 つの値をすべて読み直しています。
notificationTokens.append(
notificationCenter.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.refreshSystemState()
self?.handleSceneState(.active)
}
}
)
コード内のコメントでは、アプリが止まっている間に状態が変わると通知がまとめて届くことがあるため、復帰直後のフレームを古いフレームレートで描かないようにこうしている、と説明しています。
3. 自動で切り替えてよいかも純粋関数にする
メッセージシーケンスの自動切り替えを予約してよいかは、CueFlowAdvancePolicy.shouldSchedule で判定します。
enum CueFlowAdvancePolicy {
static func shouldSchedule(
playback: CueFlowPlayback?,
isSceneActive: Bool,
isPaused: Bool,
allowsMotion: Bool,
framesPerSecond: Int,
completed: Bool
) -> Bool {
playback?.advancesAutomatically == true
&& isSceneActive
&& !isPaused
&& allowsMotion
&& framesPerSecond > 0
&& !completed
}
}
全画面表示のビューには、allowsMotion と framesPerSecond として前節の resolve で求めた全画面用の値を渡しています。そのため、低電力モード・視差効果を減らす・温度 serious 以上のどれか 1 つでも当てはまれば、自動切り替えも止まります。温度が fair のときはフレームレートが下がるだけで、自動切り替えは続きます。
テストでは、条件を 1 つずつ崩すと false になることを確かめています。タプルの並びは (isSceneActive, isPaused, allowsMotion, framesPerSecond, completed, 期待値) です。
let variations = [
(true, false, true, 30, false, true),
(false, false, true, 30, false, false),
(true, true, true, 30, false, false),
(true, false, false, 30, false, false),
(true, false, true, 0, false, false),
(true, false, true, 30, true, false),
]
4. .task(id:) で「条件が変わったらやり直す」
待ち時間の実体は Timer ではなく、SwiftUI の .task(id:) の中の Task.sleep です。id には、判定に使う値をすべて入れた構造体を渡します。
private struct CueAdvanceTaskID: Hashable {
let playback: CueFlowPlayback?
let cueIndex: Int
let isActive: Bool
let isPaused: Bool
let allowsMotion: Bool
let framesPerSecond: Int
let completed: Bool
}
.task(id: cueAdvanceTaskID) {
guard CueFlowAdvancePolicy.shouldSchedule(
playback: cueFlow,
isSceneActive: scenePhase == .active,
isPaused: isPaused,
allowsMotion: allowsMotion,
framesPerSecond: framesPerSecond,
completed: cueFlowCompleted
), let cueFlow else {
return
}
do {
try await Task.sleep(for: .seconds(cueFlow.secondsPerMessage))
} catch {
return
}
guard !Task.isCancelled else { return }
advanceCue(.next, automatic: true)
}
Apple のドキュメントによると、.task(id:) は id の値が変わると実行中のタスクをキャンセルし、新しく作り直します(id の型は Equatable に準拠している必要があります)。この性質により、次のように動きます。
- 低電力モードがオンになると、
allowsMotionが変わるのでidも変わる - 待機中の
Task.sleepがキャンセルされてCancellationErrorを投げ、catchでreturnする - 作り直されたタスクでは
shouldScheduleがfalseを返すので、何も予約せずに終わる - 低電力モードがオフに戻ると
idがまた変わり、新しいタスクがsecondsPerMessage秒の待機を最初から始める
つまり、止まっていた間の経過時間は引き継がず、再開後はそのメッセージの表示時間を最初から数え直します。Timer を無効化したり、経過時間を自前で保存したりする処理は要りません。cueIndex も id に入っているので、手動で次のメッセージへ進めたときも待ち時間は最初からになります。
繰り返しがオフで最後のメッセージまで来たときは、完了フラグを立てて一時停止状態にしています。
guard let next = cueFlow.index(after: cueIndex, direction: direction) else {
if automatic {
cueFlowCompleted = true
setPaused(true)
} else {
revealControls()
}
return
}
5. 一方、スクロール位置は「止めた位置から再開」する
同じ全画面表示でも、文字の横スクロールは別の扱いをしています。こちらは止めた位置を覚えておき、再開したときにその位置から続けます。
func sample(at uptime: TimeInterval) -> MarqueePlaybackSample {
guard isRunning, let anchorUptime else {
return MarqueePlaybackSample(
signedDistance: frozenSignedDistance,
effectElapsed: frozenEffectElapsed
)
}
let safeUptime = uptime.isFinite ? max(uptime, anchorUptime) : anchorUptime
let elapsed = safeUptime - anchorUptime
return MarqueePlaybackSample(
signedDistance: frozenSignedDistance + elapsed * speed * direction.travelSign,
effectElapsed: frozenEffectElapsed + elapsed
)
}
描画を続けるかどうか(shouldRenderFrames)が変わるたびに synchronize を呼び、その時点の位置を frozenSignedDistance に取り込んで、基準時刻 anchorUptime を付け替えます。テストでは、速度 60 で時刻 10 秒から動かして 12 秒で止めると位置は 120、30 秒まで止めたままでも 120、30 秒に再開すると 31 秒の時点で 180 になることを確かめています。
同じ画面の中で、スクロール位置は途切れないように続きから動かし、メッセージの切り替えは待ち時間を数え直す、という 2 つの扱い方が共存しています。
6. 止めるのは自動の動きだけ
自動切り替えが止まっている間も、前後のメッセージへ移動するボタンは使えます。これらのボタンが無効になるのは、繰り返しがオフで先頭または末尾にいるときだけです。一方、再生/一時停止ボタンは、allowsMotion が false またはフレームレートが 0 の間は無効になります。
1 件あたりの表示秒数は、次の関数で範囲内に収めています。設定の保存時と読み込み時、再生データを作るときのそれぞれで通します。
static let minimumSecondsPerMessage = 2.0
static let maximumSecondsPerMessage = 20.0
static let defaultSecondsPerMessage = 5.0
static func sanitizedSeconds(_ value: Double) -> Double {
guard value.isFinite else { return defaultSecondsPerMessage }
return min(max(value, minimumSecondsPerMessage), maximumSecondsPerMessage)
}
NaN や無限大が入った場合は既定の 5 秒になるので、Task.sleep に範囲外の値が渡ることはありません。
7. 最小構成のサンプル
同じ考え方を 1 つのビューにまとめた、記事用のサンプルです。アプリのコードそのものではありません。iOS 17 をターゲットに、Swift 5 と Swift 6 の両方の言語モードで型チェックが通ることを確認しています。
import SwiftUI
struct AutoAdvanceDemo: View {
let messages: [String]
var secondsPerMessage: Double = 5
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var index = 0
@State private var isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
@State private var thermalState = ProcessInfo.processInfo.thermalState
private struct AdvanceID: Hashable {
let index: Int
let isActive: Bool
let allowsMotion: Bool
}
private var allowsMotion: Bool {
!reduceMotion
&& !isLowPower
&& thermalState != .serious
&& thermalState != .critical
}
var body: some View {
Text(messages.isEmpty ? "" : messages[index % messages.count])
.task(id: AdvanceID(
index: index,
isActive: scenePhase == .active,
allowsMotion: allowsMotion
)) {
guard scenePhase == .active, allowsMotion, messages.count >= 2 else {
return
}
do {
try await Task.sleep(for: .seconds(secondsPerMessage))
} catch {
return // id が変わった、または画面が消えた
}
index = (index + 1) % messages.count
}
.onReceive(
NotificationCenter.default
.publisher(for: .NSProcessInfoPowerStateDidChange)
.receive(on: DispatchQueue.main)
) { _ in
isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
}
.onReceive(
NotificationCenter.default
.publisher(for: ProcessInfo.thermalStateDidChangeNotification)
.receive(on: DispatchQueue.main)
) { _ in
thermalState = ProcessInfo.processInfo.thermalState
}
}
}
アプリ本体とは違い、このサンプルでは「視差効果を減らす」の判定に SwiftUI の環境値 accessibilityReduceMotion を使っています。環境値は設定が変わるとビューが再評価されるので、通知を自分で購読する必要がありません。
まとめ
- 動かしてよいかの判定は、状態を引数で受け取る純粋関数にまとめるとテストしやすい
- 未知の温度状態は最も厳しい値として扱う
- 待ち時間は
.task(id:)のTask.sleepにし、判定に使う値をすべてidに入れる。条件が変わればキャンセルされ、戻れば最初から数え直す - スクロール位置のように途切れると目立つものは、止めた位置から再開する
筆者が開発した App
この記事は、iOS アプリを開発している小さなチーム Lumi Studio(筆者)が書いています。紹介したコードは、筆者が開発した電光掲示板アプリ「LED Moving Text」のものです。
- 文字を LED 風に流して大きく表示するアプリです
- 基本無料で、メッセージシーケンスも無料版で使えます(Pro 限定の表示スタイルを選んでいる場合を除く)。一部のエフェクトや動画・GIF の書き出しなどは、買い切りの Pro(アプリ内課金)の機能です
App Store で見る(LED Moving Text)
※ この記事は AI アシスタントを使って下書きし、記載したコード・挙動はアプリのソースコードと照らし合わせて確認しています。