スポンサーリンク

[SwiftUI]PHPicker+CoreDataで写真を複数選択してアプリに保存する

スポンサーリンク

PHPickerViewControllerでカメラロールから写真を複数枚選択し、CoreDataを利用してアプリに保存できる機能をつくります。
SwiftUI+CoreDataを使用したiOSサンプルアプリの続きです。


前回の記事↓

スポンサーリンク

UIImagePickerControllerに変わる写真ピッカーPHPickerViewController

これまで書籍や多くのインターネット上の情報で、カメラロールから写真を選択するためにUIImagePickerControllerが使われてきましたが、WWDC2020で新しい写真ピッカーが紹介されました。

PhotosUIフレームワークのPHPickerViewControllerです。

↑新しすぎて情報が少なすぎた。ジャスティンが動画で説明してくれたのが一番わかりやすかったです。

これまでのピッカーと違って、ユーザーにいちいちカメラロールへのアクセス許可を取らなくても大丈夫なピッカーということで、プライバシーに配慮された新しい仕組みのようです。
AppleによるとUIImagePickerControllerから置き換え推奨とのこと。

カメラの起動はPhotoKitなどまた別の仕組みを使わなくてはならず、カメラロールからの選択だけですが、複数枚選択できるなどいいこともたくさん。

ということで複数枚の写真を選択できるよう、Appleの公式動画を参考にサンプルアプリに組み込んでみました。

開発環境バージョン
Xcode12.5.1
iOS14.0以降
macOSBigSur 11.5.2

PHPickerViewControllerとCoreDataで写真の選択・保存ができるサンプルアプリ

前回のアプリを追加編集する形で、ContentView.swiftは前回のコードそのままいじりません。(一応掲載だけしてます。)
CoreData関連ファイルは画像データの分もまとめて設定済みなので、こちらも前回のコードのままです

編集・追加したファイル
  • [編集]SampleCardView.swift
    データをリスト表示するための雛形のSwiftUIViewファイル
    写真を表示できるようにして、ついでに見た目を整える
  • [編集]SheetView.swift
    データを入力するSheetのSwiftUIViewファイル
    写真を選択して表示し、確認できるようにする
  • [作成]PhotoPicker.swift
    PHPickerViewControllerの設定ファイル

アプリの完成形はこんな感じ

コードだけ見てもピンとこないと思うので、先に完成形の画像を。

サンプルアプリのトップ画面。カードを白くして影をつけてみました。
写真が選択されると、元の画像が写真に置き換わります。

PHPicker+CoreDataアプリのサンプルコード

コードはこちら。

SheetView.Swift

入力画面からカメラロールを開けるようにします。

複数選択した写真を格納するData型の配列imagesと、カメラロールの開閉スイッチにするBool型のisPickingを@State変数で定義してます。

カメラボタンをタップすると、isPickingがtrueになり、全画面表示のシートがモダール表示される仕組みです。

選択できる写真の種類や枚数を決めるPHPickerConfigurationもここで設定しました。
config.selectionLimitの数字を0にすると枚数の上限なく選択できます。
サンプルアプリでは静止画だけ2枚まで選択します。

//
//  SheetView.swift
//  SampleApp
//
//  Created by yaguchisato on 2021/11/29.
//  データ入力ページのView

import SwiftUI
import PhotosUI

