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 から利用できるビューにします。
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 から外します。
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 に追加していきます。
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
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
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