1
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?

Swift ScreenTimeAPI

Last updated at Posted at 2025-07-05

ScreenTime APIとは

Screen Time APIは以下の3つのフレームワークで構成されています:
FamilyControls
ManagedSettings
DeviceActivity

まず最初に

俗に言うScreenTime APIを使うには、
他の記事ではAppleに申請する必要があると書いてありますが、
Demo版、つまり実機テストやシミュレーションテストの段階のみならば必要ありません。

以下の手順でFamily Controllを使用でします
Target -> Signing & Capabilitiesのところで+Cababilityをクリック,Family Controllを追加してください

もし、デモアプリの段階で

Error Domain=NSCocoaErrorDomain Code=4099 "The connection to service named com.apple.FamilyControlsAgent was invalidated: failed at lookup with error 159 - Sandbox restriction." UserInfo={NSDebugDescription=The connection to service named com.apple.FamilyControlsAgent was invalidated: failed at lookup with error 159 - Sandbox restriction.}

というエラーが出た場合、以下の問題が予想されます。

  1. +Cababilityが正しく追加されていない
  2. Apple Developerが更新されていない
  3. .entitlements内にcom.apple.developer.family-controls.user-managementが含まれていない
  4. Target -> Build Settings -> Sigming -> Code Signing Entitlments のDebug,Releaseに.entitlementsが正しく設定されていない

FamilyControls

保護者の認証を設けることができるフレームワーク。これにより、Screen Time APIによる設定の変更や削除を保護者に限定できます。また、認証後は家族のScreen Timeの情報を閲覧することが可能です。

また、アプリの取得もここでやります。

// これが何を選んだかを保存するもの
@State var selection = FamilyActivitySelection()
// これが、選ばれたインスタンスにWebサイトを入れないというもの。
@State var selection = FamilyActivitySelection(includeEntireCategory: false)
// これがアプリの取得Picker
FamilyActivityPicker(selection: $selection)
FamilyActivityPicker(headerText: "head", footerText: "foot", selection: $selection)
// これがアプリの取得Pickerのシートバージョン
@State private var isPresented: Bool = false

Button("Present FamilyActivityPicker") { isPresented = true }
       .familyActivityPicker(isPresented: $isPresented,
                             selection: $selection)
       .onChange(of: selection) {
           let applications = selection.applications
           let categories = selection.categories
           let webDomains = selection.webDomains
       }
// エンコードデコードの可
let selection = FamilyActivitySelection() // 選択済みだと仮定
if let data = try? JSONEncoder().encode(selection) {
    UserDefaults.standard.set(data, forKey: "activitySelection")
}

if let data = UserDefaults.standard.data(forKey: "activitySelection"),
   let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data) {
    // selection に復元されたインスタンスが入っている
}

あんま使わないでしょう論理式

//アクティビティの選択を比較する
static func == (FamilyActivitySelection, FamilyActivitySelection) -> Bool
//2つの値が等しいかどうかを示すブール値を返します。
static func != (Self, Self) -> Bool

この下はAuthに関わる内容です。

// これがAuthCenterとの共有を可能にします(シングルトンデザイン)
let center = AuthorizationCenter.shared

// 以下同じ実装を二通り-> これはペアレントコントロールを許可する認証
do {
    try await center.requestAuthorization(for: FamilyControlsMember.individual)
} catch {
    // Handle the error here.
}

// 非推奨らしい
center.requestAuthorization { result in
    switch result {
    case .success():
        // The request succeeded.
    case .failure(let error):
        // Handle the error here.
    }
}

// 認証を取り消す場合
center.revokeAuthorization { result in
    switch result {
    case .success():
        // The request succeeded.
    case .failure(let error):
        // Handle the error here.
    }
}

なんで認証を取り消す場合はcompletionHandlerしかないのか疑問
修正してほしい

// 現在の認証状況を確認する
let center = AuthorizationCenter.shared
let cancellable = center.$authorizationStatus
    .sink() {_ in 
    switch AuthorizationCenter.shared.authorizationStatus {
    case .notDetermined:
    // まだ認証していない
    case .denied:
    // 許可されなかった
    case .approved:
    // 許可された
    @unknown default:
    // それ以外
    }
}
// $authorizationStatusが状況を表す

