De iOS Screen Time API in 2026: wat de documentatie van Apple je niet vertelt

FamilyControls, DeviceActivity en shield-extensies, uit een app blocker die echt in de store staat. Door Thomas Willheim · 30 september 2026

In augustus en september 2026 heb ik een app blocker voor iOS uitgebracht. Hij gebruikt de Screen Time-frameworks (FamilyControls, DeviceActivity, ManagedSettings en ManagedSettingsUI) vanuit React Native, via Expo SDK 57 en de library react-native-device-activity (0.6.1). De documentatie van Apple vertelt je wat elk type is. Over hoe ze zich gedragen op een echte telefoon, ’s nachts, op iOS 26, zegt ze heel weinig.

Dit is het lijstje dat ik zelf had willen hebben. Gedrag dat ik op een toestel zag maar nergens gedocumenteerd vind, heeft het label waargenomen; het klopt misschien niet op elke iOS-versie. Punten die specifiek over de React Native-library gaan, hebben het label library; die kun je overslaan als je direct in Swift schrijft.

1. De onderdelen

‘De Screen Time API’ bestaat uit vier frameworks en in de praktijk drie app-extensies:

Om die extensies draait het. Als een gebruiker door zijn tijd heen is, is mijn app meestal gepauzeerd of afgesloten, dus het is de monitor-extensie die het shield weer neerzet. Ontwerp alsof je app-proces er nooit is op het moment dat er iets gebeurt.

2. Entitlements: één aanvraag per bundle ID

Het Family Controls (Distribution)-entitlement wordt per bundle ID goedgekeurd. Met drie extensies zijn dat vier aanvragen. De mijne waren:

Zodra ze zijn goedgekeurd, zet je de capability Family Controls (Distribution) aan op elke App ID in het developerportaal. Daarna maak je één App Group aan en koppel je die aan alle vier. Vergeet je er een, dan lukt de provisioning voor dat target niet. Goedkeuring gaat niet meteen, dus dien alle vier de aanvragen op de eerste dag in, voordat je veel code hebt geschreven.

Het entitlements-bestand van elke extensie ziet er uiteindelijk hetzelfde uit:

<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
  <string>group.com.honorfit.blocker</string>
</array>

Op Expo/EAS maakt de buildservice alleen provisioning profiles aan voor extensies die hij kent. Ik declareer elk target, met zijn entitlements, onder extra.eas.build.experimental.ios.appExtensions in app.json:

"appExtensions": [
  {
    "bundleIdentifier": "com.honorfit.blocker.ShieldConfiguration",
    "targetName": "ShieldConfiguration",
    "entitlements": {
      "com.apple.developer.family-controls": true,
      "com.apple.security.application-groups": ["group.com.honorfit.blocker"]
    }
  }
  // ...same for ShieldAction and ActivityMonitorExtension
]

3. De App Group is het enige kanaal

Je app en zijn extensies zijn aparte processen die bijna nooit tegelijk draaien. Het enige wat ze delen is de container van de App Group, meestal als UserDefaults(suiteName:). Alles wat de extensies nodig hebben (de opgeslagen selectie, de tekst op het shield, wat er moet gebeuren als een drempel afgaat) moet daar van tevoren in geschreven zijn.

react-native-device-activity trekt dit helemaal door. De app schrijft een lijst ‘actions’ onder een sleutel als actions_for_{activity}_{callback}_{event}, en de monitor-extensie zoekt ze op op het moment dat de callback afgaat. Handig gevolg: je kunt veranderen wat een monitor die al draait doet, zonder hem opnieuw te starten. De extensies roepen ook CFPreferencesAppSynchronize aan voordat ze lezen, zodat ze de laatste wijzigingen van de app zien:

let appGroup = Bundle.main.object(
  forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)

// in each callback:
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication)
if let actions = userDefaults?.array(forKey: triggeredBy) { /* run them */ }

