Sponsor Link
環境&対象
- macOS Big Sur 11.1
- Xcode 12.3
本シリーズ内容
SwiftUI を使って、イメージ処理するアプリを作ります。
以下の理解が進むことがゴールです。
- SwiftUI を使ったアプリ開発
- NSImage を使った画像処理全般
- Photos 拡張編集機能 と SwiftUI app の組み合わせ方
- イメージ処理アプリも TDD で進めることが可能かどうか
- その他 macOS app 開発 Tips
この記事で作る範囲
次回以降に、Photos の拡張機能としての実装をして行く前に少しアーキテクチャを見直しておきます。
- 写真とその表示サイズ、メガネイメージその配置とサイズをモデルとして保持する
- 合成イメージを出力
Photos の拡張機能として実装をすると、全体を合成したイメージの作成が必要になります。
そのための準備として、現在、各ビューで保持している上記の情報をモデル内に持つようにして、モデルから合成したイメージを提供できるようにします。
モデル
モデルで保持する情報は以下のものとします。
- 表示中の写真イメージ
- 表示している写真の(表示上の)大きさ
- メガネのイメージ
- メガネの配置位置
- メガネの表示上の大きさ
モデル定義
以下のような定義になります。EditModel という名前のクラスにして、このクラスからビューのアップデートをできるように ObservableObject を継承し、必要なプロパティに @Published を付与します。
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()
}
}
各種情報提供用プロパティ
メガネの移動やリサイズ用に、位置調整をしているので、そのための値を提供するプロパティもこのクラスに作ります。
ビューは、このクラスからの情報を(そのまま)使って、配置できるようにします。
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.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())
}
}
- EditModel を environmentObject で受けるようにします
- UI 操作用のプロパティは、ビューに残します
- 位置情報等は、EditModel から提供された情報を使います
モデルとして 保持しなければいけない情報と UI 操作のために一時保存している情報を整理できました。
EnvironmentObject として EditModel を受け取るようにしたので、上位ビューのコードも修正が必要です。
//
// 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)
}
}
}
- App の初期段階で EditModel を作ります
- メインビューに environmentObject として設定します
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()
}
}
- App で EnvironmentObject として EditModel を付与していますので、EnvironmentObject として受け取ることができます
- 以降で、EditModel を参照して表示するように修正します
合成したイメージを保存できるように
合成イメージ作成
イメージを合成するために必要な情報を EditModel に 保存していますので、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
// ... 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
}
}
- AppKit は、原点は、左下
- SwiftUI では 表示に使用しているレイアウトによる
メガネのイメージは、ZStack をデフォルトレイアウトで使用しているため center-aligned で表示 - SwiftUI での座標系は、画面下に移動すると Y+
- AppKit での座標系は、画面下に移動すると Y-
- SwiftUI/AppKit 共に、X軸は同じ扱い(左移動で X-、右移動で X+)
上記の点を考慮して、イメージを合成します
合成イメージ出力ボタン
合成イメージを作成するメソッドを作りたいのですが、現時点では出力する方法がありません。
合成したイメージを出力するボタンをつけます。
ボタンを1つ追加するだけなので、非常に簡単です。
//
// 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())
}
}
- イメージ表示の右側に表示するため HStack を使います
- ボタンが押下された時の処理として、EditModel から合成イメージを取得し、ファイル書き出しメソッドに渡します
- 渡されたイメージをファイルに書き出すメソッド
- 注意:App Sandbox 設定で、”User Selected File” の扱いを “Read” から “Read/Write” に変更する必要があります
アプリと出力されたJPEG
この記事でできたこと
- イメージ合成に必要な情報を集めてアーキテクチャのリファクタリング
- ビュー表示を NSImage として合成し JPEG ファイルに出力
次回
Photos の extension としての必要な実装をすすめていきます。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link