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

SwiftUI

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

環境&対象

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

  • macOS Big Sur 11.1
  • Xcode 12.3

本シリーズ内容

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

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

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

この記事で作る範囲

前々回、前回 で イメージを表示して、ドラッグにより移動させることをできるようにしました。

写真によっては、メガネの大きさを調整したくなると思うので、ドラッグにより大きさを調整できるようにしてみます。

  • ドラッグ操作で、大きさを変更する

ドラッグ操作で大きさを変更する

やること

Drag での移動量に応じて、Image の大きさを調整することにします。

今の実装は、メガネのイメージのドラッグは、移動になっていますので、メガネのイメージ上に別のイメージを配置し、そのイメージをドラッグされたら、大きさを調整することにします。

実装終了時のイメージは、以下のような動作です。

テスト

以下をテスト項目としました。
「右側のレンズ部分を右にドラッグすることで、幅が広がること」
「左側のレンズ部分を上にドラッグすることで、高さが広がること」

まずは、PageObject にレンズ部分のドラッグ操作を追加しないといけません。

レンズ部分のイメージを取得できるように、glassRightLens と glassLeftLens を用意し、操作用のメソッドとして、dragRightLens, dragLeftLens を追加しました。

MainViewPageObject

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

import Foundation
import XCTest
import SDSCGExtension

protocol PageObject {
    var app: XCUIApplication { get }
}

struct MainViewPageObject: PageObject {
    var app: XCUIApplication
    
    init(_ app: XCUIApplication) {
        self.app = app
    }
    
    var mainImage: XCUIElement {
        app.images["mainImage"].firstMatch
    }
    
    var glassImage: XCUIElement {
        app.images["glass"].firstMatch
    }
    var glassImageFrame: CGRect {
        glassImage.frame
    }
    
    var glassRightLens: XCUIElement {
        app.images["rightLens"].firstMatch
    }
    var glassLeftLens: XCUIElement {
        app.images["leftLens"].firstMatch
    }
    
    
    func dragGlasses(_ from: CGVector, _ diff:CGSize) {
        let start = glassImage.coordinate(withNormalizedOffset: from)
        let end = start.withOffset(diff.cgVector())
        start.press(forDuration: 0.01, thenDragTo: end)
    }

    func dragRightLens(_ offset: CGVector, _ diff:CGVector) {
        let start = glassRightLens.coordinate(withNormalizedOffset: offset)
        let end = start.withOffset(diff)
        start.press(forDuration: 0.01, thenDragTo: end)
    }

    func dragLeftLens(_ offset: CGVector, _ diff:CGVector) {
        let start = glassRightLens.coordinate(withNormalizedOffset: offset)
        let end = start.withOffset(diff)
        start.press(forDuration: 0.01, thenDragTo: end)
    }
}

テストコードとしては、以下のようになるはずです。

test_dragGlassForResize_makeGlassWider_shouldBecomeWider

    func test_dragGlassForResize_makeGlassWider_shouldBecomeWider() throws {
        let app = XCUIApplication()
        app.launch()
        
        let mainPage = MainViewPageObject(app)
        let initialFrame = mainPage.glassImageFrame
        let dragVector = CGVector(dx: 100, dy: 0)
        mainPage.dragRightLens(CGVector(dx: 0.5, dy: 0.5), dragVector)
        let resizedFrame = mainPage.glassImageFrame
        XCTAssertTrue(initialFrame.width < resizedFrame.width)
    }
test_dragGlassForResize_makeGlassTaller_shouldBecomeTaller

    func test_dragGlassForResize_makeGlassTaller_shouldBecomeTaller() throws {
        let app = XCUIApplication()
        app.launch()
        
        let mainPage = MainViewPageObject(app)
        let initialFrame = mainPage.glassImageFrame
        let dragVector = CGVector(dx: 0, dy: -50)
        mainPage.dragLeftLens(CGVector(dx: 0.5, dy: 0.5), dragVector)
        let resizedFrame = mainPage.glassImageFrame
        XCTAssertTrue(initialFrame.height < resizedFrame.height)
    }

当初、厳密なサイズ調整結果をテストしようと考えていたのですが、XCUICoordinate の扱いが一部わからず、上記のように、幅が広がっていること、高さが高くなっていること をテスト項目にしました。

アプリ実装

レンズ部分に、丸を表示して、その丸がドラッグされたら、大きさを調整することにします。

上下方向には、上にドラッグしたら大きく、下にドラッグしたら小さくする としました。

左右方向には、メガネの中心から離れる方にドラッグしたら大きく、中心方向にドラッグしたら小さくする としました。

