0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Screen Time APIで『トレーニングしないと開けないアプリ』を作る

0
Posted at

概要

スマホを開くとつい SNS や動画アプリを眺めてしまう……。
そこで「トレーニングしないとアプリを開けない」iPhone アプリを考えてみました。

本記事では、Apple の Screen Time API(FamilyControls / ManagedSettings / DeviceActivity)と CoreMotion を組み合わせて、次のような仕組みを実装する方法をまとめます。

  • 指定したアプリをロックする
  • 100 歩あるくとロックが解除される
  • 30 分経つと自動で再ロックされる

本記事のコードは構成と実装方針を示すための想定コードです。
お使いの iOS / Xcode のバージョンによっては修正が必要になる場合があります。

本題

動作の流れ

使用技術

フレームワーク 役割
FamilyControls スクリーンタイムの許可取得(.individual)、ロックするアプリの選択(FamilyActivityPicker)
ManagedSettings 選択したアプリにシールド(ロック)をかける/外す
ManagedSettingsUI ロック画面の見た目とボタン動作のカスタマイズ
DeviceActivity 「解除から30分後」などのスケジュール監視と再ロック
CoreMotion 歩数計(CMPedometer)でトレーニングを判定
App Groups 本体アプリと拡張機能のデータ共有

前提・開発環境

  • iOS 16 以降(.individual での許可に必要。本記事のコードは iOS 17 以降を想定)
  • Xcode(SwiftUI)
  • 実機の iPhone(シミュレーターではロック機能・歩数計が動作しません)
  • Apple Developer アカウント

シミュレーターについて
UI やロジックの確認はシミュレーターでもできますが、スクリーンタイムの許可・シールド・DeviceActivity の監視・歩数計は動作しません。
ロック機能は実機で確認しましょう。

アカウントについて
無料の Apple ID(Personal Team)では Family Controls や App Groups の Capability を追加できない可能性が高く、その場合は有料の Apple Developer Program が必要です。
また、App Store で配布する場合は Apple に Family Controls の配布用エンタイトルメントを申請する必要があります(個人の実機ビルドのみなら不要です)。

プロジェクト構成

TrainLock/
├── Shared/
│   └── Shared.swift                       # 本体 + Monitor拡張で共用
├── TrainLock/                             # 本体アプリ
│   ├── TrainLockApp.swift
│   ├── ContentView.swift
│   └── TrainingView.swift
├── DeviceActivityMonitorExtension/
│   └── DeviceActivityMonitorExtension.swift
├── ShieldConfigurationExtension/
│   └── ShieldConfigurationExtension.swift
└── ShieldActionExtension/
    └── ShieldActionExtension.swift

Xcode のセットアップ

  1. iOS App プロジェクト「TrainLock」を作成(Interface: SwiftUI)
  2. File > New > Target で次の 3 つの拡張機能を追加
    • Device Activity Monitor Extension
    • Shield Configuration Extension
    • Shield Action Extension
  3. Shared.swift を 本体アプリ と DeviceActivityMonitorExtension の両方のターゲットに含める(Target Membership)
  4. 全ターゲットの Signing & Capabilities で以下を追加
    • Family Controls
    • App Groups(全ターゲットで同じ ID。例: group.com.example.trainlock)
  5. 本体アプリの Info.plist に NSMotionUsageDescription を追加(例:「トレーニングの歩数を数えるために使用します」)
  6. iPhone 側で「設定 > プライバシーとセキュリティ > デベロッパモード」をオンにして実機ビルド

実装

1. 共通処理(Shared.swift)

本体アプリと拡張機能で同じ ManagedSettingsStore の名前を使うことで、ロック状態を共有します。
選択したアプリ(FamilyActivitySelection)は Codable なので、App Groups の UserDefaults に JSON で保存しておき、拡張機能から読み出して再ロックに使います。

Shared.swift
// Shared.swift
// ターゲットメンバーシップ: TrainLock(本体) + DeviceActivityMonitor拡張
// 本体アプリと拡張機能で共通に使う設定・処理

import Foundation
import FamilyControls
import ManagedSettings
import DeviceActivity

enum Shared {
    /// Xcodeの App Groups で作成したIDに置き換えてください
    static let appGroup = "group.com.example.trainlock"
    static let selectionKey = "lockedSelection"
}

extension ManagedSettingsStore.Name {
    /// 本体と拡張機能で同じ名前のストアを使うことでロック状態を共有する
    static let main = Self("main")
}

extension DeviceActivityName {
    /// 「ロック解除中の時間帯」を表すアクティビティ名
    static let unlockWindow = Self("unlockWindow")
}

enum LockStore {
    private static var defaults: UserDefaults? {
        UserDefaults(suiteName: Shared.appGroup)
    }