4. Tokens zijn ondoorzichtig, en de kiezer

De kiezer geeft je app ApplicationTokens, ActivityCategoryTokens en WebDomainTokens, en verder niets. Zo is het ontworpen: de app kan een token niet terugvertalen naar ‘Instagram’. Wat hij wel kan, is ze tellen, dus mijn blokkeerlijst toont ‘3 apps, 1 categorie’. Dat is het hele privacymodel, en het is een goed model: mijn app weet letterlijk niet wat je hebt geblokkeerd.

De app-kiezer van Schermtijd op iOS: een lijst met app-categorieën, waarvan Gezondheid en fitness is opengeklapt met de apps erin
De FamilyActivityPicker van Apple. De gebruiker ziet namen en iconen van apps; je app krijgt alleen ondoorzichtige tokens terug.

In de shield-extensie werkt het anders. Daar geeft het systeem je een Application voor de app die wordt afgeschermd, en ik gebruik de localizedDisplayName daarvan in de tekst op het shield. De app zelf ziet alleen ooit tokens.

Twee praktische punten:

<DeviceActivitySelectionSheetViewPersisted
  familyActivitySelectionId="distracting-apps"
  includeEntireCategory={false}   // must be present, or nothing preloads
  onSelectionChange={(e) => setCounts(e.nativeEvent)}
  onDismissRequest={() => setPickerVisible(false)}
/>

library Nog eentje: blockSelection van de library voegt tokens toe aan een opgeslagen blokkeerlijst. Past de gebruiker zijn selectie aan, dan blijven de verwijderde apps afgeschermd. Ik roep daarom elke keer eerst resetBlocks() aan en blokkeer daarna de huidige selectie opnieuw.

5. Gebruik meten met drempelevents

HonorFit geeft je een saldo aan minuten dat alleen afloopt terwijl er een geblokkeerde app op je scherm staat. DeviceActivity heeft geen aanroep in de trant van ‘vertel me hoe lang ze het hebben gebruikt’; het heeft drempelevents die afgaan zodra het opgetelde gebruik van een selectie een bepaalde duur bereikt. Dus registreer ik één event per 15 seconden gebruik, met de naam in minuten: used-0.25, used-0.5, … tot aan het saldo. Kwartjes zijn exact in binaire floating point, dus de namen komen ongeschonden terug via Number(). Onder de library is dit gewoon de API van Apple:

let schedule = DeviceActivitySchedule(
  intervalStart: DateComponents(hour: 9, minute: 45, second: 0),  // see section 6
  intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
  repeats: false
)
let event = DeviceActivityEvent(
  applications: selection.applicationTokens,
  categories: selection.categoryTokens,
  webDomains: selection.webDomainTokens,
  threshold: DateComponents(minute: 0, second: 15),
  includesPastActivity: false          // iOS 17.4+
)
try DeviceActivityCenter().startMonitoring(
  DeviceActivityName("honorfit-distracting-apps-1790000000000"),
  during: schedule,
  events: [DeviceActivityEvent.Name("used-0.25"): event]  // ...one per step
)

includesPastActivity: false (de standaard) betekent dat gebruik van vóór het registreren van de events niet meetelt. Dat is belangrijk voor de truc met het schema in de volgende sectie.

Als het laatste event afgaat, zet de monitor-extensie het shield weer neer en stuurt hij een melding dat de tijd op is. Elk event van 15 seconden werkt één melding bij met een vaste identifier, zodat die op zijn plek wordt vervangen en als een ruwe live aftelling werkt. Met interruptionLevel = .passive komt hij stil in het Berichtencentrum terecht, zonder banner.

library Gebruik geen underscores in de namen van events of activiteiten. De library slaat afgegane events op onder events_{activity}_{callback}_{eventName} en leest ze terug door te splitsen op _. Een event met de naam used_7 komt terug als used, en er matcht nooit iets. Het symptoom was een saldo dat nooit omlaag ging.