ManagedSettings

Screen Timeで設定可能な制限(アプリのシールドやアカウントのロック、パスワードの編集権限、Webサイトのフィルタリングなど)を利用することができるフレームワーク。また、アプリのシールド画面を独自にカスタマイズできます。
ManagedSettingsStore
現在のユーザやデバイスに制限の設定を適応するためのクラス。それぞれのプロパティに値を設定することで、制限したり許可したりすることができます。
ShieldConfiguration
(正確にはManagedSettingsUIフレームワークのもの)Screen Timeによるデフォルトのシールド画面をカスタマイズするためのオブジェクト。基本のコンポーネントはデフォルトのままで、テキストや画像などを変更することができます。

// シングルトン
let store = ManagedSettingsStore()

// 選んだアプリケーションを画面ロックする from familycontroll
let applications = selection.applicationTokens
store.shield.applications = applications.isEmpty ? nil : applications

// 選んだカテゴリを画面ロックする from familycontroll
let applications = selection.categoryTokens
store.shield.applicationCategories = applications.isEmpty ? nil : .specific(applications)

// 選んだWebカテゴリを画面ロックする from familycontroll
let applications = selection.categoryTokens
store.shield.webDomainCategories = applications.isEmpty ? nil : .specific(applications)

// 選んだwebドメインを画面ロックする from familycontroll
let applications = selection.webDomainTokens
store.shield.webDomains = applications.isEmpty ? nil : applications

続いては app storeに関する制限です

// アプリ内課金ができなくなります
store.appStore.denyInAppPurchases = true
// アプリ内課金の際、パスワードが必要になります
store.appStore.requirePasswordForPurchases = true

// スマホ全体のダウンロードしたアプリ数を制限する
// これをゼロにしたらダウンロードしたアプリが全部消えます
// = .maxにしたら治ります
store.appStore.maximumRating = 1000

続いては アプリケーションに関するものです

// 制限がかかっているアプリ群です
store.application.blockedApplications

// アプリをインストールできなくさせる(App Storeをひらけなくさせる)
store.application.denyAppInstallation = true
// アプリをアンインストールさせない(この場合、実機テストも更新できなくなる)
store.application.denyAppRemoval = true

続いては アカウントに関するものです

// アカウントの設定を変更できなくなります(Settingから確認できます)
store.account.lockAccounts = true

続いては セルラー通信(モバイル通信)についてです

// あまり実用性がなし

// モバイル通信における設定を制限
store.cellular.lockAppCellularData = true
// モバイル通信におけるプランの変更を制限
store.cellular.lockCellularPlan = true
// eSIMにおける設定を制限
store.cellular.lockESIM = true

続いては 時間設定についてです

// ネットワークでの自動時間設定のみです
store.dateAndTime.requireAutomaticDateAndTime = true

続いては年齢制限についてです

// 制限年齢のプロパティ
print(store.effectiveMaximumMovieRating)// 18
print(store.effectiveMaximumTVShowRating)// 18
// 値の変更の通知(@Publisher)
store.$effectiveMaximumMovieRating
store.$effectiveMaximumTVShowRating

続いては Game Centerについてです

// マルチプレイのゲームに参加することを制限
store.gameCenter.denyMultiplayerGaming = true
// フレンドの追加を制限
store.gameCenter.denyAddingFriends = true

続いてはメディアについてです

// ブックストアにおける、成人向けのアクセスを制限
store.media.denyBookstoreErotica = true
// 不適切なタグの映画おんがくの再生を制限
store.media.denyExplicitContent = true
// 音楽における、UI/UXを古くする
store.media.denyMusicService = true

続いてはパスコードについてです

// パスワードの変更を制限
store.passcode.lockPasscode = true

続いてはsafariについてです

// cookieに関する制限
store.safari.cookiePolicy..
// safariにおけるkey chainなどの自動入力を制限
store.safari.denyAutoFill = true

続いてはsiriについてです

// siriの制限
store.siri.denySiri = true

続いてはwebCntentについてです

store.webContent.blockedByFilter

