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

SwiftUI

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

環境&対象

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

  • macOS Big Sur 11.1
  • Xcode 12.3

本シリーズ内容

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

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

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

完成イメージ

写真に、メガネのイメージを重ねた写真を作るアプリにしてみます。

以下のような見た目になるイメージ

完成イメージ
完成イメージ

この記事で作る範囲

まずは、以下の実装をしていきます。

  • 写真を表示する
  • 写真に重ねて、眼鏡を表示する
  • 眼鏡をドラッグできるようにする (-> 次回以降に)

写真を表示する

やること

将来的には、Drag&Drop できるようにしたいですが、まずは、リソースとして保持している写真を表示することにします。

テスト

この状態では、モデルもビューモデルもないので、シンプルに、「起動直後に表示されているイメージの名前が適切か」をテストすることにします。

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

test_appLaunch_initial_imageShouldBeInitialImage

    func test_appLaunch_initial_imageShouldBeInitialImage() throws {
        let app = XCUIApplication()
        app.launch()

        // (1)
        let mainImage = app.images["mainImage"].firstMatch
        XCTAssertNotNil(mainImage)
        // (2)
        XCTAssertEqual(mainImage.label, "initialPhoto")
    }
コード解説
  1. ID: mainImage を持つイメージを取得
  2. イメージが表示しているものが initialPhoto であることをテスト

まだ、アプリのコードは書いていないので、当然エラーとなります。(mainImage というイメージが取得できないというエラーになるはずです)

アプリ実装

イメージの表示は、SwiftUI では、Image を使います。初期イメージは、適当(?)なイメージをリソース(Assets.xcassets)に、"initialPhoto" という名前で追加します。

ContentView

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

    var body: some View {
        // (2)
        Image(nsImage: image).resizable().scaledToFit().frame(width: 500, height: 500)
            // (3)
            .accessibility(identifier: "mainImage")
            .padding()
    }
}
コード解説
  1. 今後、別の写真も表示する予定なので、@State で NSImage を保持するようにします。
  2. 写真を表示するために、Image を使っています。フレームの大きさは適当に決めました。
  3. テストで取得できるように AccessibilityID を付与しておきます。

こうすることで、先ほどのテストをパスすることができるようになりました。

写真を表示しているアプリ
写真を表示しているアプリ

メガネの表示

やること

先ほどの写真に重ねて表示するので、ZStack を使います。

メガネの表示は、先ほどの写真と同じです。リソースに追加しておいて、Image を使って表示します。

テスト

写真とメガネの両方が表示されていることをテストします。

起動時のチェックなので、先ほどのテストを拡張します。

test_appLaunch_initial_imageShouldBeInitialImage

    func test_appLaunch_initial_imageShouldBeInitialImage() throws {
        let app = XCUIApplication()
        app.launch()

        let mainImage = app.images["mainImage"].firstMatch
        XCTAssertNotNil(mainImage)
        XCTAssertEqual(mainImage.label, "initialPhoto")
        // (1)
        let glassImage = app.images["glass"].firstMatch
        XCTAssertNotNil(glassImage)
        // (2)
        XCTAssertEqual(glassImage.label, "glass")
    }
コード解説
  1. 写真と同じように、ID: glass を持つイメージを取得
  2. イメージが "glass" を表示していることをテスト

アプリ実装

initialPhoto と同様に、メガネ用のイメージをリソースに追加して表示します。

ZStack を使って initialPhoto に重ねて表示するようにします。

なお、メガネのイメージは、背景を透明にした png ファイルを用意しました。

ContentView

struct ContentView: View {
    @State private var image:NSImage = NSImage(named: "initialPhoto")!
    @State private var pos: NSSize = .zero
    @State var isDragging = false
    @State var dragStartLoc: NSSize = .zero
    @State private var grassScale: CGFloat = 0.5 /// 100
    
    var body: some View {
        // (1)
        ZStack {
            Image(nsImage: image).resizable().scaledToFit().frame(width: 500, height: 500, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
                    .accessibility(identifier: "mainImage")
            // (2)
            Image(nsImage: NSImage(named: "glass")!)
                .resizable().scaledToFit()
                // (3)
                .accessibility(identifier: "glass")
        }
        .padding()
    }
}
コード解説
  1. 重ねて表示するので ZStack を使います
  2. glass という名前でリソースに保存したイメージを表示します
  3. glass という ID を付与します

この実装で先ほどのテストをパスします。

写真とメガネを表示しているアプリ
写真とメガネを表示しているアプリ

リファクタリング

ContentView を使い続けるのは良くない気がするので、MainView に リネームしておきます。

# XCode のリネーム機能を使うと、Struct 名だけでなく、ファイル名や、呼び出し側(App) も修正してくれて便利です。

以下が、ここまでに作ってきたコードです。

PhotoGlassesApp.swift

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

import SwiftUI

@main
struct PhotoGlassesApp: App {
    var body: some Scene {
        WindowGroup {
            MainView()
        }
    }
}
MainView

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

import SwiftUI
import SDSCGExtension

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")
            Image(nsImage: NSImage(named: "glass")!)
                .resizable().scaledToFit()
                .accessibility(identifier: "glass")
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MainView()
    }
}

この記事でできたこと

  • JPG/PNG を Image を使って重ねて表示
  • Accessibility ID を使って、テストコードから要素を取得し属性をテスト

次回

メガネを Drag で動かせるようにします。

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

コメントを残す

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