6. Schema’s starten niet wanneer je denkt

Gaan je drempelevents stilletjes nooit af, dan is het waarschijnlijk dit.

‘Nu’ ligt al in het verleden

waargenomen Een niet-herhalend DeviceActivitySchedule wordt vergeleken met datumcomponenten, en de monitor draait het eerstvolgende interval dat past. Geef je de huidige kloktijd mee als intervalStart, dan is dat moment voorbij tegen de tijd dat het framework het verwerkt. De volgende match is dan morgen, en vandaag gaat er niets af: geen drempels, geen callbacks, geen foutmelding. De oplossing is de start terug te zetten, zodat ‘nu’ binnen het interval valt:

const now = new Date();
const backdated = new Date(now.getTime() - 15 * 60_000);
const intervalStart =
  backdated.getDate() === now.getDate()
    ? { hour: backdated.getHours(), minute: backdated.getMinutes(), second: backdated.getSeconds() }
    : { hour: 0, minute: 0, second: 0 };   // don't wrap past midnight

await startMonitoring(activityName,
  { intervalStart, intervalEnd: { hour: 23, minute: 59, second: 59 }, repeats: false },
  events);

Door includesPastActivity: false kan het terugzetten de gebruiker niet laten betalen voor eerder scrollen. Het houdt het interval ook boven het minimum van Apple: schema’s korter dan 15 minuten worden geweigerd, en daar loop je tegenaan als je vlak voor middernacht een sessie start.

Een schema waar ‘nu’ in valt, start meteen

waargenomen De keerzijde: een schema van 00:00–23:59:59 bevat het huidige tijdstip, dus het start nu, niet bij de volgende middernacht. Ik vond twee manieren om morgen in te plannen:

waargenomen Gebruik geen volledige jaar/maand/dag-componenten in een schema om een specifieke datum te raken. Toen ik dat probeerde, werden de callbacks van de extensie onbetrouwbaar, en dat klopt met meldingen op de Apple Developer Forums (thread 729841). Houd het bij uur/minuut/seconde.

Een kanttekening: dit gedrag rond middernacht zie je alleen op een toestel, ’s nachts. Ik heb er guards omheen gebouwd, maar ik heb nog niet zoveel echte middernachten gemeten als ik zou willen, dus zie deze sectie als ‘wat ik heb gezien’, niet als specificatie.

7. stopMonitoring triggert intervalDidEnd

waargenomen Als je stopMonitoring aanroept, of startMonitoring met de naam van een monitor die al draait, roept iOS intervalDidEnd aan op je extensie, alsof het schema vanzelf was afgelopen. Zet jouw intervalDidEnd als vangnet het shield opnieuw neer (de mijne doet dat, zodat een monitor niet kan verlopen en apps onbeschermd achterlaat), dan schermt de extensie elke keer dat de app een monitor stopt heel even alles af.

Een gebruiker meldde het symptoom: Spotify pauzeerde elke keer dat hij HonorFit opende. Door het stoppen van de monitor schermde de extensie de selectie even af, waardoor Spotify werd gepauzeerd, voordat de app de shields weer weghaalde.

Twee oplossingen, en je hebt ze allebei nodig. Ten eerste: verwijder de actions van de extensie voordat je stopt, zodat er niets is om uit te voeren:

function stopActivities(names: string[]): void {
  for (const name of names) RNDA.cleanUpAfterActivity(name); // clear actions first
  RNDA.stopMonitoring(names);                                // then stop
}

Ten tweede: geef elke action van intervalDidEnd en intervalDidStart een ‘niet eerder dan’-tijdstempel op de echte grens van het schema, min een minuut speling. De extensie controleert die voordat ze iets uitvoert:

// TypeScript: register the safety net
RNDA.configureActions({
  activityName,
  callbackName: 'intervalDidEnd',
  actions: [{
    type: 'blockSelection',
    familyActivitySelectionId: 'distracting-apps',
    neverTriggerBefore: new Date(intervalEndMs - 60_000),
  }],
});

