【ハンズオン資料】Swift SDKを使ってカメラメモアプリを作る

ニフクラ mobile backend（NCMB）では各種言語向けにSDKを提供しています。その中で最も新しいSwift SDKについて、その使い方を紹介します。

## 下準備

今回のコードは[NCMBMania/camera_memo_swift: Swift SDKを使ったカメラメモアプリのコードです](https://github.com/NCMBMania/camera_memo_swift)にアップしてあります。ハンズオンにあたって、まずこちらをダウンロードしてください。

## カメラメモアプリの機能について

カメラメモアプリは、写真を撮影して、そのデータをファイルストアにアップロードします。そして別途メモを書き残すことで、写真とメモを紐付けて保存します。

## 利用する機能について

カメラメモアプリで利用するNCMBの機能は次の通りです。

- 認証機能
  - ID/パスワードによるログイン
  - ログアウト
- ファイルストア
  - ファイルアップロード
  - ファイルダウンロード
- データストア
  - ファイルストアと紐付けたメモを保存

## 画面について

カメラメモアプリでは次の画面（View）を用意しています。

- ContentView  
タブバーで2つの画面を読み込んでいます。1つはログインとアップロード画面（UploaderView）、もう1つは写真一覧画面（ImageView）です。
- Imagepicker  
写真の撮影またはフォトライブラリから写真を選択します。
- UploaderView  
タブバーから読み込まれます。ログイン画面やアップロード画面を表示します。
- MemoView  
撮影または選択した写真を表示し、メモとともにNCMBへアップロードします。
- LoginView  
NCMBへのユーザ登録、ログイン処理を行います。
- ImageView  
NCMBへアップロードした写真を一覧表示します。
- ModalView  
写真一覧をタップした際にモーダル表示します。
- GridImageView  
写真一覧のグリッド表示用ビューです。

### ContentView

タブバーで2つの画面を読み込んでいます。

```swift
struct ContentView: View {
    var body: some View {
        TabView {
            UploaderView()
                .tabItem {
                    VStack {
                        Image(systemName: "photo")
                        Text("Photo")
                    }
            }.tag(1)
            ImageView()
                .tabItem {
                    VStack {
                        Image(systemName: "rectangle.grid.2x2")
                        Text("Photos")
                    }
            }.tag(2)
        }
    }
}
```

### Imagepicker

写真の撮影またはフォトライブラリから写真を選択します。コードは[【SwiftUI】カメラ機能の実装方法【撮影画像とライブラリー画像の利用】](https://tomato-develop.com/swiftui-how-to-use-camera-and-select-photos-from-library/)から拝借しています。

### UploaderView

ログイン画面やアップロード画面を表示します。ログインしていない場合は `LoginView` を表示します。

```swift
// 実装済みです
struct UploaderView: View {
    @State var imageData : Data = .init(capacity:0)
    @State var source:UIImagePickerController.SourceType = .photoLibrary
    @State var isImagePicker = false
    @State var isLogin: Bool = NCMBUser.currentUser != nil
    var body: some View {
            NavigationView{
                VStack(spacing:0){
                        ZStack{
                            Color.white
                                .edgesIgnoringSafeArea(.all)
                                .opacity(0.0)
                            NavigationLink(
                                destination: Imagepicker(show: $isImagePicker, image: $imageData, sourceType: source),
                                isActive:$isImagePicker,
                                label: {
                                    Text("")
                                })
                            VStack{
                                if isLogin {
                                    VStack(spacing:30){
                                        MemoView(imageData: $imageData)
                                        HStack(spacing:60){
                                            Button(action: {
                                                self.source = .photoLibrary
                                                self.isImagePicker.toggle()
                                            }, label: {
                                                Text("Photo library")
                                            })
                                            Button(action: {
                                                self.source = .camera
                                                self.isImagePicker.toggle()
                                            }, label: {
                                                Text("Take Photo")
                                            })
                                        }
                                        Button(action: {
                                          // ログアウト処理を実装します
                                        }, label: {
                                            Text("ログアウト")
                                        })
                                    }
                                } else {
                                    LoginView(isLogin: $isLogin)
                                }
                            }
                        }
                        .gesture(
                            TapGesture()
                                .onEnded { _ in
                                    UIApplication.shared.closeKeyboard()
                                }
                        )
                
                }
                .navigationBarTitle("Home", displayMode: .inline)
            }
        .ignoresSafeArea(.all, edges: .top)
        .background(Color.primary.opacity(0.06).ignoresSafeArea(.all, edges: .all))
    }
}
```

### MemoView

ログインしている場合に使われる写真とメモを表示する画面です。

```swift
struct MemoView: View {
    @Binding var imageData : Data
    @State private var text: String = ""
    @State private var uploaded = false
    
    var body: some View {
        if imageData.count != 0 {
            Image(uiImage: UIImage(data: self.imageData)!)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(height: 250)
                .cornerRadius(15)
                .padding()
            TextField("メモ", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(maxWidth: 280)
            Button(action: {
                // メモアップロード処理（後述）
            }, label: {
                Text("Upload")
            })
            .alert(isPresented: $uploaded, content: {
                Alert(title: Text("アップロード完了"), message: Text("写真をアップロードしました"), dismissButton: .default(Text("閉じる"))
                )
            })
            
        }
    }
}
```

### LoginView

ユーザ登録およびログイン処理を実行します。

```swift
struct LoginView: View {
    @State private var userName: String = ""
    @State private var password: String = ""
    @Binding var isLogin: Bool
    
    var body: some View {
        VStack(spacing: 16) {
            TextField("ユーザ名", text: $userName)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(maxWidth: 280)
            SecureField("パスワード", text: $password)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(maxWidth: 280)
            Button(action: {
                signUpOrLogin()
            }, label: {
                Text("新規登録/ログイン")
            })
        }
    }

    func signUpOrLogin() {
      // ユーザ登録とログイン処理（後述）
    }
}
```

### ImageView

ファイルストアから画像を取得します。

```swift
struct ImageView: View {
    @State var memos: [NCMBObject] = []
    
    var columns: [GridItem] = Array(repeating: .init(.fixed(200)), count: 2)
    
    var body: some View {
        ScrollView(.vertical) {
            LazyVGrid(columns: columns, alignment: .center, spacing: 200) {
                ForEach (memos, id: \.objectId) { memo in
                    GridImageView(memo: memo)
                }
            }
            .onAppear() {
                getAllPhotos()
            }
        }
    }
    func getAllPhotos() {
      // 写真をファイルストアから取得（後述）
    }
}
```

### ModalView  

写真をタップした際にモーダル表示します。

```swift
struct ModalView: View {
    @Binding var isActive: Bool
    @State var memo: NCMBObject
    @State var imageData: Data
    
    var body: some View {
        HStack {
            Spacer()
            VStack {
                Spacer()
                GeometryReader { geometry in
                    Image(uiImage: UIImage(data: imageData)!)
                        .resizable()
                        .scaledToFill()
                        .frame(width: geometry.size.width, height: geometry.size.height)
                        .clipped()
                }
                if let text: String = memo["text"] {
                    Text(text).padding()
                }
                Button("閉じる") {
                    isActive = false
                }
                Spacer()
            }
            Spacer()
        }
        .padding()
        .background(Color(.black))
    }
}
```

### GridImageView

写真一覧のグリッド表示用です。

```swift
struct GridImageView: View {
    // ファイルストアからダウンロードした写真データが入る
    @State private var imageData: Data? = .init(capacity:0)
    // Memoクラスのインスタンス
    @State var memo: NCMBObject? = nil
    // モーダル表示の制御用
    @State private var isShowing = false
        
    var body: some View {
        GeometryReader { geometry in
            if imageData?.count ?? 0 > 0 {
                Image(uiImage: UIImage(data: imageData!)!)
                    .resizable()
                    .scaledToFill()
                    .frame(width: geometry.size.width, height: geometry.size.width)
                    .clipped()
            } else {
                Rectangle().fill(Color.clear)
            }
        }.onAppear() {
            loadImage()
        }.onTapGesture {
            isShowing = true
        }.fullScreenCover(isPresented: $isShowing) {
            ModalView(isActive: $isShowing, memo: memo!, imageData: imageData!)
        }
        
    }

    func loadImage() {
      // 写真データを読み込みます（後述）
    }
}
```

## Swift SDKの導入法

プロジェクトを開いて、Package Dependenciesの中にある + アイコンをクリックします。

![ScreenShot__2021-10-04_18_35_29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/20d0f49e-5afc-1ecd-4602-04a3dbc7ebd6.png)

パッケージ名が色々と出てくるので、左上のURL入力欄にて下記のURLを入力します。

![ScreenShot__2021-10-04_18_35_37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d49802de-0551-0388-5b1e-ab922a8705cb.png)


```
https://github.com/NIFCLOUD-mbaas/ncmb_swift
```

そうすると ncmb_swift が出てくるので、右下にある Add Package ボタンを押します。

![ScreenShot__2021-10-04_18_35_41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/2f45f740-a1ab-7b04-946b-5cf2ce40f69d.png)


確認ダイアログはそのままで、Add Package ボタンを押します。

![ScreenShot_ 2021-10-04 18.35.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d83394e7-4e7a-fa6b-b804-bb39a6001a83.png)


これでインストール完了です。

![ScreenShot_ 2021-10-04 18.35.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/e7b5a8c6-a65e-da9d-0c79-eb42413e1728.png)

## 初期化について

現在、新規でiOSアプリを作成すると、InterfaceがSwiftUI、Life CycleがSwiftUI Appとなっています。この状態で作ると `AppDelegate.swift` はなく、 `(アプリ名)App.swift` というファイルが作られます。

この場合、まずSwift SDKを読み込みます。

```swift
import SwiftUI
import NCMB   // 追加
```

次にWindowGroupをダミーのVStackで囲み、そこにonAppearを追加します。その中で初期化処理を行います。 `YOUR_APPLICATION_KEY` と `YOUR_CLIENT_KEY` をそれぞれNCMBから取得したものに書き換えてください。

```swift
WindowGroup {
    VStack {
        ContentView()
    }
    .onAppear() {
        print("Initialize NCMB")
        // APIキーの設定とSDK初期化
        NCMB.initialize(applicationKey: "YOUR_APPLICATION_KEY", clientKey: "YOUR_CLIENT_KEY")
    }
}
```

これで利用可能になります。

## 認証処理について

認証処理を行う画面は `LoginView.swift` になります。今回はユーザ登録とログインを一つの画面で行うようにします。もちろん分けて実装しても問題ありません。実装は `signUpOrLogin` 内で行います。まず入力されたユーザ名 `userName` とパスワード `password` を使ってユーザ登録処理を実行します。

```swift
// NCMBへのユーザ登録とログイン処理を行う関数
func signUpOrLogin() {
    // ログイン用のユーザオブジェクトを作成
    let user = NCMBUser()
    user.userName = userName // 入力されたユーザ名
    user.password = password // 入力されたパスワード
    // ユーザ登録処理
    user.signUpInBackground(callback: { _ in
        // ユーザ登録の成否は問わずログイン処理
        NCMBUser.logInInBackground(userName: userName, password: password, callback: { _ in
            // NCMBUser.currentUser が nilでなければログイン成功
            self.isLogin = NCMBUser.currentUser != nil
        })
    })
}
```

### ログアウト処理

ログアウト処理は `UploaderView.swift` にて実装します。

```swift
Button(action: {
    // ログアウト処理を実装します
}, label: {
    Text("ログアウト")
})
```

内容は次の3行です。

```swift
NCMBUser.logOutInBackground(callback: { _ in
    isLogin = false
})
```

## メモ入力画面について

ログインが完了すると isLogin が true になります。その結果、 `UploaderView.swift` にて写真選択/入力画面が表示されます。

```swift
// 実装済み
if isLogin {
    VStack(spacing:30){
        MemoView(imageData: $imageData)
        HStack(spacing:60){
            Button(action: {
                self.source = .photoLibrary
                self.isImagePicker.toggle()
            }, label: {
                Text("Photo library")
            })
            Button(action: {
                self.source = .camera
                self.isImagePicker.toggle()
            }, label: {
                Text("Take Photo")
            })
        }
// 以下略
```

写真を撮影または選択すると、そのデータは `imageData` に入ります。そうすると `MemoView` にて選択した写真が表示されます。

```swift
// 実装済み
var body: some View {
    // 画像データがあれば表示
    if imageData.count != 0 {
        Image(uiImage: UIImage(data: self.imageData)!)
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(height: 250)
            .cornerRadius(15)
            .padding()
        TextField("メモ", text: $text)
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .frame(maxWidth: 280)
        Button(action: {
          // アップロード処理（後述）
        }, label: {
            Text("Upload")
        })
        .alert(isPresented: $uploaded, content: {
            Alert(title: Text("アップロード完了"), message: Text("写真をアップロードしました"), dismissButton: .default(Text("閉じる"))
            )
        })
        
    }
}
```

### アップロード処理について

`Upload` ボタンをタップするとNCMBへのアップロード処理を開始します。MemoView.swiftの下記コメント内に記述します。

```swift
Button(action: {
                // ここにアップロード処理を記述します
            }, label: {
                Text("アップロード")
            })
```


まずファイル名をUUIDを使ってユニークなものを生成し、その名前を使ってNCMBFileオブジェクトを作ります。

```swift
// ファイル名を作成（ユニークなものを生成）
let fileName = "\(UUID()).jpg"
// ファイルストア用のインスタンスを作成
let file = NCMBFile(fileName: fileName)
```

次にそのデータへのアクセス権限として NCMBAcl を設定します。ログインユーザだけが読み書き可能としています。それを NCMBFile オブジェクトの acl に設定します。

```swift
// ACL（アクセス権限）を作成
var acl = NCMBACL.empty
let user = NCMBUser.currentUser
// アップロードした本人だけ読み書き可能とします
acl.put(key: user!.objectId!, readable: true, writable: true)
// ACLをセット
file.acl = acl
```

アップロードは `saveInBackground` メソッドで実行されます。引数として、写真データを渡します。保存処理は非同期です。

```swift
// アップロードを実行します
file.saveInBackground(data: self.imageData, callback: { result in
    switch result {
    case .success: // アップロードが成功
      // 続けて、この中に実装します
    case let .failure(error):
        print("保存に失敗しました: \(error)")
        return;
    }
})
```

アップロード処理が成功したら、続けてメモを保存します。メモ用のNCMBObjectを作ります。クラス名（DBでいうテーブル名相当）はMemoとします。

```swift
// Memoクラス（DBでいうテーブル相当）のインスタンス（DBでいう行相当）を準備
let memo = NCMBObject(className: "Memo");
```

そしてフィールド（DBでいうテーブルのカラム名）として、メモの入力内容 `text` とアップロードしたファイル名 `fileName` を設定します。ACLもファイルと同じものを設定します。

```swift
// 入力されたテキスト
memo["text"] = text
// アップロードしたファイル名
memo["fileName"] = fileName
// ACL（アクセス権限）はファイルと同じ
memo.acl = acl
```

そしてデータストアもファイルストアと同じく `saveInBackground` で保存処理を行います。保存がうまくいったら入力内容を消したり、キーボードを非表示にしています。

```swift
// 保存処理の実行
memo.saveInBackground(callback: {_ in
    // トグル
    self.uploaded.toggle()
    // 入力テキストをクリア
    self.text = ""
    // キーボードを閉じる（メインスレッドにて）
    DispatchQueue.main.async {
        UIApplication.shared.closeKeyboard()
    }
})
```

## メモデータの取得

メモデータの取得は `ImageView.swift` の `getAllPhotos` にて行います。まずNCMBを検索するためのNCMBQueryを用意します。対象となるクラス名（DBでいうテーブル相当）は保存したのと同じMemoです。

```swift
// Memoクラス（DBでいうテーブル相当）を準備
let query = NCMBQuery.getQuery(className: "Memo")
```

そして検索を実行します。検索結果はそのまま `memos` の中に入れます。

```swift
// 検索実行（今回は条件なし）
query.findInBackground(callback: { result in
    switch result {
        case let .success(array): // 検索成功
            // メモデータを適用
            self.memos = array
        case let .failure(error): // 検索失敗
            print("取得に失敗しました: \(error)")
    }
})
```

memosの中にデータが入ると、ForEachで繰り返し描画されます。この時、ユニークキーは objectId としてください。

```swift
ForEach (memos, id: \.objectId) { memo in
    GridImageView(memo: memo)
}
```

## グリッド表示について

メモのグリッド表示は `GridImageView.swift` にて行います。

## 画像データを読み込む

ファイルストアから画像データを読み込む処理は `loadImage` 関数で行っています。ファイルストアではHTTPSアクセスも可能ですが、ACL（アクセス制限）を行っている場合にはデータを取得する方が良いでしょう。こうすることで、他のユーザからは読まれないデータとして利用できます。

まずメモデータからファイル名を取得します。

```swift
// ファイル名があれば処理
if let fileName : String = self.memo?["fileName"] {
}
```

この fileName を使ってNCMBFileオブジェクトを作ります。

```swift
// ファイルストア用のオブジェクトを用意
let file : NCMBFile = NCMBFile(fileName: fileName)
```

ファイルデータの取得は `fetchInBackground` にて行います。

```swift
// ダウンロード実行
file.fetchInBackground(callback: { result in
})
```

処理がうまくいっていればデータが取得できますので、それをimageDataとして適用します。

```swift
switch result {
    case let .success(data):  // ダウンロード成功
        self.imageData = data
    case let .failure(error): // ダウンロード失敗
        print(error)
}
```

imageDataが入れば、それをUIImageのdataとして適用し、Imageオブジェクトで画像として表示します。

```swift
// 記述済み
if imageData?.count ?? 0 > 0 {
    Image(uiImage: UIImage(data: imageData!)!)
        .resizable()
        .scaledToFill()
        .frame(width: geometry.size.width, height: geometry.size.width)
        .clipped()
} else {
    Rectangle().fill(Color.clear)
}
```

これでファイルストアから取得した画像データを描画できます。

## 画像をタップした際の処理

画像をタップした場合にはモーダル表示を行っています。

```swift
}.onTapGesture {
    // タップしたらフラグを立てる
    isShowing = true
// タップしたらフラグを立てる
}.fullScreenCover(isPresented: $isShowing) {
    // モーダルビューの呼び出し
    ModalView(isActive: $isShowing, memo: memo!, imageData: imageData!)
}
```

このModalViewは `ModalView.swift` にて定義しています。ここでは受け取ったNCMBObjectと画像データをそのまま描画しています。

これでカメラメモアプリの完成です。

## まとめ

今回のカメラメモアプリを通して、NCMBの次の機能を利用しました。

- 認証機能
  - ID/パスワードによるログイン
  - ログアウト
- ファイルストア
  - ファイルアップロード
  - ファイルダウンロード
- データストア
  - ファイルストアと紐付けたメモを保存

認証機能とデータストアはよく利用される機能ですし、スマートフォンアプリでは写真データもよく利用するでしょう。今回のコードを参考にSwift SDKをぜひご利用ください。