![macOS-10.15.3](https://img.shields.io/badge/macOS-10.15.3 -blue) ![Xcode-11.3.1](https://img.shields.io/badge/Xcode-11.3.1 -blue) ![iPadOS-13.3.1](https://img.shields.io/badge/iPadOS-13.3.1 -blue)
まえがき
iCloudに置いたファイルをユーザー/アプリ間で共有し、どちらからでも自由にアクセスできる機能を実装します。(Keynoteとかがそうですね)
iCloud Documents Storageを使うと上手くいきそうな事はすぐ分かったのですが、実際に動くところまで持っていくのに結構苦労しました。そこで得られた知見を共有したいと思います。
公式ドキュメントはこちらです。
**Apple Developer Programに登録したアカウントが必要**です。
下準備
実装を始める前に、環境構築します。
プロジェクトの作成
Xcodeを立ち上げて、ツールバーからFile → New → Project... を選んでください。
Single View Appを選択し、 好きなProduct Nameを入力します。
Capabilityの追加
iCloudの機能を使うには、Capabilityを追加する必要があります。
赤丸で囲ったボタンを押して、iCloudを追加してください。
その後、iCloud Documents
にチェックを入れます。
コンテナの追加
iCloudのCapabilityを追加したからと言って、iCloud内の全てにアクセスはできません。コンテナ(フォルダみたいなもの)を指定し、そのコンテナ内のみアクセスできます。
+ボタンを押して新しくコンテナを作成します。コンテナ名は好きなものを入れてください。こだわりが無ければProduct Nameと同じでいいと思います。
公式ドキュメントにある通り、コンテナは一度作ると削除することができません。typoしてないかよく確認してください。テスト用に適当に作ったものももちろん削除できないので注意してください。
コンテナ作成後、文字が赤くなっている場合は更新ボタンを押してください。
Info.plistの編集
Info.plistを右クリックし、 Open As → Source Codeを選びます。そして、以下のコードを追加してください。
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.kakeru.iCloudDocumentTest</key> ← ここにコンテナ名を入れます
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>iCloudDocumentTest</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
</dict>
</dict>
Property List形式だと以下のような形になります。
- NSUbiquitousContainerIsDocumentScopePublic
- trueにするとコンテナのDocumentsフォルダ内が、iCloud Drive上にフォルダとして見えるようになります。
- NSUbiquitousContainerName
- iCloud Drive上で表示するフォルダ名です。
- NSUbiquitousContainerSupportedFolderLevels
- None: Documentsフォルダ内には、フォルダを作ることができません。
- One: Documentsフォルダ直下であれば、フォルダを作れます。
- Any: 制限なし
各キーの詳しい情報はこちらの公式ドキュメントを確認してください。
実装
test.txt
というファイルの読み書きを実装します。コードが長くなるため、エラーハンドリングは省略しています。各自で追加してください。
まず、UIDocumentを継承したクラスを作成します。このクラスは適当にググって出てきたものをベースに作ったため、もっとスマートな書き方があるかもしれません。
class Document: UIDocument {
var text: String? = ""
override func contents(forType typeName: String) throws -> Any {
text?.data(using: .utf8) ?? Data()
}
override func load(fromContents contents: Any, ofType typeName: String?) throws {
guard let contents = contents as? Data else { return }
text = String(data: contents, encoding: .utf8)
}
}
ファイルの新規作成
以下のコードだけで、test.txt
ファイルがiCloud上に作成されます。かんたんですね。
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)!
.appendingPathComponent("Documents")
.appendingPathComponent("test.txt")
let document = Document(fileURL: url)
document.save(to: url, for: .forCreating)
ファイルへの書き込み
新規作成とあまり変わりません。
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)!
.appendingPathComponent("Documents")
.appendingPathComponent("test.txt")
let document = Document(fileURL: url)
document.text = document.text! + "追記"
document.save(to: url, for: .forOverwriting)
ファイルの読み込み
以下を実行すると、test.txt
ファイルの検索が始まります。ファイルが見つかれば中身を出力します。これも適当にググって出てきたものがベースなので、もっとスマートな書き方があるかもしれません。(特にNSMetadataQuery)
let metadata = NSMetadataQuery()
metadata.predicate = NSPredicate(format: "%K like 'test.txt'", NSMetadataItemFSNameKey)
metadata.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
NotificationCenter.default.addObserver(forName: .NSMetadataQueryDidFinishGathering, object: metadata, queue: nil) { notification in
let query = notification.object as! NSMetadataQuery
if query.resultCount == 0 { return }
let url = (query.results[0] as AnyObject).value(forAttribute: NSMetadataItemURLKey) as! URL
let document = Document(fileURL: url)
document.open { success in
if success {
print(document.text)
}
}
}
metadata.start()
#完全に動くコード
みなさんが欲しいのはこれですよね。
import SwiftUI
struct ContentView: View {
@State private var text: String = ""
private let containerManager = ContainerManager()
var body: some View {
VStack() {
TextField("テキストを入力...", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 200)
Button("Save") {
self.containerManager.save(self.text)
}
Button("Load") {
self.containerManager.load {
self.text = $0 ?? ""
}
}
Button("Clear") {
self.text = ""
}
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class ContainerManager {
private var metadata: NSMetadataQuery! // 参照を保持するため、メンバとして持っておく。load()内のローカル変数にするとうまく動かない。
private var url: URL {
FileManager.default.url(forUbiquityContainerIdentifier: nil)!
.appendingPathComponent("Documents")
.appendingPathComponent("test.txt")
}
func load(completion: @escaping (String?) -> Void) {
metadata = NSMetadataQuery()
metadata.predicate = NSPredicate(format: "%K like 'test.txt'", NSMetadataItemFSNameKey)
metadata.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
NotificationCenter.default.addObserver(forName: .NSMetadataQueryDidFinishGathering, object: metadata, queue: nil) { notification in
let query = notification.object as! NSMetadataQuery
if query.resultCount == 0 {
print("ファイルが見つからなかったので新規作成")
let document = Document(fileURL: self.url)
document.save(to: self.url, for: .forCreating) { success in
print(success ? "作成成功" : "作成失敗")
completion(nil)
}
return
}
let url = (query.results[0] as AnyObject).value(forAttribute: NSMetadataItemURLKey) as! URL
let document = Document(fileURL: url)
document.open { success in
if success {
print("ファイル読み込み: \(document.text ?? "nil")")
completion(document.text)
} else {
print("ファイル読み込み失敗")
completion(nil)
}
}
}
metadata.start()
}
func save(_ text: String) {
let document = Document(fileURL: url)
document.text = text
document.save(to: url, for: .forOverwriting) { success in
print("ファイル保存\(success ? "成功" : "失敗")")
}
}
}
class Document: UIDocument {
var text: String? = ""
override func contents(forType typeName: String) throws -> Any {
text?.data(using: .utf8) ?? Data()
}
override func load(fromContents contents: Any, ofType typeName: String?) throws {
guard let contents = contents as? Data else { return }
text = String(data: contents, encoding: .utf8)
}
}
ハマったところ
Info.plistを編集しても、うまく反映されない事が多々あります。例えば、 NSUbiquitousContainerIsDocumentScopePublic
の値をtrue → falseに変えても、iCloud Drive上でフォルダが見えたままとか。
公式のQ&Aにもありますが、仕様のようです。
Info.plistを編集を編集した場合は、以下おまじないをすると反映されるかと思います。