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

SwiftUI

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

環境&対象

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

  • macOS Big Sur 11.1
  • Xcode 12.3

本シリーズ内容

SwiftUI を使って、イメージ処理するアプリを作ります。

以下の理解が進むことがゴールです。

  • SwiftUI を使ったアプリ開発
  • NSImage を使った画像処理全般
  • Photos 拡張編集機能 と SwiftUI app の組み合わせ方
  • イメージ処理アプリも TDD で進めることが可能かどうか
  • その他 macOS app 開発 Tips

この記事で作る範囲

次回以降に、Photos の拡張機能としての実装をして行く前に少しアーキテクチャを見直しておきます。

  • 写真とその表示サイズ、メガネイメージその配置とサイズをモデルとして保持する
  • 合成イメージを出力

Photos の拡張機能として実装をすると、全体を合成したイメージの作成が必要になります。
そのための準備として、現在、各ビューで保持している上記の情報をモデル内に持つようにして、モデルから合成したイメージを提供できるようにします。

モデル

モデルで保持する情報は以下のものとします。

  • 表示中の写真イメージ
  • 表示している写真の(表示上の)大きさ
  • メガネのイメージ
  • メガネの配置位置
  • メガネの表示上の大きさ

モデル定義

以下のような定義になります。EditModel という名前のクラスにして、このクラスからビューのアップデートをできるように ObservableObject を継承し、必要なプロパティに @Published を付与します。

EditModel 定義

class EditModel : ObservableObject{
    let canvasSize: NSSize = NSSize(width: 500, height: 500)
    @Published var image: NSImage
    @Published var glassImage: NSImage
    @Published var glassSize: NSSize
    @Published var glassLoc: NSSize

    init() {
        image = NSImage(named: "initialPhoto")!
        glassImage = NSImage(named: "glass")!
        let initialGlassSize = NSSize(width: 300, height: 150)
        glassSize = initialGlassSize
        glassLoc = NSPoint(x: 0, y: 0).cgSize()
    }
}

各種情報提供用プロパティ

メガネの移動やリサイズ用に、位置調整をしているので、そのための値を提供するプロパティもこのクラスに作ります。

ビューは、このクラスからの情報を(そのまま)使って、配置できるようにします。

EditModel property

    var circleSize: NSSize {
        return NSSize(width: glassSize.width / 4, height: glassSize.height/2)
    }
    var circleWidth: CGFloat {
        return circleSize.width
    }
    var circleHeight: CGFloat {
        return circleSize.height
    }
    var rightCircleOffset: CGSize {
        return CGSize(width: +1 * glassSize.width / 4 + glassLoc.width, height: glassLoc.height)
    }
    var leftCircleOffset: CGSize {
        return CGSize(width: -1 * glassSize.width / 4 + glassLoc.width, height: 0 + glassLoc.height)
    }

ビューからクラスを参照

GlassImage ビューを、この EditModel を参照するように変更します。

GlassImage

//
//  GlassImage.swift
//
//  Created by : Tomoaki Yagishita on 2021/01/19
//  © 2021  SmallDeskSoftware
//

import SwiftUI
import SDSCGExtension
import SwiftUIDebugUtil