struct SheetView: View {
    @ObservedObject var sampleModel : SampleModel
    @Environment(\.managedObjectContext)private var context
//カメラロールを開閉スイッチにするBool値
    @State private var isPicking: Bool = false
//選択した写真を入れる配列
    @State private var images: [Data] = []
//PHPickerの設定
    var pickerConfig: PHPickerConfiguration {
        var config = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
        config.filter = .images//静止画写真のみ選択
        config.preferredAssetRepresentationMode = .current
        config.selectionLimit = 2//選択する枚数の上限
        // ちなみにios15以降なら選択する順番も付けられる
        //config.selection = .ordered
        return config
    }
//
    var body: some View {
        VStack(alignment: .leading){
            HStack {
                Button("Cansel", action:{
                    sampleModel.isNewData = false
                }).foregroundColor(.blue)
                Spacer()
                Button("Save", action:
                        {
                            sampleModel.writeData(context: context)
                            
                        }
                ).foregroundColor(.blue)
                
            }
            .padding(.bottom, 20.0)
            
            HStack {
                DatePicker("", selection: $sampleModel.date, displayedComponents: .date)
                    .labelsHidden()
                Button(action: {sampleModel.bool.toggle()}){
                    Image(systemName: sampleModel.bool ? "star.fill":"star")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 30.0, height: 30.0)
                }.padding()
//isPickingをtoggleしてモダールでカメラロールを開く
                Button(action: {self.isPicking.toggle()}){
                    Image(systemName: "camera.fill")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 30.0, height: 30.0)
                    .foregroundColor(.blue)
                }
                .padding()
                .fullScreenCover(isPresented: $isPicking){ImagePicker(
                          configuration: pickerConfig,
                          completion: {result in },
                          isPicking: $isPicking,
                          images: $images)
                }
//
            }
            
            TextField("入力できます", text: $sampleModel.text).textFieldStyle(RoundedBorderTextFieldStyle())
            //カメラロールから選択されたimages配列の中の写真を表示する
            //images配列の写真データの枚数によって表示を切り替えます
                switch images.count {
                //1枚の場合
                    case 1:
                        HStack {
                            Image(uiImage: UIImage(data: images[0]) ?? UIImage(systemName: "photo")!)
                                .resizable()
                                .scaledToFill()
                                .frame(width: 150, height:150, alignment: .center)
                                .border(Color.gray)
                                .clipped()
                            
                            Spacer()
                        }
            //選択した画像が表示された時点でCoreDataモデルに代入する
                        .onAppear(){
                            sampleModel.image1 = images[0]
                            sampleModel.image2 = Data.init()
                        }
                //2枚の場合
                    case 2:
                        HStack{
                            Image(uiImage: UIImage(data: images[0]) ?? UIImage(systemName: "photo")!)
                                .resizable()
                                .scaledToFill()
                                .frame(width: 150, height: 150, alignment: .center)
                                .border(Color.gray)
                                .clipped()
                            Image(uiImage: UIImage(data: images[1]) ?? UIImage(systemName: "photo")!)
                                .resizable()
                                .scaledToFill()
                                .frame(width: 150, height: 150, alignment: .center)
                                .border(Color.gray)
                                .clipped()
                            Spacer()
                        }.onAppear(){
                            sampleModel.image1 = images[0]
                            sampleModel.image2 = images[1]
                        }
                //0枚の場合か,編集で開いたシートの場合
                    default:
                        HStack{
                            Image(uiImage: UIImage(data: sampleModel.image1) ?? UIImage(systemName: "person.crop.square")!)
                               .resizable()
                               .scaledToFill()
                               .frame(width: 150, height: 150, alignment: .center)
                               .clipped()
                               .opacity(0.5)
                            Image(uiImage: UIImage(data: sampleModel.image2) ?? UIImage(systemName: "person.crop.square")!)
                               .resizable()
                               .scaledToFill()
                               .frame(width: 150, height: 150, alignment: .center)
                               .clipped()
                               .opacity(0.5)
                            Spacer()
                        }
            //
                }
        Spacer()
        }.padding()
    }
}
//プレビュー用コード
struct SheetView_Previews: PreviewProvider {
    static var previews: some View {
        SheetView(sampleModel: SampleModel()).environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}

PHPickerで写真を選択した後、シートに画像が表示された時点でModelの変数に画像データを代入する形になってます。

PhotoPicker.swift

PHPickerをSwiftUIで使えるようにするためのコードです。

//
//  PhotoPicker.swift
//  SampleApp
//
//  Created by yaguchisato on 2021/12/03.
// 写真を複数選択できるPHPicker

import PhotosUI
import SwiftUI

public typealias PhotoPickerviewCompletionHandler = (([PHPickerResult]) -> Void)

struct ImagePicker: UIViewControllerRepresentable {
    