    private static var store: ManagedSettingsStore {
        ManagedSettingsStore(named: .main)
    }

    // MARK: - 選択したアプリの保存・読み込み(FamilyActivitySelectionはCodable)

    static func saveSelection(_ selection: FamilyActivitySelection) {
        guard let data = try? JSONEncoder().encode(selection) else { return }
        defaults?.set(data, forKey: Shared.selectionKey)
    }

    static func loadSelection() -> FamilyActivitySelection? {
        guard let data = defaults?.data(forKey: Shared.selectionKey) else { return nil }
        return try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
    }

    // MARK: - ロック(シールド)の適用・解除

    static func applyShield() {
        guard let selection = loadSelection() else { return }
        let apps = selection.applicationTokens
        let categories = selection.categoryTokens

        store.shield.applications = apps.isEmpty ? nil : apps
        store.shield.applicationCategories = categories.isEmpty ? nil : .specific(categories)
    }

    static func removeShield() {
        store.shield.applications = nil
        store.shield.applicationCategories = nil
    }
}

2. アプリのエントリーポイント(TrainLockApp.swift)

ロック画面のボタンから通知を送るため、起動時に通知の許可を取っておきます。

TrainLockApp.swift
// TrainLockApp.swift
// ターゲット: TrainLock(本体アプリ)

import SwiftUI
import UserNotifications