仕様がきまれば、メガネの移動と同じように実装するだけです。

メガネイメージと操作用のビューを配置することを想定しているため、GlassImage という別ビューを作成してその中に実装することとしました。

MainView

struct MainView: View {
    @State private var image:NSImage = NSImage(named: "initialPhoto")!

    var body: some View {
        ZStack {
            Image(nsImage: image).resizable().scaledToFit().frame(width: 500, height: 500)
                .accessibility(identifier: "mainImage")
            GlassImage()
        }
        .padding()
    }
}

GlassImage を以下のように実装します。

GlassImage

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

import SwiftUI

struct GlassImage: View {
    @State private var pos: NSSize = .zero
    @State private var glassSize: NSSize = NSSize(width: 300, height: 150)

    @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

    var body: some View {
        // (1)
        ZStack {
            Image(nsImage: NSImage(named: "glass")!)
                .resizable()
                .frame(width: glassSize.width, height: glassSize.height)
                .accessibility(identifier: "glass")
                .offset(pos)
                .gesture(DragGesture()
                            .onChanged { (gesture) in
                                if !isGlassDragging {
                                    self.glassDragStartLoc = pos
                                    isGlassDragging = true
                                }
                                pos = self.glassDragStartLoc.move(gesture.translation)
                            }
                            .onEnded { gesture in
                                self.isGlassDragging = false
                            })
            // (2)
            Circle()
                .foregroundColor(Color.red.opacity(eyeSignOpacity))
                .frame(width: glassSize.width / 4, height: glassSize.height/2)
                .offset(x: -1 * glassSize.width / 4 + pos.width, y: 0 + pos.height)
                .accessibility(identifier: "leftLens")
                .gesture(DragGesture()
                            .onChanged { gesture in
                                if !isEyeDragging {
                                    self.isEyeDragging = true
                                    // (3)
                                    self.eyeSignOpacity = 0.8
                                    self.eyeDragStartSize = self.glassSize
                                }
                                // (4)
                                guard (self.eyeDragStartSize.width - gesture.translation.width > 0)  else { return }
                                guard (self.eyeDragStartSize.height - gesture.translation.height > 0)  else { return }
                                // (5)
                                glassSize.width = self.eyeDragStartSize.width - gesture.translation.width
                                glassSize.height = self.eyeDragStartSize.height - gesture.translation.height
                            }
                            .onEnded { gesture in
                                self.isEyeDragging = false
                                // (6)
                                self.eyeSignOpacity = 0.01

                            })
            // (7)
            Circle()
                .foregroundColor(Color.red.opacity(eyeSignOpacity))
                .frame(width: glassSize.width / 4, height: glassSize.height/2)
                .offset(x: +1 * glassSize.width / 4 + pos.width, y: 0 + pos.height)
                .accessibility(identifier: "rightLens")
                .gesture(DragGesture()
                            .onChanged { gesture in
                                if !isEyeDragging {
                                    self.isEyeDragging = true
                                    self.eyeSignOpacity = 0.8
                                    self.eyeDragStartSize = self.glassSize
                                }
                                guard (self.eyeDragStartSize.width + gesture.translation.width > 0)  else { return }
                                guard (self.eyeDragStartSize.height - gesture.translation.height > 0)  else { return }
                                glassSize.width = self.eyeDragStartSize.width + gesture.translation.width
                                glassSize.height = self.eyeDragStartSize.height - gesture.translation.height
                            }
                            .onEnded { gesture in
                                self.isEyeDragging = false
                                self.eyeSignOpacity = 0.01
                            })
        }

    }
}
コード解説
  1. ZStack を使って、メガネのイメージ上に重ねて表示します
  2. Circle を使って、ドラッグする対象を作成します(左側レンズ分)
  3. ドラッグするときだけ、Opacity を上げて 見易くします(ドラッグしていないときはほとんど透明にします)
  4. ドラッグ操作によって、小さくしすぎてしまうことを防ぎます
  5. ドラッグ操作の移動量に応じて、メガネのイメージサイズを変更します
  6. ドラッグ操作が終わった時に、Opacity を下げて、透明にします(0 に設定すると、ドラッグできなくなってしまうので、0.01 にしてます)
  7. 右側のレンズ部分も同じです

# 配置に使用している frame や offset の値は、調整した数値です

この実装で、先ほどのテストをパスすることができます。

この実装で、この記事冒頭のアプリケーションの動きとなります。

この記事でできたこと

  • ドラッグ操作で、イメージのサイズを変更できるようにした

次回

別のイメージを Drag&Drop で表示できるようにします。

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

コメントを残す

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