    let configuration: PHPickerConfiguration
    let completion: PhotoPickerviewCompletionHandler
//カメラロールの開閉用のBool値isPickingと複数写真保存用の配列imagesをBinding
    @Binding var isPicking: Bool
    @Binding var images: [Data]
    
    var itemProviders: [NSItemProvider] = []
   
    
    func makeUIViewController(context: Context) -> PHPickerViewController {
        
        let controller = PHPickerViewController(configuration: configuration)
        controller.delegate = context.coordinator
        return controller
    }
    
    func updateUIViewController(_ uiViewController: PHPickerViewController,context : Context){}
    
    func makeCoordinator() -> Coordinator {
       Coordinator(self)
   }
    
    class Coordinator: NSObject,PHPickerViewControllerDelegate,UINavigationControllerDelegate {
        private var parent: ImagePicker
        init(_ parent: ImagePicker){
            self.parent = parent
        }
        
        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]){
            
 
            if !results.isEmpty {
                parent.itemProviders = []
                parent.images = []
            }
            parent.itemProviders = results.map(\.itemProvider)
            displayNextImage()
            
            parent.isPicking.toggle()
            self.parent.completion(results)
            
            }

        private func displayNextImage(){
            for itemProvider in parent.itemProviders {
                if itemProvider.canLoadObject(ofClass: UIImage.self){
                    itemProvider.loadObject(ofClass: UIImage.self){(newImage,error) in if let error = error{
                        print("画像取得失敗",error)
                        return
                    }
                    DispatchQueue.main.async {
                    guard let image = newImage as? UIImage else {return}
//選択した画像をData型のjpegデータに変換してimagesの配列に保存
                    let data = image.jpegData(compressionQuality: 0.7)
                    self.parent.images.append(data!)
                    print("画像\(self.parent.images.count)枚取得")
                    return
                    }
                    }   
                }
        }         
    }     
}
}

写真を選択した後、配列に写真データが代入されるよりも、カメラロールが閉じるタイミングの方が早くて、CoreData用モデルの変数image1とimage2に代入するタイミングに苦戦しました。

一旦選択した写真をシートに表示して、そのタイミングで.onAppearでモデルの変数に入れることで解決しましたが、何か他にもいい方法があるかもしれません。

SampleCardView.Swift

//
//  SampleCardView.swift
//  SampleApp
//
//  Created by yaguchisato on 2021/11/29.
// データ表示用の雛形View

import SwiftUI

struct SampleCardView: View {
    @Environment(\.managedObjectContext) private var context
    @ObservedObject var sampleModel : SampleModel
    @ObservedObject var samples : SampleData

    var body: some View {
            HStack{
//CoreDataに保存された画像データがある場合は表示する
              if samples.image1?.count ?? 0 != 0 {
                Image(uiImage: UIImage(data: samples.wrappedImg1)!)
                               .resizable()
                               .scaledToFill()
                               .frame(width: 75, height: 75, alignment: .center)
                               .clipped()
                               .padding(.leading)
              }
              if samples.image2?.count ?? 0 != 0 {
                Image(uiImage: UIImage(data: samples.wrappedImg2)!)
                               .resizable()
                               .scaledToFill()
                               .frame(width: 75, height: 75, alignment: .center)
                               .clipped()
              }
            
//
                Spacer()
                VStack {
                    Text(samples.wrappedDate, formatter: itemFormatter)
                        .font(.title)
                    Text(samples.wrappedText)
                        .font(.body)
                }
                
                Image(systemName: samples.bool ? "star.fill":"star")
                    .resizable()
                    .scaledToFit()
                    .foregroundColor(Color.yellow)
                    .frame(width: 30.0, height: 30.0)
                    .padding()
            }
        //カードの形
        .frame(
            minWidth:UIScreen.main.bounds.size.width * 0.9,
            maxWidth: UIScreen.main.bounds.size.width * 0.9,
            minHeight:UIScreen.main.bounds.size.height * 0.2,
            maxHeight: UIScreen.main.bounds.size.height * 0.8
        )
//背景を白にして影をつけました
            .background(RoundedRectangle(cornerRadius: 15)
                        .fill(Color(.white)).shadow(radius: 10))
//
            .contextMenu{
                Button(action: {
                    sampleModel.editItem(item: samples)}){
                       Image(systemName: "pencil")
                        .foregroundColor(Color.blue)
                }
                Button(action: {
                    context.delete(samples)
                    try! context.save()}){
                       Image(systemName: "trash")
                        .foregroundColor(Color.blue)
                }
            }
    }
  //日付表示のフォーマット  
    private let itemFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = Locale(identifier: "en_US")
        formatter.dateStyle = .medium
        formatter.timeStyle = .none
        return formatter
    }()
}

