[SwiftUI][Image] イメージ処理アプリを作る(7)

SwiftUI

SwiftUI を使った イメージ処理アプリを作ってみます

環境&対象

以下の環境で動作確認を行なっています。

  • 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 から利用できるビューにします。

これまでの実装(MainView)

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 から外します。

MainView と MainViewWithSave

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 に追加していきます。

PhotoEditingViewController#viewDidLoad

    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)
        
    }
glassHostView の frame は適当に決めています。

上記で、Photos から外部編集機能拡張として起動すると UI が表示されるようになります。

Photos から渡される写真を受け取る

やること

編集開始時に、startContentEditing がよばれるので、Photos から写真を受け取り、自 UI に表示するようにします。

Photos からは、表示用のデータと 実データのどちらも PHContentEditingInput から取得できるようになっています。

今回のアプリでは、常に実データを扱うようにしていましたので、表示用のデータは無視して、実データを 自アプリに保持するようにします。

実装/ startContentEditing

PhotoEditingViewController#startContentEditing

    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

PhotoEditingViewController#finishContentEditing

    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 にすることで対応可能です。

一段落したので、本アプリ開発の記事は、一旦終了とします。

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です