// Swift, in the DeviceActivityMonitor extension
if let neverTriggerBefore = neverTriggerBefore {
  let now = Date().timeIntervalSince1970 * 1000
  if now < neverTriggerBefore { return false }   // skip this action
}

In gewone Swift: sla de echte eindtijd op in de App Group en laat intervalDidEnd afhaken als hij te vroeg wordt aangeroepen.

8. Spookdrempels in iOS 26

Op iOS 26.x gaat eventDidReachThreshold soms af zonder dat er echt iets is gebruikt: vlak na het inplannen, of als een telefoon die een tijdje vergrendeld lag wordt opgepakt of aan de lader gaat. Apple heeft dit erkend in threads op de developerforums (809410, 811305, 814559). Het zou zijn opgelost in een bèta van 26.5, maar daarna bleven mensen het melden. In mijn app was het symptoom ‘tijd is op’ nadat de telefoon in een broekzak had gezeten, omdat de extensie het shield weer neerzette en het saldo naar nul liep.

Je kunt niet voorkomen dat het event afgaat, maar je kunt weigeren het te geloven. Twee feiten over echte drempels doen het meeste werk:

  1. Gebruik kan de klok niet inhalen. used-7 kan niet terecht afgaan binnen 7 minuten nadat de monitor is gestart. Elke drempel-action krijgt neverTriggerBefore = armedAt + N minutes − 5s mee, zodat de extensie onmogelijk vroege events overslaat.
  2. Echt gebruik komt stukje bij beetje binnen; spookevents komen in één klap. Als de app de afgegane events terugleest, behandelt hij ze als spook wanneer de laagste en de hoogste drempel binnen ongeveer 3 seconden van elkaar zijn afgegaan, terwijl ze een minuut gebruik of meer uit elkaar liggen.
// Guard 1: drop thresholds that fired before they could have been reached
if (at < startedAt + minutes * 60_000 - 5_000) continue;

// Guard 2: lowest and highest fired in the same instant, a minute or more apart
if (highest.minutes - lowest.minutes >= 1 &&
    Math.abs(highest.at - lowest.at) <= 3_000) {
  return { usedMinutes: 0, partialMs: 0 };   // phantom: ignore the lot
}

Guard 2 draait in de app, niet in de extensie, dus een spookevent kan het shield nog steeds neerzetten. Het saldo blijft wel overeind, en de volgende keer dat de app wordt geopend, haalt hij het shield weer weg. Burstdetectie in de extensie zelf zou eigen Swift-code vragen, en die heb ik nog niet geschreven.

waargenomen Een verwant probleem werkt precies andersom: drempels die zouden moeten afgaan en dat niet doen. Bij gebruik met tussenpozen op iOS 26 zag ik de eerste drempel van 15 seconden van een verse monitor op tijd afgaan, en latere samengevoegd worden of helemaal wegvallen (used-0.25 ging af, used-0.5 en hoger nooit). Mijn workaround: elke monitor die al gebruik heeft gemeten, behandel ik als verouderd, en elke keer dat de app naar de achtergrond gaat start ik een verse, over het resterende saldo. Daardoor schiet ik per herstart tot 15 seconden gebruik kwijt, en dat vind ik prima.

9. Een shield kan je app niet openen

Het voor de hand liggende ontwerp is een knop op het shield met ‘Verdien meer tijd’ die je app opent. Daar is geen ondersteunde manier voor; Apple DTS zegt dat er geen API is waarmee een shield-extensie zijn hoofdapp kan starten. Een ShieldActionDelegate beantwoordt een druk op de knop met een ShieldActionResponse (.none, .close of .defer), en geen daarvan opent iets.