続いては便利な機能です

// これは設定をオールクリアするものです。
store.clearAllSettings()
// これはクラスの変更を通知するものです
store.objectWillChange

Device Manager

これがまとめるのに最も苦労しました。
上記二個と比べて、実行寄りのフレームなため簡単に探索できなかった。

このフレームワークはアプリの動作を実際に監視するフレームワークで、
大きく二つの機能に分かれています

  1. スケジュールによる制限開始・終了
  2. スクリーンタイムの取得

まずは1から

2. スクリーンタイムの取得

これが一番だるかった
まず、これは公式ドキュメントの説明だけでは足りないと思いました。
これは途中までは全く成果が得られないので、動かなくても続けましょう。

まずはApple 公式から出てるサンプルコードを書きます

struct ExampleView: View {
    let selectedApps: Set<ApplicationToken>
    let selectedCategories: Set<ActivityCategoryToken>
    let selectedWebDomains: Set<WebDomainToken>


    @State private var context: DeviceActivityReport.Context = .barGraph
    @State private var filter = DeviceActivityFilter(
        segment: .daily(
            during: Calendar.current.dateInterval(
               of: .weekOfYear, for: .now
            )!
        ),
        users: .children,
        devices: .init([.iPhone, .iPad]),
        applications: selectedApps,
        categories: selectedCategories,
        webDomains: selectedWebDomains
    )


    public var body: some View {
        VStack {
            DeviceActivityReport(context, filter: filter)


            // A picker used to change the report's context.
            Picker(selection: $context, label: Text("Context: ")) {
                Text("Bar Graph")
                    .tag(DeviceActivityReport.Context.barGraph)
                Text("Pie Chart")
                     .tag(DeviceActivityReport.Context.pieChart)
            }


            // A picker used to change the filter's segment interval.
            Picker(
                selection: $filter.segmentInterval,
                 label: Text("Segment Interval: ")
            ) {
                Text("Hourly")
                    .tag(DeviceActivityFilter.SegmentInterval.hourly())
                Text("Daily")
                    .tag(DeviceActivityFilter.SegmentInterval.daily(
                        during: Calendar.current.dateInterval(
                             of: .weekOfYear, for: .now
                        )!
                    ))
                Text("Weekly")
                    .tag(DeviceActivityFilter.SegmentInterval.weekly(
                        during: Calendar.current.dateInterval(
                            of: .month, for: .now
                        )!
                    ))
            }
            // ...
        }
    }
}

このコードを貼ってもシンタックスエラーが出ます。
所々に不完全な部分があるので修正したものを貼ります。

extension DeviceActivityReport.Context {
    static let barGraph = Self("barGraph")
    static let pieChart = Self("pieChart")
}

struct ExampleView: View {
    @ObservedObject var viewModel: SelectionViewModel

    @State var context: DeviceActivityReport.Context = .pieChart

    @State private var selectedSegment: DeviceActivityFilter.SegmentInterval = .daily(
        during: Calendar.current.dateInterval(of: .weekOfYear, for: .now)!
    )

    @State private var reportID = UUID()
    @State private var filter: DeviceActivityFilter = .init()

    var body: some View {
        VStack(spacing: 20) {
            // 自動で再描画されないため手動
            DeviceActivityReport(context, filter: filter)
                .id(reportID)

            Picker(selection: $context, label: Text("Context: ")) {
                Text("Bar Graph").tag(DeviceActivityReport.Context.barGraph)
                Text("Pie Chart").tag(DeviceActivityReport.Context.pieChart)
            }

            Picker("時間区切り", selection: $selectedSegment) {
                Text("Hourly").tag(DeviceActivityFilter.SegmentInterval.hourly(
                    during: Calendar.current.dateInterval(of: .day, for: .now)!
                ))
                Text("Daily").tag(DeviceActivityFilter.SegmentInterval.daily(
                    during: Calendar.current.dateInterval(of: .weekOfYear, for: .now)!
                ))
                Text("Weekly").tag(DeviceActivityFilter.SegmentInterval.weekly(
                    during: Calendar.current.dateInterval(of: .month, for: .now)!
                ))
            }
            .pickerStyle(.menu)
        }
        .padding()
        .onAppear {
            regenerateReport()
        }
        .onChange(of: context) { regenerateReport() }
        .onChange(of: selectedSegment) { regenerateReport() }
        .onChange(of: viewModel.selection) { regenerateReport() }
    }