@main
struct TrainLockApp: App {
    init() {
        // ロック画面のボタンから送る「トレーニングしよう」通知のための許可
        UNUserNotificationCenter.current()
            .requestAuthorization(options: [.alert, .sound]) { _, _ in }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

3. 設定画面(ContentView.swift)

AuthorizationCenter.shared.requestAuthorization(for: .individual) で自分自身の端末を制限する許可を取り、.familyActivityPicker でロックするアプリを選びます。

アプリは ApplicationToken という不透明なトークンで渡されるため、コード上でアプリ名を取得することはできません。
ただし Label(token) に渡すと、アプリ名とアイコンを表示できます。

ContentView.swift
// ContentView.swift
// ターゲット: TrainLock(本体アプリ)
// 許可の取得 → ロックするアプリの選択 → ロック開始 → トレーニング画面へ

import SwiftUI
import FamilyControls

struct ContentView: View {
    @StateObject private var auth = AuthorizationCenter.shared
    @State private var selection = LockStore.loadSelection() ?? FamilyActivitySelection()
    @State private var isPickerPresented = false
    @State private var errorMessage: String?

    var body: some View {
        NavigationStack {
            List {
                Section("1. スクリーンタイムの許可") {
                    if auth.authorizationStatus == .approved {
                        Label("許可済み", systemImage: "checkmark.seal.fill")
                            .foregroundStyle(.green)
                    } else {
                        Button("スクリーンタイムへのアクセスを許可") {
                            Task { await requestAuthorization() }
                        }
                    }
                }

                Section("2. ロックするアプリ") {
                    Button("アプリを選ぶ(\(selectedCount)件選択中)") {
                        isPickerPresented = true
                    }
                    .disabled(auth.authorizationStatus != .approved)

                    // トークンは不透明だが、Labelに渡すとアプリ名とアイコンを表示できる
                    ForEach(Array(selection.applicationTokens), id: \.self) { token in
                        Label(token)
                    }
                }

                Section("3. ロック") {
                    Button("今すぐロックする") {
                        LockStore.saveSelection(selection)
                        LockStore.applyShield()
                    }
                    .disabled(selectedCount == 0)

                    NavigationLink("トレーニングして解除する") {
                        TrainingView()
                    }
                }

                if let errorMessage {
                    Section { Text(errorMessage).foregroundStyle(.red) }
                }
            }
            .navigationTitle("TrainLock")
            .familyActivityPicker(isPresented: $isPickerPresented, selection: $selection)
            .onChange(of: selection) { _, newValue in
                LockStore.saveSelection(newValue)
            }
        }
    }

    private var selectedCount: Int {
        selection.applicationTokens.count + selection.categoryTokens.count
    }

    private func requestAuthorization() async {
        do {
            // .individual = 自分自身の端末を制限する(iOS 16以降)
            try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
        } catch {
            errorMessage = "許可に失敗しました: \(error.localizedDescription)"
        }
    }
}

4. トレーニング画面(TrainingView.swift)

CMPedometer で歩数を取得し、目標の 100 歩に達したらシールドを外します。
同時に DeviceActivityCenter に「今から 30 分間」のスケジュールを登録しておき、その終了時に拡張機能で再ロックします。

TrainingView.swift
// TrainingView.swift
// ターゲット: TrainLock(本体アプリ)
// 歩数計で目標歩数を達成したらロックを解除し、一定時間後の再ロックを予約する

import SwiftUI
import CoreMotion
import DeviceActivity

struct TrainingView: View {
    /// 解除に必要な歩数
    private let goalSteps = 100
    /// 解除しておく時間(分)※DeviceActivityの仕様上、15分以上にする
    private let unlockMinutes = 30

    @State private var steps = 0
    @State private var isTraining = false
    @State private var isUnlocked = false
    @State private var message: String?

    private let pedometer = CMPedometer()

    var body: some View {
        VStack(spacing: 24) {
            Text("\(steps) / \(goalSteps) 歩")
                .font(.system(size: 48, weight: .bold, design: .rounded))
                .monospacedDigit()

            ProgressView(value: Double(min(steps, goalSteps)), total: Double(goalSteps))
                .padding(.horizontal)

            if isUnlocked {
                Label("解除しました! \(unlockMinutes)分後に再ロックされます", systemImage: "lock.open.fill")
                    .foregroundStyle(.green)
            } else if isTraining {
                Text("歩いてください…").foregroundStyle(.secondary)
            } else {
                Button("トレーニング開始") { startTraining() }
                    .buttonStyle(.borderedProminent)
            }

            if let message {
                Text(message).font(.footnote).foregroundStyle(.red)
            }
        }
        .padding()
        .navigationTitle("トレーニング")
        .onDisappear { pedometer.stopUpdates() }
    }

    private func startTraining() {
        guard CMPedometer.isStepCountingAvailable() else {
            message = "この端末では歩数計を使えません(シミュレーターでは動作しません)"
            return
        }
        steps = 0
        isTraining = true

        pedometer.startUpdates(from: Date()) { data, error in
            DispatchQueue.main.async {
                if let error {
                    message = "歩数の取得に失敗しました: \(error.localizedDescription)"
                    isTraining = false
                    return
                }
                guard let data else { return }
                steps = data.numberOfSteps.intValue

                if steps >= goalSteps && !isUnlocked {
                    pedometer.stopUpdates()
                    isTraining = false
                    unlock()
                }
            }
        }
    }

    private func unlock() {
        LockStore.removeShield()
        do {
            try scheduleRelock()
            isUnlocked = true
        } catch {
            // 再ロックの予約に失敗したら安全側に倒してロックを戻す
            LockStore.applyShield()
            message = "再ロックの予約に失敗しました: \(error.localizedDescription)"
        }
    }

    /// 「今から unlockMinutes 分間」のアクティビティを登録する。
    /// 終了時に DeviceActivityMonitor 拡張の intervalDidEnd が呼ばれ、そこで再ロックする。
    private func scheduleRelock() throws {
        let calendar = Calendar.current
        let now = Date()
        let end = calendar.date(byAdding: .minute, value: unlockMinutes, to: now)!

        let schedule = DeviceActivitySchedule(
            intervalStart: calendar.dateComponents([.hour, .minute, .second], from: now),
            intervalEnd: calendar.dateComponents([.hour, .minute, .second], from: end),
            repeats: false
        )

        let center = DeviceActivityCenter()
        center.stopMonitoring([.unlockWindow])
        try center.startMonitoring(.unlockWindow, during: schedule)
    }
}

DeviceActivitySchedule の間隔は 15 分以上にする必要があります。短すぎると startMonitoring がエラーになります。

5. 再ロック(DeviceActivityMonitorExtension.swift)

登録したスケジュールが終わると intervalDidEnd が呼ばれるので、ここでシールドを再適用します。

DeviceActivityMonitorExtension.swift
// DeviceActivityMonitorExtension.swift
// ターゲット: DeviceActivityMonitorExtension
// (Xcode: File > New > Target > "Device Activity Monitor Extension")
// ※ Shared.swift もこのターゲットに含めること
// ※ 拡張機能はメモリ上限が厳しい(数MB程度)ので、重い処理は書かない

import DeviceActivity
import ManagedSettings

class DeviceActivityMonitorExtension: DeviceActivityMonitor {

    override func intervalDidStart(for activity: DeviceActivityName) {
        super.intervalDidStart(for: activity)
        // 解除期間の開始時は何もしない(解除は本体アプリで済んでいる)
    }

    override func intervalDidEnd(for activity: DeviceActivityName) {
        super.intervalDidEnd(for: activity)
        guard activity == .unlockWindow else { return }

        // 解除期間が終わったので再ロック
        LockStore.applyShield()
    }
}

6. ロック画面のデザイン(ShieldConfigurationExtension.swift)

ロックされたアプリを開いたときに表示される画面のアイコン・文言・ボタンをカスタマイズします。

ShieldConfigurationExtension.swift
// ShieldConfigurationExtension.swift
// ターゲット: ShieldConfigurationExtension
// (Xcode: File > New > Target > "Shield Configuration Extension")
// ロックされたアプリを開いたときに表示される画面の見た目を決める

import ManagedSettings
import ManagedSettingsUI
import UIKit

class ShieldConfigurationExtension: ShieldConfigurationDataSource {

    override func configuration(shielding application: Application) -> ShieldConfiguration {
        makeConfiguration(appName: application.localizedDisplayName)
    }

    override func configuration(shielding application: Application,
                                in category: ActivityCategory) -> ShieldConfiguration {
        makeConfiguration(appName: application.localizedDisplayName)
    }

    private func makeConfiguration(appName: String?) -> ShieldConfiguration {
        ShieldConfiguration(
            backgroundBlurStyle: .systemMaterialDark,
            backgroundColor: .black,
            icon: UIImage(systemName: "figure.walk"),
            title: .init(text: "まずはトレーニング!", color: .white),
            subtitle: .init(
                text: "\(appName ?? "このアプリ")を使うには、TrainLockで100歩あるきましょう",
                color: .lightGray
            ),
            primaryButtonLabel: .init(text: "トレーニングする", color: .white),
            primaryButtonBackgroundColor: .systemOrange,
            secondaryButtonLabel: .init(text: "閉じる", color: .lightGray)
        )
    }
}

7. ロック画面のボタン動作(ShieldActionExtension.swift)

ロック画面から自作アプリを直接開く API は用意されていないため、「トレーニングする」ボタンが押されたらローカル通知を送り、通知のタップで TrainLock を開いてもらう形にしています。

ShieldActionExtension.swift
// ShieldActionExtension.swift
// ターゲット: ShieldActionExtension
// (Xcode: File > New > Target > "Shield Action Extension")
// ロック画面のボタンが押されたときの処理
// ※ ロック画面から自作アプリを直接開くAPIはないため、通知を送って誘導する

import ManagedSettings
import UserNotifications

class ShieldActionExtension: ShieldActionDelegate {

    override func handle(action: ShieldAction,
                         for application: ApplicationToken,
                         completionHandler: @escaping (ShieldActionResponse) -> Void) {
        handle(action: action, completionHandler: completionHandler)
    }

    override func handle(action: ShieldAction,
                         for category: ActivityCategoryToken,
                         completionHandler: @escaping (ShieldActionResponse) -> Void) {
        handle(action: action, completionHandler: completionHandler)
    }

    private func handle(action: ShieldAction,
                        completionHandler: @escaping (ShieldActionResponse) -> Void) {
        switch action {
        case .primaryButtonPressed:
            // 「トレーニングする」→ 通知をタップしてTrainLockを開いてもらう
            sendTrainingNotification {
                completionHandler(.close)
            }
        case .secondaryButtonPressed:
            completionHandler(.close)
        @unknown default:
            completionHandler(.close)
        }
    }

    private func sendTrainingNotification(completion: @escaping () -> Void) {
        let content = UNMutableNotificationContent()
        content.title = "トレーニングの時間です"
        content.body = "タップしてTrainLockを開き、100歩あるいてロックを解除しましょう"
        content.sound = .default

        let request = UNNotificationRequest(
            identifier: "training-reminder",
            content: content,
            trigger: nil // すぐに表示
        )
        UNUserNotificationCenter.current().add(request) { _ in
            completion()
        }
    }
}

ハマりどころ・注意点

  • シミュレーターでは動かない:ロック機能と歩数計は実機必須です。
  • ロック画面から自作アプリを開けない:通知で誘導するのが一般的な回避策です。
  • アプリはトークンで扱う:アプリ名やバンドル ID はコード側で取得できません。
  • 拡張機能のメモリ上限が厳しい:DeviceActivityMonitor 拡張などでは重い処理を書かないようにしましょう。
  • スケジュールは 15 分以上:DeviceActivitySchedule の最小間隔に注意。
  • 強制力は「自分との約束」レベル:設定からスクリーンタイムの許可をオフにすれば解除できます。

拡張アイデア

  • HealthKit でワークアウトの完了を解除条件にする
  • Vision の VNDetectHumanBodyPoseRequest でカメラからスクワットや腕立て伏せの回数を数える
  • 1 日の解除回数に上限を設ける
  • 解除時間をトレーニング量に応じて変える(200 歩なら 60 分など)

おわりに

Screen Time API を使うと、サードパーティのアプリでも「特定アプリのロック」「時間経過での再ロック」を実現できます。
トレーニング判定の部分を差し替えれば、勉強や瞑想など、さまざまな「ご褒美型」のアプリ制限に応用できそうです。

参考

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?