😸

SwiftUIでファイルから画像を読み込んで表示するmacOSアプリケーションの作成

に公開

概要

SwiftUIでファイルから画像を読み込みリサイズが画像の反転などを前処理を行って表示するアプリケーションを作成します。

環境

  • macOS: Sequoia 15.5
  • Xcode: 16.4

方法1:画像をAssetに登録して表示

あらかじめ用意しておいた画像を表示する場合はAssetに画像を登録して表示する方法だと簡単に実現できます。

Xcodeのプロジェクトに画像をドラッグ&ドロップするとAssetに画像を登録できます。
Add image to asset

Assetに登録した画像はImageで表示できます。

ContentView.swift
struct ContentView: View {
    var body: some View {
        Image("dango")
    }
}

しかし、画像がフレームより大きいと画像の表示が切れてしまいます。
以下のように設定するとアスペクト比を維持してフレームに合わせてリサイズして表示します。

ContentView.swift
struct ContentView: View {
    var body: some View {
        Image("dango")
            .resizable()
            .scaledToFit()
    }
}

Show image with Image

方法2:ファイルから画像を読み込む

例えば画像ビューアのようなアプリケーションの場合、ユーザーが用意した画像ファイルを読み込みたい場合があります。
iOSやmacOSではサンドボックスの仕組みでファイルへのアクセスが制限されています。
基本的にはユーザーが許可した場合にアクセスが可能となります。
ファイル選択のUIでユーザーが明示的に指定したファイル、ユーザーが許可を与えたファイルはアクセスできます。

Xcodeプロジェクト設定でSandboxの設定値が確認できます。
Xcode sandbox settings

ファイル選択UIで指定した画像ファイルを開く

fileImporterを使えばファイルを選択するUIを表示して選択したファイルを処理する処理を記載することができます。
基本的な使い方はファイル選択UIの表示状態の変数の用意、ファイル選択UIを表示する処理、ファイル選択時の処理の記載となります。

以下のサンプルプログラムでは上記の説明以外にも状態管理のためにenumで状態の定義を追加して、読み込み中はグルグルを表示して、読み込み完了したら画像を表示するようにしています。

ContentView.swift
import SwiftUI
import UniformTypeIdentifiers

enum ImageState {
    case idle
    case loading
    case displayed
}

struct ContentView: View {
    @State var state: ImageState = .idle
    @State var isPresented: Bool = false
    @State var image: NSImage = NSImage()
    
    var body: some View {
        ZStack {
            if (state == .idle) {
                Button("Open Image") {
                    isPresented = true
                }
                .fileImporter(isPresented: $isPresented, allowedContentTypes: [.image], allowsMultipleSelection: false) { result in
                    state = .loading
                    switch result {
                    case .success(let urls):
                        guard let url = urls.first else { return }
                        let gotAccess = url.startAccessingSecurityScopedResource()
                        if !gotAccess { return }
                        image = NSImage(contentsOf: url) ?? NSImage()
                        url.stopAccessingSecurityScopedResource()
                    case .failure(let error):
                        print(error.localizedDescription)
                    }
                    state = .displayed
                  }
            } else if (state == .loading) {
                ProgressView()
            } else {
                ScrollView([.horizontal, .vertical]) {
                    Image(nsImage: image)
                }
            }
        }
    }
}

SwiftUI file choosing

ドラッグ&ドロップで画像ファイルを表示

onDropを使うとドラッグ&ドロップされたファイルについても表示できるようになります。
基本的な使い方はドラッグ&ドロップの受付状態の変数の用意、ドラッグ&ドロップの受付状態を有効にする処理、ドラッグ&ドロップ時の処理の記載となります。

以下のサンプルプログラムではドラッグ&ドロップ受付時に画面を半透明の四角形領域を表示して視覚的に受付中であることが分けるようにしています。

ContentView.swift
import SwiftUI
import UniformTypeIdentifiers

enum ImageState {
    case idle
    case loading
    case displayed
}

struct ContentView: View {
    @State var state: ImageState = .idle
    @State var isPresented: Bool = false
    @State var image: NSImage = NSImage()
    @State var isDropTargeted: Bool = false
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                if (state == .idle) {
                    Button("Open Image") {
                        isPresented = true
                    }
                    .fileImporter(isPresented: $isPresented, allowedContentTypes: [.image], allowsMultipleSelection: false) { result in
                        state = .loading
                        switch result {
                        case .success(let urls):
                            guard let url = urls.first else { return }
                            let gotAccess = url.startAccessingSecurityScopedResource()
                            if !gotAccess { return }
                            image = NSImage(contentsOf: url) ?? NSImage()
                            url.stopAccessingSecurityScopedResource()
                        case .failure(let error):
                            print(error.localizedDescription)
                        }
                        state = .displayed
                    }
                } else if (state == .loading) {
                    ProgressView()
                } else {
                    ScrollView([.horizontal, .vertical]) {
                        Image(nsImage: image)
                    }
                }
                if isDropTargeted {
                    Rectangle()
                        .fill(Color.blue)
                        .opacity(0.2)
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
        .onDrop(of: [UTType.image.identifier], isTargeted: $isDropTargeted) { providers in
            state = .loading
            guard let provider = providers.first else { return false }
            if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
                provider.loadItem(forTypeIdentifier: UTType.image.identifier) { item, error in
                    guard error == nil, let url = item as? URL else { return }
                    image = NSImage(contentsOf: url) ?? NSImage()
                }
            }
            state = .displayed
            return true
        }
    }
}