星マークのサイズを大きくしたり、フォントを大きくしたり、余白を調整したりして見た目を整えてます。
カードも背景を白くして影をつけました。

ContentView.swift (前回から変更なし)

//
//  ContentView.swift
//  SampleApp
//
//  Created by yaguchisato on 2021/11/26.
// メインView

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @StateObject private var sampleModel = SampleModel()
    @FetchRequest(
//データの取得方法を指定 下記は日付降順
        entity:SampleData.entity(),sortDescriptors: [NSSortDescriptor(keyPath: \SampleData.date, ascending: false)],
        animation: .default)
    
    private var samples: FetchedResults<SampleData>

    var body: some View {
        VStack{
//新規作成ボタン
          Button(action:
                    {sampleModel.isNewData.toggle()
                    }){
                Text("新規作成はここをクリック")
          }
//タップするとシートが開く
            .sheet(isPresented: $sampleModel.isNewData,
                content: {
                SheetView(sampleModel: sampleModel)
            })
            
//データを表示する
//スクロール表示については別記事で紹介します
          List{
            ForEach(samples){samples in
                SampleCardView(sampleModel: sampleModel, samples: samples)
            }
         }
        }
}
}
//プレビュー用コード
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}

表示はCoreDataデータを引っ張ってきて、CardViewに載せるだけなのでContentView.swiftは前回から変更ありません。

SampleModel.swift(前回から変更なし)

//
//  SampleModel.swift
//  SampleApp
//
//  Created by yaguchisato on 2021/11/29.
// CoreDataへの保存・編集の処理

import Foundation
import SwiftUI
import CoreData

class SampleModel : ObservableObject{
    @Published var date = Date()
    @Published var id = UUID()
    @Published var bool = false
    @Published var image1: Data = Data.init()
    @Published var image2: Data = Data.init()
    @Published var text = ""

    @Published var isNewData = false
    @Published var updateItem : SampleData!
    
    func writeData(context :NSManagedObjectContext){
//データが新規か編集かで処理を分ける
        if updateItem != nil {
            
            updateItem.date = date
            updateItem.bool = bool
            updateItem.image1 = image1
            updateItem.image2 = image2
            updateItem.text = text
            
            try! context.save()
            
            updateItem = nil
            isNewData.toggle()
            
            date = Date()
            bool = false
            image1 = Data.init()
            image2 = Data.init()
            text = ""
 
            return
        }
//データ新規作成
        let newSampleData = SampleData(context: context)
        newSampleData.date = date
        newSampleData.id = UUID()
        newSampleData.bool = bool
        newSampleData.image1 = image1
        newSampleData.image2 = image2
        newSampleData.text = text
        
        do{
            try context.save()
            
            isNewData.toggle()
            
            date = Date()
            bool = false
            image1 = Data.init()
            image2 = Data.init()
            text = ""
            
            
        }
        catch {
            print(error.localizedDescription)
            
        }
    }
//編集の時は既存データを利用する
    func editItem(item: SampleData){
        updateItem = item
        
        date = item.wrappedDate
        id = item.wrappedId
        bool = item.bool
        image1 = item.wrappedImg1
        image2 = item.wrappedImg2
        text = item.wrappedText

        isNewData.toggle()
    }
}

image1,image2が選んだ画像の保存先です。

Next

CoreDataのサンプルアプリ、次回はiCloudとの連携です。
では、今回はここまで〜

タイトルとURLをコピーしました