Sponsor Link
目次
環境&対象
- macOS Big Sur 11.1
- Xcode 12.3
- Photos 6.0
本シリーズ内容
SwiftUI を使って、イメージ処理するアプリを作ります。
以下の理解が進むことが目標です。
- SwiftUI を使ったアプリ開発
- NSImage を使った画像処理全般
- Photos 拡張編集機能 と SwiftUI app の組み合わせ方
- イメージ処理アプリも TDD で進めることが可能かどうか
- その他 macOS app 開発 Tips
この記事で作る範囲
Photos の編集機能拡張として、写真を編集して Photos に保存できるようにしていきます
- 編集用 UI を表示する
- Photos から渡される写真を UI に表示する
- 編集した 写真を Photos に渡す
準備
アプリとして作成してきた UI を Photos からも利用する予定です。
アプリとして作成していたときには、編集したデータを保存するボタンをつけていましたが、Photos の中の UI として、独自に保存ボタンを持つのは不自然なので、ビューを階層化して、編集ビューに直接ボタンを配置することをやめます
以下のように、ビューを2階層にして、MainView を Photos から利用できるビューにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
struct MainView: View { @EnvironmentObject var editModel: EditModel @State private var isTargeted = false var body: some View { HStack { ZStack(alignment: .center) { Image(nsImage: editModel.image).resizable().scaledToFit().frame(width: editModel.canvasSize.width, height: editModel.canvasSize.height) .accessibility(identifier: "mainImage") .onDrop(of: [UTType.fileURL], isTargeted: $isTargeted) { (providers) -> Bool in guard let provider = providers.first else { return false } // handle first item only provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (url, error) in DispatchQueue.main.async { if let url = url as? Data { let imageURL = NSURL(absoluteURLWithDataRepresentation: url, relativeTo: nil) as URL if let localImage = NSImage(contentsOf: imageURL) { editModel.image = localImage } } else if let error = error { print(error) } } } return true } GlassImage() } .padding() VStack { Button(action: { let exportImage = editModel.composedImage() exportNSImage(image: exportImage) }, label: { Text("Save") }) } .padding() } } ... snip ... } |
現在の MainView を2つに分けて、編集用のビューと Save ボタンを別々のビューとします。
また、Photos 内で、Drag&Drop を受け付けるのも良くないので、onDrop 処理も、MainView から外します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
struct MainViewWithSave:View { @EnvironmentObject var editModel: EditModel @State private var isTargeted = false var body: some View { HStack { MainView() .padding() .onDrop(of: [UTType.fileURL], isTargeted: $isTargeted) { (providers) -> Bool in guard let provider = providers.first else { return false } // handle first item only provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (url, error) in DispatchQueue.main.async { if let url = url as? Data { let imageURL = NSURL(absoluteURLWithDataRepresentation: url, relativeTo: nil) as URL if let localImage = NSImage(contentsOf: imageURL) { editModel.image = localImage } } else if let error = error { print(error) } } } return true } VStack { Button(action: { let exportImage = editModel.composedImage() exportNSImage(image: exportImage) }, label: { Text("Save") }) } .padding() } } func exportNSImage(image: NSImage) { // png file guard let imageData = image.tiffRepresentation else { return } guard let bitmapRep = NSBitmapImageRep(data: imageData) else { return } guard let jpgData = bitmapRep.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor:1.0]) else { return } let savePanel = NSSavePanel() savePanel.canCreateDirectories = true savePanel.showsTagField = false savePanel.isExtensionHidden = false savePanel.nameFieldStringValue = "ExportedImage.jpg" //savePanel.nameFieldStringValue = "ExportedImage.png" savePanel.begin { (result) in if result == .OK { guard let saveFileURL = savePanel.url else { return } do { try jpgData.write(to: saveFileURL) } catch { print("\(error)") } } } } } struct MainView: View { @EnvironmentObject var editModel: EditModel var body: some View { ZStack(alignment: .center) { Image(nsImage: editModel.image).resizable().scaledToFit().frame(width: editModel.canvasSize.width, height: editModel.canvasSize.height) .accessibility(identifier: "mainImage") GlassImage() } .padding() } } |
この変更によって、Photos から MainView を再利用しやすくなりました。
編集用 UI を表示する
やること
アプリとして、SwiftUI で UI を作っているので、その UI を Photos にも表示します。
テンプレートとして生成されている PhotoEditingViewController に実装を追加していきます。
実装/ viewDidLoad
Photos は、NSView/NSViewController を期待しているので、
SwiftUI の View を NSHostingView でラップして渡します。
まずは、viewDidLoad に追加していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. editModel = EditModel() let glassMainView = MainView().environmentObject(editModel) let glassHostView = NSHostingView(rootView: glassMainView) glassHostView.frame = CGRect(origin: CGPoint(x: 10, y: 10), size: CGSize(width: 500, height: 500)) self.view.addSubview(glassHostView) } |
上記で、Photos から外部編集機能拡張として起動すると UI が表示されるようになります。
Photos から渡される写真を受け取る
やること
編集開始時に、startContentEditing がよばれるので、Photos から写真を受け取り、自 UI に表示するようにします。
Photos からは、表示用のデータと 実データのどちらも PHContentEditingInput から取得できるようになっています。
今回のアプリでは、常に実データを扱うようにしていましたので、表示用のデータは無視して、実データを 自アプリに保持するようにします。
実装/ startContentEditing
1 2 3 4 5 6 7 8 9 10 11 12 |
func startContentEditing(with contentEditingInput: PHContentEditingInput, placeholderImage: NSImage) { // Present content for editing, and keep the contentEditingInput for use when closing the edit session. // If you returned true from canHandleAdjustmentData:, contentEditingInput has the original image and adjustment data. // If you returned false, the contentEditingInput has past edits "baked in". input = contentEditingInput if let url = contentEditingInput.fullSizeImageURL { let inputImage = NSImage(contentsOf: url)! editModel.image = inputImage } } |
Photos へ編集した写真を渡す
やること
Photos 上で、”変更内容を保存”ボタンを押下されると finishContentEditing が呼ばれます。
PHContentEditingOutput から指定される URL に編集後のデータを書き出し、CompletionHandler をコールします。
なお、AdjustmentData として何らかを保存しないと、書き出したデータは無視されてしまう仕様のようです。ここでは、ダミーを入れています。
実装/ finishContentEditing
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
func finishContentEditing(completionHandler: @escaping ((PHContentEditingOutput?) -> Void)) { // Update UI to reflect that editing has finished and output is being rendered. // Render and provide output on a background queue. DispatchQueue.global().async { // Create editing output from the editing input. let output = PHContentEditingOutput(contentEditingInput: self.input!) // Provide new adjustments and render output to given location. let dummyAdjustmentData = "dummy".data(using: .utf8)! output.adjustmentData = PHAdjustmentData(formatIdentifier: Self.myFormatID, formatVersion: Self.myFormatVersion, data: dummyAdjustmentData) let editedImage = self.editModel.composedImage() guard let imageData = editedImage.tiffRepresentation else { return } guard let bitmapRep = NSBitmapImageRep(data: imageData) else { return } guard let jpgData = bitmapRep.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor:1.0]) else { return } do { try jpgData.write(to: output.renderedContentURL, options: .atomic) } catch { print(error) } // Call completion handler to commit edit to Photos. completionHandler(output) // Clean up temporary files, etc. } } |
作成した編集機能拡張の動作
ここまでの実装で以下のような動作になります。
この記事でできたこと
- SwiftUI で作ったアプリを Photos の編集機能拡張として使えるようにした
まとめ:イメージ処理アプリと Photos 編集機能拡張
- NSImage を使って、イメージを扱うのは、UIKit/AppKit と同じ
- SwiftUI で作った UI は、NSHostingView を使うことで、Photos 編集機能拡張としても使える
- 写真を扱うには、NSImage だけでは不十分で CoreImage を使う必要がある
- 編集内容そのものを TDD でテストしていくのは難しそう
作成したアプリ/編集機能拡張 では以下のような点が 改善・拡張 の対象と考えられます
- UI の表示領域を可変にする(ドラッグで拡大縮小可能にする)
- Photos の表示領域いっぱいに、編集用 UI を表示する
- Photos 上での再編集に対応する
最初の2つは、canvasSize として保持している変数を使うと対応することができ、3つ目は、EditModel に保持している変数を serialize して adjustmentData にすることで対応可能です。
一段落したので、本アプリ開発の記事は、一旦終了とします。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link