[SwiftUI] 元の画像のサイズを保持して画像とテキストを合成する



## 概要
- Viewを画像化する方法で、[SwiftUI exporting the content of Canvas](https://stackoverflow.com/questions/71285697/swiftui-exporting-the-content-of-canvas)に書いてある方法だと画面の端末の表示サイズに合わせてしまうので、取得画像が小さくなる。また記事にある通りViewのframeが設定されていなければ画像を取得できない。
そのため、ViewではなくUIImageからそのまま画像を描画してその上に画像化したTextを合成する方法にする。
- iOS16から[ImageRenderer](https://developer.apple.com/documentation/swiftui/imagerenderer)が使えるようになっているのでこちらを使ってテキスト画像を作成する。
- イメージピッカーはSwiftUIでまだ使えないので[SwiftUIでUIImagePickerControllerを使う](https://zenn.dev/yorifuji/articles/swiftui-imagepicker)にある通り`UIViewControllerRepresentable`を使った方法をそのまま使う。


## 環境
Xcode 14.1
iOS 16.0以上

## 画像合成処理の流れ
1. 選択された画像の表示サイズを保持しておく
1. 入力されたテキストを画像化
1. テキスト画像を実際の画像のサイズに合わすように拡大する
1. ドラッグした位置を調整して選択画像とテキスト画像を合成

## できたもの
![taki.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1734199/172acbd4-efc1-e47f-3399-6c6c090f5f1e.gif)
元画像の3000x2002を保持しています。


## コード

#### メインの画面 ContentView.swift 
```swift
import SwiftUI

class ViewModel: ObservableObject {
    @Published var uiImage: UIImage?
    @Published var inputText = ""
    @Published var frameSize: CGSize = .zero
    @Published var offset: CGSize = .zero
}

struct ContentView: View {
    
    @State private var isImagePickerViewPresented = false

    @ObservedObject private var viewModel = ViewModel()

    var body: some View {
        let imageView = ImageView(viewModel: viewModel)

        VStack {
            imageView
            
            HStack {
                Button("画像選択") {
                    isImagePickerViewPresented.toggle()
                }
                .padding()
                .sheet(isPresented: $isImagePickerViewPresented) {
                    ImagePickerView(image: $viewModel.uiImage, sourceType: .library)
                }
                
                TextField("テキスト", text: $viewModel.inputText)
                    .textFieldStyle(.roundedBorder)
                    .padding()
                
                Button("保存") {
                    let composedImage = imageView.compose()
                    UIImageWriteToSavedPhotosAlbum(composedImage, nil, nil, nil)
                }
                .padding()

            }
        }
    }
}
```

#### 画像を表示するView ImageView.swift
- Textはドラッグ可能です。本当はpositionを使った方法にしたかったが`ImageRenderer`を使った画像化で位置の調整がめんどくさかったのでoffsetで調整しています。
そのためドラッグした位置の調整する記述がちょっと雑になっています。
- 画像合成時に保持したい変数はViewModelに、他は`@State`で使うようにしています。

```swift
import SwiftUI

struct ImageView: View {
    @State private var offset: CGSize = .zero
    @State private var dragging: CGSize = .zero

    @ObservedObject var viewModel: ViewModel

    var body: some View {
        if let uiImage = viewModel.uiImage {
            GeometryReader { proxy in
                ZStack {
                    Image(uiImage: uiImage)
                        .resizable()
                        .scaledToFit()
                        .onAppear {
                            viewModel.frameSize = proxy.size
                        }
                    
                    textView
                }
            }
        }
    }
    
    var textView: some View {
        Text(viewModel.inputText)
            .padding()
            .border(.black)
            .foregroundColor(.red)
            .offset(
                x: offset.width + dragging.width,
                y: offset.height + dragging.height
            )
            .gesture(
                DragGesture()
                    .onChanged { value in
                        dragging.width = value.translation.width
                        dragging.height = value.translation.height
                    }
                    .onEnded { value in
                        offset.width += dragging.width
                        offset.height += dragging.height
                        
                        // 現在の画像オフセットをViewModelに保持
                        viewModel.offset = offset
                        dragging = .zero
                    }
            )
    }

    /// 合成
    @MainActor
    func compose() -> UIImage {

        guard let uiImage = viewModel.uiImage else { return UIImage() }
        
        // テキスト画像を作成
        let textImage = textView.getImage()

        // 選択画像のサイズ
        let imageSize = CGSize(width: uiImage.size.width, height: uiImage.size.height)

        // 拡大倍率
        let ratio: Double
        if uiImage.size.height < uiImage.size.width {
            ratio = uiImage.size.width / viewModel.frameSize.width
        } else {
            ratio = uiImage.size.height / viewModel.frameSize.height
        }
        
        // テキスト画像のサイズ
        let textSize = CGSize(width: textImage.size.width * ratio, height: textImage.size.height * ratio)

        // テキスト画像描画位置
        let x = imageSize.width / 2 - textSize.width / 2  + viewModel.offset.width * ratio
        let y = imageSize.height / 2 - textSize.height / 2  + viewModel.offset.height * ratio

        let format = UIGraphicsImageRendererFormat()
        format.scale = 1.0

        return UIGraphicsImageRenderer(size: imageSize, format: format).image {_ in
            // 画像を描画
            uiImage.draw(in: CGRect(x: 0, y: 0, width: imageSize.width, height: imageSize.height))
            textImage.draw(in: CGRect(origin: CGPoint(x: x, y: y), size: textSize))
        }
    }
}

```

#### Extensions.swift
Viewをそのまま画像化するメソッドをextensionにする。
View内で使われている変数などは初期化されており、画像をドラッグして移動しても移動前の位置で画像が描画される。

```swift
import SwiftUI

extension View {

    @MainActor
    func getImage() -> UIImage {
        let renderer = ImageRenderer(content: self)
        renderer.scale = 1
        return renderer.uiImage ?? UIImage()
    }
}

```


## 悩んだところ
[SwiftUI: 別Viewのメソッドを呼び出す](https://www.fuwamaki.com/article/65) ここの記事にある通り View内に別のインスタンスを作りそこから関数を呼ぶ方法は間違っていると思う。
```swift
let imageView = ImageView(viewModel: viewModel)
imageView

...

let composedImage = imageView.compose()
```

ただ、別Viewを画像化するメソッドがViewModelにあるのは違う気がする。ボタン押下後ViewModelから表示しているViewにどうやってアクセスすればいいかわからず。
どうすべきかわからなかった。

## あとがき
- Textをそのまま画像化するとサイズは表示サイズのままなのでもっとカッコよくする場合は、テキストから画像にする際にフォントサイズの調整や、解像度が高い画像のテンプレートを用意しておくなど方法が必要だと思う。(Line cameraとかそんな感じ)
- 写真ライブラリのアクセスは`Privacy - Photo Library Usage Description`の記述をinfo.plistに忘れずに

## 参考
https://stackoverflow.com/questions/71285697/swiftui-exporting-the-content-of-canvas
https://zenn.dev/yorifuji/articles/swiftui-imagepicker
https://www.fuwamaki.com/article/65