「友だちを招待する」ボタンを押した瞬間に、「"アプリ名" がすべての連絡先へのアクセスを求めています」と出る——これを見て手が止まる人は少なくありません。私自身、個人開発で長くアプリを出していますが、たった1件を共有したいだけの人にとって、全連絡先の開示要求は明らかに重すぎる取引です。
iOS 18 では、連絡先アクセスに「限定(limited)」という段階が加わりました。そして ContactAccessButton を置くと、許可ダイアログを出すことすらせずに、ユーザーがその場で選んだ連絡先だけ をアプリに渡せます。全許可は要りません。Rork Max が生成するネイティブ Swift を前提に、設計の勘所と実装を追います。
「全許可・全拒否」しかなかった頃の問題
これまでの連絡先は、実質「全部見せるか、何も見せないか」の二択でした。1件だけ招待したいユーザーにも全開示を求めるため、拒否されれば機能ごと死にます。個人開発の規模では、この一度の拒否がそのまま離脱になりがちです。
iOS 18 の限定アクセスは、この二択を崩します。ユーザーは「この人とこの人だけ」を選んで共有でき、アプリはその範囲だけを読めます。ContactAccessButton は、その選択をボタン1つに畳み込んだ部品です。
ContactAccessButton を置く
ContactAccessButton は SwiftUI のビューです。検索文字列を渡すと、その条件に合う連絡先を内部で探し、ユーザーがタップした1件だけをアプリへ渡します。重要なのは、このボタン自体は許可ダイアログを出さない ことです。
import SwiftUI
import ContactsUI
import Contacts
struct InviteField : View {
@State private var query: String = ""
@State private var picked: [ String ] = [] // 受け取った連絡先の識別子
var body: some View {
VStack {
TextField ( "名前で検索" , text : $query)
. textFieldStyle (.roundedBorder)
ContactAccessButton ( queryString : query) { identifiers in
// ユーザーがタップした連絡先だけがここに届く
picked. append ( contentsOf : identifiers)
fetchPickedDetails (identifiers)
}
. frame ( height : 44 )
}
}
}
ユーザーが入力した名前に一致する候補がボタンの中に現れ、タップされたものだけが identifiers として返ってきます。全件を読む権限を一度も求めずに、必要な1件を受け取れます。
受け取った連絡先を読む
ContactAccessButton が返すのは識別子です。実際の名前や電話番号は CNContactStore から取りますが、ここで読めるのは限定アクセスで共有された範囲だけ です。全件は見えません。
func fetchPickedDetails ( _ ids: [ String ]) {
let store = CNContactStore ()
let keys = [CNContactGivenNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor]
let request = CNContactFetchRequest ( keysToFetch : keys)
request.predicate = CNContact. predicateForContacts ( withIdentifiers : ids)
try? store. enumerateContacts ( with : request) { contact, _ in
// 共有された連絡先だけがここを通る
print (contact.givenName)
}
}
ここがプライバシー設計の肝です。ボタンで選ばれた範囲=アプリが読める範囲なので、「うっかり全件を舐めてしまう」事故が原理的に起きません。
認可状態を3つの世界として扱う
限定アクセスが入ったことで、認可状態は「許可・拒否」の2値ではなくなりました。設計上は3つの世界として分けて扱います。
authorized(全許可)
ユーザーが明示的に全連絡先を許可した状態です。既存の全件読み込みコードはこのときだけ動かします。
limited(限定)
iOS 18 で増えた状態です。共有されたぶんだけが読め、追加は ContactAccessButton か後述のピッカーから行います。ここを authorized と同じ扱いにすると、見えない連絡先を探して無言で失敗します 。
notDetermined / denied
まだ決めていない、あるいは拒否された状態です。ここでも ContactAccessButton は機能するので、「拒否されたら招待機能ごと無効」にせず、ボタン経由の1件共有だけは残しておくことを強くお勧めします。
共有範囲を後から増減できるようにする
一度限定で共有した後、ユーザーが「もう少し足したい」と思うこともあります。ContactAccessPicker を出すと、システム製の画面で共有する連絡先を追加・削除できます。
. contactAccessPicker ( isPresented : $showPicker) { identifiers in
// 共有範囲が更新された
}
設定アプリへ飛ばす代わりに、アプリ内のこのピッカーで完結できるので、離脱が減ります。共有を「増やす」だけでなく「減らす」操作も同じ場所でできることは、ユーザーの安心感に直結します。
なぜこの設計が「通過率」に効くのか
連絡先の許可は、ユーザーが最も警戒する権限のひとつです。住所録は人間関係そのものなので、「全部見せて」と言われた瞬間に身構えるのは自然な反応だと思います。私自身、招待やシェアの導線でこの全許可ダイアログを出していた頃は、そこで離脱する人の多さに悩んでいました。
限定アクセスは、この警戒の対象を「住所録ぜんぶ」から「いま選ぶこの1件」へと小さくします。ユーザーが払うコストが下がるぶん、同じ機能でも先へ進む人が増えます。私の経験では、許可の粒度を細かくする変更は、派手な機能追加よりも地味に効きます。招待や紹介のように「1件あれば成立する」機能ほど、この恩恵は大きくなります。
設計判断としては、まず「本当に全件が必要な画面はどれか」を洗い出すことを推奨します。多くのアプリでは、全件が要る画面はごく一部で、残りは1件単位の共有で十分です。全件が要る画面だけ authorized を求め、それ以外はすべて ContactAccessButton に寄せる、という切り分けが現実的です。
移行とつまずきやすいところ
旧コードからの移行では、まず CNContactStore.authorizationStatus(for: .contacts) の分岐に .limited を足します。ここを .authorized と同じ枝に流し込むと、限定ユーザーに対して全件前提のクエリが走り、結果が空で返って「連絡先が読めない不具合」に見えます。本番運用に出す前に、限定アクセスの実機で一度通すことが欠かせません。
もうひとつは、Info.plist の NSContactsUsageDescription です。限定アクセスでも理由文は必要で、ここが空だと審査で弾かれます。文面は「なぜ連絡先が要るのか」をユーザー目線で書きます。
もうひとつの落とし穴は、ContactAccessButton の検索文字列を空のまま放置することです。クエリが空だと候補が広く出てしまい、ボタンの利点である「狙った1件」が薄れます。入力に連動して queryString を更新する作りにすることで回避できます。
注意点として、限定アクセスは「ユーザーがいつでも共有を減らせる」前提で設計します。アプリが過去に受け取った識別子をキャッシュしていても、ユーザーが共有を解除すれば次の読み取りは空で返ります。キャッシュを正として扱わず、毎回ストアから取り直すことで、この不整合を未然に対処できます。App Store の審査でも、限定アクセスを正しく扱えているかは見られるポイントなので、ここを丁寧に作っておくと安心です。
まずは招待やシェアなど「連絡先が1件あれば足りる」画面を1つ選び、そこを ContactAccessButton に置き換えてみてください。全許可ダイアログが消えるだけで、その画面の通過率は目に見えて変わります。