struct GlassImage: View {
    // (1)
    @EnvironmentObject var editModel: EditModel
    // (2)
    @State private var isGlassDragging = false
    @State private var glassDragStartLoc: NSSize = .zero
    @State private var isEyeDragging = false
    @State private var eyeDragStartSize:NSSize = .zero
    @State private var eyeSignOpacity:Double = 0.01
    // (3)
    var body: some View {
        ZStack {
            Image(nsImage: editModel.glassImage)
                .resizable()
                .frame(width: editModel.glassSize.width, height: editModel.glassSize.height)
                .accessibility(identifier: "glass")
                .offset(editModel.glassLoc)
                .debugPrint("offset \(editModel.glassLoc)")
                .gesture(DragGesture()
                            .onChanged { (gesture) in
                                if !isGlassDragging {
                                    self.glassDragStartLoc = editModel.glassLoc
                                    isGlassDragging = true
                                }
                                editModel.glassLoc = self.glassDragStartLoc.move(gesture.translation)
                            }
                            .onEnded { gesture in
                                self.isGlassDragging = false
                            })
            Circle()
                .foregroundColor(Color.red.opacity(eyeSignOpacity))
                .frame(width: editModel.circleWidth, height: editModel.circleHeight)
                .offset(editModel.leftCircleOffset)
                .accessibility(identifier: "leftLens")
                .gesture(DragGesture()
                            .onChanged { gesture in
                                if !isEyeDragging {
                                    self.isEyeDragging = true
                                    self.eyeSignOpacity = 0.8
                                    self.eyeDragStartSize = self.editModel.glassSize
                                }
                                guard (self.eyeDragStartSize.width - gesture.translation.width > 0)  else { return }
                                guard (self.eyeDragStartSize.height - gesture.translation.height > 0)  else { return }
                                editModel.glassSize = NSSize(width: self.eyeDragStartSize.width - gesture.translation.width,
                                                             height: self.eyeDragStartSize.height - gesture.translation.height)
                            }
                            .onEnded { gesture in
                                self.isEyeDragging = false
                                self.eyeSignOpacity = 0.01

                            })
            Circle()
                .foregroundColor(Color.red.opacity(eyeSignOpacity))
                .frame(width: editModel.circleWidth, height: editModel.circleHeight)
                .offset(editModel.rightCircleOffset)
                .accessibility(identifier: "rightLens")
                .gesture(DragGesture()
                            .onChanged { gesture in
                                if !isEyeDragging {
                                    self.isEyeDragging = true
                                    self.eyeSignOpacity = 0.8
                                    self.eyeDragStartSize = self.editModel.glassSize
                                }
                                guard (self.eyeDragStartSize.width + gesture.translation.width > 0)  else { return }
                                guard (self.eyeDragStartSize.height - gesture.translation.height > 0)  else { return }
                                editModel.glassSize = NSSize(width: self.eyeDragStartSize.width + gesture.translation.width,
                                                             height: self.eyeDragStartSize.height - gesture.translation.height)
                            }
                            .onEnded { gesture in
                                self.isEyeDragging = false
                                self.eyeSignOpacity = 0.01
                            })
        }

    }
}

struct GlassImage_Previews: PreviewProvider {
    static var previews: some View {
        GlassImage()
            .environmentObject(EditModel())
    }
}
コード解説
  1. EditModel を environmentObject で受けるようにします
  2. UI 操作用のプロパティは、ビューに残します
  3. 位置情報等は、EditModel から提供された情報を使います

モデルとして 保持しなければいけない情報と UI 操作のために一時保存している情報を整理できました。

EnvironmentObject として EditModel を受け取るようにしたので、上位ビューのコードも修正が必要です。

PhotoGlassesApp

//
//  PhotoGlassesApp.swift
//
//  Created by : Tomoaki Yagishita on 2021/01/19
//  © 2021  SmallDeskSoftware
//

import SwiftUI

@main
struct PhotoGlassesApp: App {
    // (1)
    @StateObject var editModel: EditModel = EditModel()

    var body: some Scene {
        WindowGroup {
            MainView()
                // (2)
                .environmentObject(editModel)
        }
    }
}
コード解説
  1. App の初期段階で EditModel を作ります
  2. メインビューに environmentObject として設定します

MainView は、以下のようになります。

MainView

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/01/19
//  © 2021  SmallDeskSoftware
//

import SwiftUI
import UniformTypeIdentifiers

struct MainView: View {
    // (1)
    @EnvironmentObject var editModel: EditModel
    @State private var isTargeted = false
    // (2)
    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")
                .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()
    }
}
コード解説
  1. App で EnvironmentObject として EditModel を付与していますので、EnvironmentObject として受け取ることができます
  2. 以降で、EditModel を参照して表示するように修正します