    func regenerateReport() {
        filter = DeviceActivityFilter(
            segment: selectedSegment,
            users: .all,
            devices: .init([.iPhone, .iPad]),
            applications: viewModel.selection.applicationTokens,
            categories: viewModel.selection.categoryTokens,
            webDomains: viewModel.selection.webDomainTokens
        )
        reportID = UUID() // 強制再描画
    }
}


class SelectionViewModel: ObservableObject {
    @Published var selection = FamilyActivitySelection(includeEntireCategory: false)
}

これだけでは動きません。
このあと、
File -> New -> Target -> Device Activity Report Extension
で、App Extensionを追加しましょう

ExtentionTarget
|- info.plist
|- ExtentionTarget.entitlements
|- ExtentionTarget.swift  (@main)
|- TotalActivityReport.swift
|- TotalActivityView.swift

のようなコードが追加できたら成功です。

これだけでは動きません。

// ExtentionTarget.swift
@main
struct report_test: DeviceActivityReportExtension {
    var body: some DeviceActivityReportScene {
        // 棒グラフ表示用レポート
        TotalActivityReport(context: .barGraph) { totalActivity in
            TotalActivityView(contextLabel: "barGraph")
        }

        // 円グラフ表示用レポート
        TotalActivityReport(context: .pieChart) { totalActivity in
            TotalActivityView(contextLabel: "pieChart")
        }
    }
}
// TotalActivityReport.swift
extension DeviceActivityReport.Context {
    // If your app initializes a DeviceActivityReport with this context, then the system will use
    // your extension's corresponding DeviceActivityReportScene to render the contents of the
    // report.
    static let barGraph = Self("barGraph")
    static let pieChart = Self("pieChart")
}

struct TotalActivityReport: DeviceActivityReportScene {
    // Define which context your scene will represent.
    let context: DeviceActivityReport.Context
    
    // Define the custom configuration and the resulting view for this report.
    let content: (String) -> TotalActivityView
    
    func makeConfiguration(representing data: DeviceActivityResults<DeviceActivityData>) async -> String {
        // Reformat the data into a configuration that can be used to create
        // the report's view.
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.day, .hour, .minute, .second]
        formatter.unitsStyle = .abbreviated
        formatter.zeroFormattingBehavior = .dropAll
        
        let totalActivityDuration = await data.flatMap { $0.activitySegments }.reduce(0, {
            $0 + $1.totalActivityDuration
        })
        return formatter.string(from: totalActivityDuration) ?? "No activity data"
    }
}
// TotalActivityView.swift
struct TotalActivityView: View {
    let contextLabel: String
    
    var body: some View {
        Text(contextLabel)
    }
}

以上で動きます。

基本的に考える部分はExtentionTarget.swiftとTotalActivityView.swiftであり、

  • ExtentionTarget.swift
    DeviceActivityReport(context, filter: filter)のエントリーポイントです。
    プロトコルの影響で特に設定することはないのです。
    TotalActivityReport(context: .barGraph) { totalActivity in
        TotalActivityView(contextLabel: "barGraph")
    }
    
    の形しか呼び出せない。
  • TotalActivityView.swift
    これがグラフ本体のViewです。
    上記のコードではテキストしか出ていませんが、totalActivityを引数として受け取ることでスクリーンタイムを扱える。なお、グラフは自作らしいです。

もし.barGraph のような、グラフの種類を増やしたい場合、

// TotalActivityReport.swift
extension DeviceActivityReport.Context {
    // If your app initializes a DeviceActivityReport with this context, then the system will use
    // your extension's corresponding DeviceActivityReportScene to render the contents of the
    // report.
    static let barGraph = Self("barGraph")
    static let pieChart = Self("pieChart")
}
extension DeviceActivityReport.Context {
    static let barGraph = Self("barGraph")
    static let pieChart = Self("pieChart")
}

