PHPickerViewControllerでカメラロールから写真を複数枚選択し、CoreDataを利用してアプリに保存できる機能をつくります。
SwiftUI+CoreDataを使用したiOSサンプルアプリの続きです。
前回の記事↓
UIImagePickerControllerに変わる写真ピッカーPHPickerViewController
これまで書籍や多くのインターネット上の情報で、カメラロールから写真を選択するためにUIImagePickerControllerが使われてきましたが、WWDC2020で新しい写真ピッカーが紹介されました。
PhotosUIフレームワークのPHPickerViewControllerです。
↑新しすぎて情報が少なすぎた。ジャスティンが動画で説明してくれたのが一番わかりやすかったです。
これまでのピッカーと違って、ユーザーにいちいちカメラロールへのアクセス許可を取らなくても大丈夫なピッカーということで、プライバシーに配慮された新しい仕組みのようです。
AppleによるとUIImagePickerControllerから置き換え推奨とのこと。
カメラの起動はPhotoKitなどまた別の仕組みを使わなくてはならず、カメラロールからの選択だけですが、複数枚選択できるなどいいこともたくさん。
ということで複数枚の写真を選択できるよう、Appleの公式動画を参考にサンプルアプリに組み込んでみました。
開発環境 | バージョン |
---|---|
Xcode | 12.5.1 |
iOS | 14.0以降 |
macOS | BigSur 11.5.2 |
PHPickerViewControllerとCoreDataで写真の選択・保存ができるサンプルアプリ
前回のアプリを追加編集する形で、ContentView.swiftは前回のコードそのままいじりません。(一応掲載だけしてます。)
CoreData関連ファイルは画像データの分もまとめて設定済みなので、こちらも前回のコードのままです。
アプリの完成形はこんな感じ
コードだけ見てもピンとこないと思うので、先に完成形の画像を。
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との連携です。
では、今回はここまで〜