合成したイメージを保存できるように

合成イメージ作成

イメージを合成するために必要な情報を EditModel に 保存していますので、EditModel から 合成したイメージを取得できるようなメソッドを作ります。

example

class EditModel : ObservableObject{
    let canvasSize: NSSize = NSSize(width: 500, height: 500)
    @Published var image: NSImage
    @Published var glassImage: NSImage
    @Published var glassSize: NSSize
    @Published var glassLoc: NSSize
    
    // ... snip ...
    func composedImage() -> NSImage {
        let scale = CGSize.minScale(base: image.size, target: canvasSize) // scale from image to canvas
        let scaleToImage = 1.0 / scale
        
        // glassoffset is calced from image center (because default ZStack align is center)
        let imageCenter = image.size.center()
        let glassSizeInImage = glassSize.scale(scaleToImage)
        let glassOffsetInImage = glassLoc.cgVector().scale(scaleToImage, -1 * scaleToImage)
        let glassLocInImage = imageCenter.move(glassOffsetInImage)
            .move(CGVector(dx: -1 * glassSizeInImage.width / 2 , dy: -1 * glassSizeInImage.height / 2))

        let newImage = NSImage(size: image.size)
        newImage.lockFocus()
        // image
        image.draw(in: NSRect(origin: .zero, size: image.size))
        // glass
        glassImage.draw(in: NSRect(origin: glassLocInImage, size: glassSizeInImage))
        newImage.unlockFocus()
        return newImage
    }
}    
コード解説
  1. AppKit は、原点は、左下
  2. SwiftUI では 表示に使用しているレイアウトによる
    メガネのイメージは、ZStack をデフォルトレイアウトで使用しているため center-aligned で表示
  3. SwiftUI での座標系は、画面下に移動すると Y+
  4. AppKit での座標系は、画面下に移動すると Y-
  5. SwiftUI/AppKit 共に、X軸は同じ扱い(左移動で X-、右移動で X+)

上記の点を考慮して、イメージを合成します

合成イメージ出力ボタン

合成イメージを作成するメソッドを作りたいのですが、現時点では出力する方法がありません。

合成したイメージを出力するボタンをつけます。

アプリに出力ボタンを付与
アプリに出力ボタンを付与

ボタンを1つ追加するだけなので、非常に簡単です。

MainView

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/01/19
//  © 2021  SmallDeskSoftware
//

import SwiftUI
import UniformTypeIdentifiers

struct MainView: View {
    @EnvironmentObject var editModel: EditModel
    @State private var isTargeted = false

    var body: some View {
        // (1)
        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 {
                // (2)
                Button(action: {
                    let exportImage = editModel.composedImage()
                    exportNSImage(image: exportImage)
                }, label: {
                    Text("Save")
                })
            }
            .padding()
        }
    }
    // (3)
    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.begin { (result) in
            if result == .OK {
                guard let saveFileURL = savePanel.url else { return }
                do {
                    try jpgData.write(to: saveFileURL)
                } catch {
                    print("\(error)")
                }

            }
        }
    }
    
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MainView()
            .environmentObject(EditModel())
    }
}
コード解説
  1. イメージ表示の右側に表示するため HStack を使います
  2. ボタンが押下された時の処理として、EditModel から合成イメージを取得し、ファイル書き出しメソッドに渡します
  3. 渡されたイメージをファイルに書き出すメソッド
  4. 注意:App Sandbox 設定で、"User Selected File" の扱いを "Read" から "Read/Write" に変更する必要があります

アプリと出力されたJPEG

アプリと出力したJPEG
アプリと出力したJPEG

この記事でできたこと

  • イメージ合成に必要な情報を集めてアーキテクチャのリファクタリング
  • ビュー表示を NSImage として合成し JPEG ファイルに出力

次回

Photos の extension としての必要な実装をすすめていきます。

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

コメントを残す

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