この二つに追加すれば大丈夫です。

もし.barGraph のような、グラフの種類を増やしたい場合、

DeviceActivityReport(context, filter: filter)
                .id(context.rawValue) // contextが変わるとViewを再構築

つまりはDeviceActivityReportは自動再描画じゃないってことです。
これによってさまざまな要因で手動で再描画の動作を入れる必要があります。注意してください

さて、上記まででは記録のフィルタリングが完了しました。
続いてはそのフィルタリングされたデータをハンドリングする処理を説明していきます。
まず、フィルタリングされたデータは下記の、TotalActivityReport.swiftにあります。

// TotalActivityReport.swif

struct TotalActivityReport: DeviceActivityReportScene {
    // Define which context your scene will represent.
    let context: DeviceActivityReport.Context
    
    // Define the custom configuration and the resulting view for this report.
    let content: (String) -> TotalActivityView
    
    func makeConfiguration(representing data: DeviceActivityResults<DeviceActivityData>) async -> String {
        // Reformat the data into a configuration that can be used to create
        // the report's view.
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.day, .hour, .minute, .second]
        formatter.unitsStyle = .abbreviated
        formatter.zeroFormattingBehavior = .dropAll
        
        let totalActivityDuration = await data.flatMap { $0.activitySegments }.reduce(0, {
            $0 + $1.totalActivityDuration
        })
        return formatter.string(from: totalActivityDuration) ?? "No activity data"
    }
}

この内の、representing data: DeviceActivityResultsがデータそのものです。
また、DeviceActivityDataは1アカウント1デバイスごとのデータであるため、個人開発の場合は一個のデータしかありません。

// アカウント数が一個の場合
var firstData: DeviceActivityData?
for await item in data {
    firstData = item
}

また、そのデータ内にセグメントされた(1日ごとや一週間ごと)データが入っていますのでそれを取り出す

var firstData: DeviceActivityData?
for await item in data {
    firstData = item
}

var Intervals: [TimeInterval] = []
if let segments = firstData?.activitySegments {
    for await segment in segments {
        Intervals.append(segment.totalActivityDuration)
    }
}

これでセグメントごとのTimeIntervalができて処理できるというわけですが、

return Intervals

としたいので、返り値は[TimeInterval]で

// TotalActivityReport.swif
let content: ([TimeInterval]) -> TotalActivityView
// TotalActivityView.swift

struct TotalActivityView: View {
    let contextLabel: String
    let totalActivity: [TimeInterval]
    
    var body: some View {
        VStack {
            Text("\(contextLabel)")
            Text("\(totalActivity)")
        }
}

と設定すれば[TimeInterval]を使ったチャートを作成できます。

もし選んだアプリケーションごとの時間を計りたい人がいる場合。
2025年7月現在では、

サポートしていません!!!

なので諦めてください
ですが、カテゴリー別の時間取得は可能ですのでそちらでやってください
segments.categoriesには、セグメントごとの、各カテゴリーごとの時間などの情報があります。
そちらを使いましょう

カテゴリーでやる方に朗報です。
カテゴリーはずべて英語で表示する際に不便を要します。
なので変換関数を作っておきました。

func getJapaneseCategoryName(from localizedName: String) -> String {
    switch localizedName {
    case "Education":
        return "教育"
    case "Social":
        return "SNS"
    case "Games":
        return "ゲーム"
    case "Entertainment":
        return "エンターテインメント"
    case "Productivity & Finance":
        return "仕事効率化とファイナンス"
    case "Creativity":
        return "クリエイティビティ"
    case "Information & Reading":
        return "情報と読書"
    case "Health & Fitness":
        return "健康とフィットネス"
    case "Shopping & Food":
        return "ショッピングとフード"
    case "Utilities":
        return "ユーティリティ"
    case "Travel":
        return "旅行"
    case "Other":
        return "その他"
    default:
        return localizedName // 不明な場合はそのまま返す
    }
}

まとめ

ScreenTimeAPIはスクリーンタイムの取得というよりかは他のアプリの使用を制限する方が安定してるし実用性がある。
また、DeviceActivityがもっとも複雑で、難しかった。

1
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
1
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?