waargenomen De workaround die je tegenkomt (NSExtensionContext().open(url) vanuit de ShieldAction-extensie, door de library aangeboden als openUrl) deed niets op de huidige iOS toen ik het in september 2026 op een toestel testte. De knop sloot gewoon het shield. Een melding sturen bij het tikken op de knop was voor mij ook onbetrouwbaar.

Wat ik in plaats daarvan gebruik: het shield heeft één knop, ‘Sluiten’, en de ShieldConfiguration-extensie stuurt een lokale melding zodra het shield wordt getoond. De gebruiker tikt op de banner en komt in de app terecht. De app schrijft de inhoud van de melding van tevoren naar de App Group en haalt de melding weg zodra hij op de voorgrond komt. iOS kan per keer dat het shield verschijnt meerdere keren om de shield-configuratie vragen, dus het versturen heeft een debounce:

func postShieldShownNotification(placeholders: [String: String?]) {
  guard let config = userDefaults?.dictionary(forKey: "honorfitShieldShownNotification"),
        let payload = config["payload"] as? [String: Any] else { return }

  let minInterval = config["minIntervalSeconds"] as? Double ?? 30
  let now = Date().timeIntervalSince1970
  let lastSentAt = userDefaults?.double(forKey: "honorfitShieldShownNotificationLastSentAt") ?? 0
  if now - lastSentAt < minInterval { return }
  userDefaults?.set(now, forKey: "honorfitShieldShownNotificationLastSentAt")

  // builds a UNMutableNotificationContent and adds a UNNotificationRequest
  // with a fixed identifier, so repeats replace rather than stack
  sendNotification(contents: payload, placeholders: placeholders)
}

class ShieldConfigurationExtension: ShieldConfigurationDataSource {
  override func configuration(shielding application: Application) -> ShieldConfiguration {
    postShieldShownNotification(placeholders: [
      "applicationOrDomainDisplayName": application.localizedDisplayName
    ])
    return buildShield(/* title, subtitle, colors from the App Group */)
  }
  // ...same hook in the category and web-domain overloads
}

Het is een hack, en het werkt alleen als de gebruiker meldingen toestaat, maar het is de enige route naar de app die bij mij betrouwbaar werkte.

10. Eigen extensies behouden bij prebuild

library Gebruik je Expo, dan draait expo prebuild de config-plugin van react-native-device-activity, en die kopieert standaard bij elke run de standaard extensiebronnen van de library naar ./targets. Mijn aangepaste ShieldConfiguration werd zonder waarschuwing overschreven. De oplossing is één optie van de plugin:

["react-native-device-activity", {
  "appleTeamId": "XXXXXXXXXX",
  "appGroup": "group.com.honorfit.blocker",
  "copyToTargetFolder": false
}]

De prijs: als je de library bijwerkt, moet je de targets/ en de gedeelde Swift-code daarvan met de hand vergelijken met de jouwe en je wijzigingen opnieuw toepassen.

11. Xcode 26, de Simulator en testen

12. En op Android

Ter vergelijking: Android heeft geen officiële tegenhanger, dus daar bouw je het zelf. De blocker van HonorFit op Android is een foreground service (type specialUse, omdat app-blokkering bij geen van de genoemde types past) die zolang het scherm aan staat elke seconde UsageStatsManager uitvraagt om te weten welke app vooraan staat. Staat er een geblokkeerde app open en is het saldo nul, dan tekent hij er een vergrendelscherm van het type TYPE_APPLICATION_OVERLAY overheen (‘Weergeven over andere apps’). Een boot-receiver en een watchdog-alarm starten de service opnieuw na herstarts, app-updates en agressieve batterijbeheerders van fabrikanten.

De afwegingen wijzen de andere kant op dan bij iOS. Je ziet echte pakketnamen, dus privacy is een belofte die jij nakomt in plaats van een die het besturingssysteem afdwingt. Een geblokkeerde app kan tot één polling-interval lang zichtbaar zijn, en hij blijft onder de overlay doordraaien. En je schrijft verklaringen in de Play Console voor het type foreground service, de overlay-toestemming en pakketzichtbaarheid. Een AccessibilityService heb ik bewust vermeden; die krijgt de strengste review van allemaal.