SwiftUI file drag and drop

画像をリサイズして表示

ここまでのサンプルプログラムではNSImageに画像の読み込み処理を任せていました。
ここでは内部的な画像データそのものを反転したりリサイズしたりして画像を前処理して表示する方法を記載します。
アプリケーションによってはフレームの表示方法を工夫して反転やサイズを変更することもできると思いますしその方が処理が高速な可能性はありますが、ここではあくまで画像データそのものを加工することを想定しています。

Rawデータの取得

以下のようにすると生データを取得できます。

do {
    let rawData = try Data(contentsOf: url)
} catch let error {
    print("error: \(error)")
}

その後画像を加工することになるのですが、NSImage画像から幅や高さを取得しようとすると実行する環境によってはディスプレイの設定によって取得できる値が変わってくる場合があります。
そのため画像そのものの幅と高さを取得するために以下のようにします。

do {
    let rawData = try Data(contentsOf: url)
    if let rep = NSBitmapImageRep(data: rawData) {
        let width = rep.pixelsWide
        let height = rep.pixelsHigh
    }
} catch let error {
    print("error: \(error)")
}

リサイズ処理

例えばリサイズで幅と高さを2倍にしたい場合は、幅と高さを2倍にした画像情報を作成して、新しいContextで画像を描画することでリサイズ後の画像データを生成します。

do {
    let rawData = try Data(contentsOf: url)
    if let rep = NSBitmapImageRep(data: rawData) {
        let scale = 2.0
        let newSize = NSMakeSize(CGFloat(rep.pixelsWide) * scale, CGFloat(rep.pixelsHigh) * scale)
        
        // Make BitmapImageRep
        let bitmapRep = NSBitmapImageRep(
            bitmapDataPlanes: nil,
            pixelsWide: Int(newSize.width),
            pixelsHigh: Int(newSize.height),
            bitsPerSample: rep.bitsPerSample,
            samplesPerPixel: rep.samplesPerPixel,
            hasAlpha: rep.hasAlpha,
            isPlanar: rep.isPlanar,
            colorSpaceName: rep.colorSpaceName,
            bytesPerRow: Int(newSize.width) * 4,
            bitsPerPixel: rep.bitsPerPixel
        )!
        bitmapRep.size = newSize
        
        // Make context
        guard let context = CGContext(data: nil, width: Int(newSize.width), height: Int(newSize.height), bitsPerComponent: 8, bytesPerRow: Int(newSize.width) * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
        
        // Make image
        guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, nil) else { return }
        guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { return }
        context.draw(cgImage, in: NSMakeRect(0.0, 0.0, newSize.width, newSize.height))
        let newImage = context.makeImage()!
        image = NSImage(cgImage: newImage, size: newSize)
        state = .displayed
    }
} catch let error {
    print("error: \(error)")
}

画像の反転

上記でContextを生成できたのでContextに対して反転して描画されるように設定します。

do {
    let rawData = try Data(contentsOf: url)
    if let rep = NSBitmapImageRep(data: rawData) {
        let scale = 2.0
        let newSize = NSMakeSize(CGFloat(rep.pixelsWide) * scale, CGFloat(rep.pixelsHigh) * scale)
        
        // Make BitmapImageRep
        let bitmapRep = NSBitmapImageRep(
            bitmapDataPlanes: nil,
            pixelsWide: Int(newSize.width),
            pixelsHigh: Int(newSize.height),
            bitsPerSample: rep.bitsPerSample,
            samplesPerPixel: rep.samplesPerPixel,
            hasAlpha: rep.hasAlpha,
            isPlanar: rep.isPlanar,
            colorSpaceName: rep.colorSpaceName,
            bytesPerRow: Int(newSize.width) * 4,
            bitsPerPixel: rep.bitsPerPixel
        )!
        bitmapRep.size = newSize
        
        // Make context
        guard let context = CGContext(data: nil, width: Int(newSize.width), height: Int(newSize.height), bitsPerComponent: 8, bytesPerRow: Int(newSize.width) * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
        
        // Flip image
        let horizontalFlip = true
        let verticalFlip = true
        if (horizontalFlip && verticalFlip) {
            context.translateBy(x: newSize.width, y: newSize.height)
            context.scaleBy(x: -1.0, y: -1.0)
        } else if (horizontalFlip) {
            context.translateBy(x: newSize.width, y: 0)
            context.scaleBy(x: -1.0, y: 1.0)
        } else if (verticalFlip) {
            context.translateBy(x: 0, y: newSize.height)
            context.scaleBy(x: 1.0, y: -1.0)
        } else {
            context.translateBy(x: 0, y: 0)
            context.scaleBy(x: 1.0, y: 1.0)
        }
        
        // Make image
        guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, nil) else { return }
        guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { return }
        context.draw(cgImage, in: NSMakeRect(0.0, 0.0, newSize.width, newSize.height))
        let newImage = context.makeImage()!
        image = NSImage(cgImage: newImage, size: newSize)
        state = .displayed
    }
} catch let error {
    print("error: \(error)")
}

SwiftUI edit image

参考

Discussion