「すべてのカレンダーへのアクセス」という一文の重さ
習慣づけ系のアプリに「予定をカレンダーにも入れる」ボタンを試作したときのことです。実装そのものは半日で動いたのですが、TestFlight で配った知人数十人の反応が想像以上に渋いものでした。理由を聞いてまわると、ほぼ全員が同じ箇所を挙げます。フルアクセスの許可ダイアログに表示される「すべてのカレンダーの予定への アクセスを許可しますか」という一文です。仕事の予定も家庭の予定も見られるように読めるこの文言は、予定を1件書き込みたいだけの機能には明らかに過剰でした。手元の集計では、この時点の許可率は5割を切っていました。
転機は iOS 17 の EventKit 刷新を調べ直したことでした。書き込み専用アクセスに切り替え、さらに一部の導線を許可不要のシステム UI に寄せたところ、同じテスト群で「カレンダーに入れる」機能の利用完了率は8割台まで戻りました。個人開発では権限ダイアログの1枚が機能の生死を分けます。ここでは Rork Max で生成したネイティブ Swift アプリを前提に、EventKit の3段階のアクセスレベルをどう使い分けるか、動くコードと審査対策まで含めて実装します。
なお、アプリ内に自前のカレンダー画面を作る話とはレイヤーが異なります。自前カレンダー UI の構築は Rork でカレンダー・スケジュール管理アプリを作る初心者向けチュートリアル が扱っているので、本稿は「ユーザーが普段使っている標準カレンダー・リマインダーに書き込む」連携に絞ります。
iOS 17 で EventKit は3段階になりました
iOS 16 までのカレンダー権限は NSCalendarsUsageDescription 1本の全か無かでした。iOS 17 SDK でビルドするアプリからは、これが3段階に分かれています。
アクセスレベル できること 必要な Info.plist キー 許可ダイアログ 許可不要(EventKitUI) システム標準の編集画面経由で予定を追加。アプリはカレンダーデータに一切触れない 不要 表示されない 書き込み専用 コードから予定を直接保存。既存予定の読み取りは不可 NSCalendarsWriteOnlyAccessUsageDescription「予定の追加のみ」と明示される軽い文言 フルアクセス 予定の読み取り・検索・編集・削除まで全て NSCalendarsFullAccessUsageDescription「すべてのカレンダーの予定」に触れる重い文言
最初に踏む落とし穴がひとつあります。iOS 17 SDK でビルドしたバイナリでは、旧来の NSCalendarsUsageDescription を書いていても新 API の要求時に参照されず、キー欠落として実行時クラッシュします。Rork Max に「カレンダー連携を追加して」とだけ頼むと、学習データの都合か旧キーで Info.plist を生成してくることがあり、私自身もシミュレータで This app has crashed because it attempted to access privacy-sensitive data without a usage description に一度ぶつかりました。生成後に新キーへ置き換わっているかを必ず確認してください。
リマインダー側も同様に NSRemindersFullAccessUsageDescription へ移行しています。ただし後述のとおり、リマインダーには書き込み専用の段階が存在しません。この非対称が設計判断に効いてきます。
許可ゼロで予定を追加する — EKEventEditViewController
まず検討すべきは、権限をまったく要求しない導線です。EventKitUI の EKEventEditViewController は iOS 17 以降、アクセス許可なしで表示できます。編集画面がアプリ外のシステムプロセスで描画され、ユーザーが保存を押した内容はアプリからは読めない、という構造で成立している仕組みです。ダイアログが1枚も出ないため、離脱ポイントそのものが消えます。
SwiftUI から使うには UIViewControllerRepresentable で包みます。
import SwiftUI
import EventKitUI
// 許可不要でシステム標準の予定作成画面を出すラッパー
struct EventEditSheet : UIViewControllerRepresentable {
let title: String
let startDate: Date
let endDate: Date
let notes: String
@Environment (\.dismiss) private var dismiss
func makeUIViewController ( context : Context) -> EKEventEditViewController {
let store = EKEventStore () // 権限リクエストは呼ばない
let event = EKEvent ( eventStore : store)
event.title = title
event.startDate = startDate
event.endDate = endDate
event.notes = notes
let vc = EKEventEditViewController ()
vc.eventStore = store
vc.event = event // 初期値の事前入力は可能
vc.editViewDelegate = context.coordinator
return vc
}
func updateUIViewController ( _ vc: EKEventEditViewController, context : Context) {}
func makeCoordinator () -> Coordinator { Coordinator ( dismiss : dismiss) }
final class Coordinator : NSObject , EKEventEditViewDelegate {
let dismiss: DismissAction
init ( dismiss : DismissAction) { self .dismiss = dismiss }
func eventEditViewController ( _ controller: EKEventEditViewController,
didCompleteWith action: EKEventEditViewAction) {
dismiss () // .saved / .canceled どちらでも閉じる
}
}
}
呼び出し側は .sheet で提示するだけです。タイトルや開始時刻はこちらで事前入力できるので、ユーザーの操作は内容確認と「追加」タップの2手で済みます。
Rork Max へのプロンプトは、この構造を名指しで指定すると精度が上がります。私が実際に通した指示は次の形です。
予定詳細画面に「カレンダーに追加」ボタンを追加してください。
実装は EventKitUI の EKEventEditViewController を UIViewControllerRepresentable で
ラップし、sheet で表示する方式にしてください。EKEventStore への
requestFullAccessToEvents / requestWriteOnlyAccessToEvents は呼ばないでください。
Info.plist にカレンダー系の Usage Description キーは追加しないでください。
「権限を要求しない実装にして」と抽象的に頼むより、呼んではいけない API 名まで書く方が、生成コードの再現性は明らかに安定します。
書き込み専用アクセスで「サイレント登録」を実装する
システム UI を毎回挟むのが冗長な場面もあります。たとえば予約完了と同時に予定を自動登録したい、複数件をまとめて書き込みたい、という要件です。ここで使うのが iOS 17 の書き込み専用アクセス requestWriteOnlyAccessToEvents() です。
import EventKit
enum CalendarWriter {
static let store = EKEventStore ()
// 書き込み専用アクセスを確認してから予定を保存する
static func addEvent ( title : String , start : Date, end : Date,
alarmMinutesBefore : Int ? = nil ) async throws -> Bool {
var status = EKEventStore. authorizationStatus ( for : .event)
if status == .notDetermined {
_ = try await store. requestWriteOnlyAccessToEvents ()
status = EKEventStore. authorizationStatus ( for : .event)
}
// .writeOnly と .fullAccess のどちらでも書き込みは可能
guard status == .writeOnly || status == .fullAccess else { return false }
let event = EKEvent ( eventStore : store)
event.title = title
event.startDate = start
event.endDate = end
event.calendar = store.defaultCalendarForNewEvents
if let minutes = alarmMinutesBefore {
event. addAlarm ( EKAlarm ( relativeOffset : TimeInterval ( - minutes * 60 )))
}
try store. save (event, span : .thisEvent)
return true
}
}
Info.plist には NSCalendarsWriteOnlyAccessUsageDescription を追加します。このダイアログは「このアプリはカレンダーに予定を追加できます(読み取りは不可)」という趣旨の軽い文言になり、体感の抵抗がまるで違います。冒頭に書いたとおり、手元のテストではフルアクセス時に5割を切っていた許可率が、書き込み専用への変更後は8割台まで回復しました。母数の小さい参考値ですが、文言の重さが直接効くことを確認するには十分でした。
制約もはっきりしています。書き込み専用では既存予定を一切読めないため、「同じ予定を二重登録していないか」のチェックができません。連打や再訪で同じ予定が2件並ぶ問題には、保存済みの event.eventIdentifier を UserDefaults 等に控えておき、自アプリ発の登録済みフラグとして扱う回避策が現実的です。識別子の照合そのものに読み取り権限は不要です。
フルアクセスが本当に必要な場面は思ったより狭い
フルアクセス requestFullAccessToEvents() が要るのは、既存予定の表示・検索・更新が要件に入るときだけです。典型的には「ユーザーの空き時間を計算して提案する」「外部の予定と突き合わせる」類の機能です。
// フルアクセス前提: 直近7日の予定を取得して重複判定に使う
static func fetchUpcoming ( days : Int = 7 ) async throws -> [EKEvent] {
guard try await store. requestFullAccessToEvents () else { return [] }
let start = Date ()
let end = Calendar.current. date ( byAdding : .day, value : days, to : start) !
let predicate = store. predicateForEvents ( withStart : start, end : end, calendars : nil )
return store. events ( matching : predicate)
}
私の判断基準は単純で、「画面に他人の予定(ユーザーが自分で入れた既存予定)を描画する必要があるか」です。描画しないならフルアクセスは要りません。逆にここを曖昧にしたまま「とりあえずフルアクセスで」と実装すると、次節の審査文言も重くなり、許可率も下がり、良いことがひとつもありません。段階を上げるのは要件が確定してからで遅くないです。
リマインダー連携には書き込み専用がありません
期限つきのタスクは、カレンダーよりリマインダー(標準の Reminders アプリ)の方が収まりが良い場面があります。ただし EventKit のリマインダーには書き込み専用の段階が用意されておらず、requestFullAccessToReminders() の一択です。
// リマインダーはフルアクセスのみ。要求は実際に使う瞬間まで遅延させる
static func addReminder ( title : String , due : DateComponents) async throws -> Bool {
let store = EKEventStore ()
guard try await store. requestFullAccessToReminders () else { return false }
let reminder = EKReminder ( eventStore : store)
reminder.title = title
reminder.dueDateComponents = due // 例: 年月日+時分まで指定
reminder.calendar = store. defaultCalendarForNewReminders ()
reminder. addAlarm ( EKAlarm ( absoluteDate : Calendar.current. date ( from : due) ! ))
try store. save (reminder, commit : true )
return true
}
権限の非対称を踏まえると、機能の主従を決めておく必要があります。私はこの場面では「主導線はカレンダー(書き込み専用)、リマインダーはおまけの選択肢」という構成を好みます。逆にタスク管理が主戦場のアプリなら、リマインダー連携の初回要求時にだけ丁寧な事前説明画面を挟む価値があります。権限を求める直前に1画面挟んで文脈を作る手法は、ATT の事前許可プライミング設計 で書いた考え方がそのまま流用できます。
審査と権限文言 — リジェクトを避ける書き方
EventKit まわりの審査指摘は、経験上ほぼ Guideline 5.1.1(目的文言の具体性不足)に集中します。押さえる点は3つです。
Usage Description は「何を・なぜ」を1文に入れる。 「カレンダーを使用します」は不十分です。「予約した講座の開始時刻を、お使いのカレンダーに予定として追加するために使用します」のように、書き込む対象と理由を具体化します。
要求タイミングは機能の実行直前に限定する。 起動直後にまとめて権限を要求する実装は、文言がどれだけ丁寧でも印象が悪く、許可率も下がります。ボタンが押された瞬間に要求するのが原則です。
プライバシーラベルとの整合を取る。 予定の内容を自社サーバへ送らないなら、App Store Connect のデータ収集申告に「カレンダーデータを収集」を立てる必要はありません。逆に送るなら申告漏れが即リジェクト要因になります。書き込み専用で完結する設計は、この申告を最小で済ませられる点でも有利です。
似た発想の「段階を下げた権限設計」は iOS 18 の連絡先まわりにも入っており、iOS 18 の連絡先アクセスボタンと限定アクセスの設計 と合わせて読むと、Apple がこの数年で権限 UI を「全か無か」から「必要最小限の段階制」へ寄せている流れが掴めるはずです。
まとめ — まず許可不要の導線から始める
カレンダー連携の実装順序は、上から順に「EKEventEditViewController(許可不要)→ 書き込み専用 → フルアクセス」で検討するのが、許可率・審査・実装量のすべてで有利でした。最初の一歩としては、既存アプリの詳細画面に本稿の EventEditSheet をそのまま組み込み、権限ダイアログなしで予定が1件入る体験を確かめてみてください。ダイアログが出ないだけで、機能の使われ方がはっきり変わるはずです。