13. Checklist

SymptoomWaarschijnlijke oorzaakOplossing
Provisioning mislukt voor één targetEntitlement of App Group ontbreekt op die bundle IDVraag Family Controls (Distribution) aan en koppel de App Group voor elke bundle ID
Nooit drempeleventsintervalStart = nu, dus de monitor staat ingepland voor morgenZet de start terug; houd het interval ≥ 15 min
Monitor voor morgen draait vandaag alHet schema bevat het huidige tijdstipSchema dat vandaag al verlopen is ([00:00, T)), of repeats: true met afgeschermde actions
Apps krijgen even een shield als je app opentstopMonitoring triggert intervalDidEndWis de actions voor het stoppen; guard met een niet-eerder-dan-tijd
‘Tijd is op’ nadat de telefoon stil lagSpookdrempels in iOS 26Klokguard plus burstfilter
Latere drempels gaan nooit afWeggevallen/samengevoegd bij gebruik met tussenpozenStart een verse monitor bij elke keer naar de achtergrond
Knop op het shield opent de app nietGeen ondersteunde APIStuur een melding vanuit ShieldConfiguration als het shield wordt getoond
Afgegane events matchen niet (RN)Underscore in de eventnaamGebruik used-7, niet used_7
Kiezer opent leeg (RN)Prop includeEntireCategory ontbreektGeef hem mee, met welke waarde dan ook

14. Korte antwoorden

Heb ik het Family Controls-entitlement nodig voor elke extensie?

Ja. Het wordt per bundle ID toegekend. De app plus een DeviceActivityMonitor-, een ShieldConfiguration- en een ShieldAction-extensie zijn samen vier goedkeuringen, en elke App ID heeft ook de gedeelde App Group nodig.

Waarom gaat eventDidReachThreshold van DeviceActivity niet af?

Bij mij was het meestal een niet-herhalend schema dat ‘nu’ begon en daardoor voor morgen werd ingepland. Zet intervalStart terug. Op iOS 26 vielen bij gebruik met tussenpozen ook latere drempels van minder dan een minuut weg; een verse monitor starten hielp.

Kan een ShieldAction- of ShieldConfiguration-extensie de hoofdapp openen?

Niet met een ondersteunde API, en de workaround met NSExtensionContext deed in mijn tests niets op de huidige iOS. Wat ik gebruik, is een lokale melding die ShieldConfiguration stuurt als het shield wordt getoond.

Kan ik de Screen Time API testen in de iOS Simulator?

Deels. In de Simulator met iOS 26 verschijnen de toestemmingsvraag en de kiezer, maar de toestemming werd niet afgerond, de kiezer had niets om te tonen en de monitor-extensie draaide nooit. Gebruik een echt toestel.

15. Waarom ik dit allemaal nodig had

Ik heb HonorFit gebouwd, een app blocker waarmee je schermtijd verdient door op te drukken. Het werkt op erewoord: je typt je herhalingen in, en er is geen camera, geen AI en geen netwerkverkeer. Alles hierboven is wat ervoor nodig was om ‘je verdiende minuten lopen alleen af terwijl er een geblokkeerde app op je scherm staat’ werkend te krijgen op een iPhone. Wil je het in actie zien, dan staat het in de App Store en op Google Play. Ben je hier omdat je zo’n app wilt gebruiken in plaats van er een te bouwen, dan heb ik uitgelegd hoe app blockers met opdrukken werken (Engels) en een eerlijk overzicht gemaakt van wat er te krijgen is (Engels), concurrenten inbegrepen. Ben je tegen Screen Time-gedrag aangelopen dat hier niet staat, of klopt er iets van het bovenstaande niet meer op een nieuwere iOS, dan hoor ik het graag.

Lees verder