SwiftUI で画像の拡大縮小からの画像の切り抜き

ImageRenderer を使っているので iOS 16+ 向けです。その部分さえ他のブログで紹介されている方法 (UIGraphicsImageRenderer とか) で置き換えれば iOS 15 などでも動作するかと思います。

PhotosPicker などから取得した画像を、ユーザーが調整した特定のサイズや配置で切り取りたい時に使う View。既にライブラリなどを公開されている方もいるので今更ですが。
画像をぐりぐりするコードは以下のブログから流用させてもらいました。ありがとうございます。
https://qiita.com/ShinTTK/items/dba55af0912acde90a71

切り取り前の View (PhotoResizeView 部分)
切り取り後(受け取った後の表示例)。表示時に clipShape しているので、実際の画像データ自体は正方形です。
import Foundation
import SwiftUI

struct PhotoResizeView: View {
    @State var image: UIImage
    @State var guideWidth: CGFloat = 256
    @State var guideHeight: CGFloat = 256
    @State var closure: (UIImage?) -> Void
    
    @State var offset: CGSize = .zero
    @State var lastOffset: CGSize = .zero
    @State var scale: CGFloat = 1.0
    @State var lastScale: CGFloat = 1.0
    @State var angle: Angle = .zero
    @State var lastAngle: Angle = .zero
    let minScale = 0.2
    let maxScale = 5.0
    
    var imageView: some View {
        Image(uiImage: self.image)
            .rotationEffect(self.angle, anchor: .center)
            .scaleEffect(self.scale)
            .offset(self.offset)
            .gesture(self.dragGesture)
            .gesture(self.scaleGesture)
            // iOS17+
            //.gesture(SimultaneousGesture(self.rotateGesture, self.scaleGesture))
    }

    @Environment(\.dismiss) var dismiss

    var dragGesture: some Gesture {
        DragGesture()
            .onChanged({
                self.offset = CGSize(width: self.lastOffset.width + $0.translation.width, height: self.lastOffset.height + $0.translation.height)
            })
            .onEnded({ _ in
                self.lastOffset = self.offset
            })
    }
    var scaleGesture: some Gesture {
        MagnificationGesture()
            .onChanged({
                if ($0 > self.minScale) && ($0 < self.maxScale) {
                    self.scale = $0 * self.lastScale
                }
            })
            .onEnded({ _ in
                self.lastScale = self.scale
            })
    }
    /* iOS17+
    var rotateGesture: some Gesture {
        RotateGesture(minimumAngleDelta: .degrees(8))
            .onChanged({
                self.angle = $0.rotation + self.lastAngle
            })
            .onEnded({ _ in
                self.lastAngle = self.angle
            })
    }*/
    
    var body: some View {
        ZStack(alignment: .center) {
            GeometryReader { proxy in
                imageView
                    .position(x: proxy.frame(in: .local).width / 2, y: proxy.frame(in: .local).height / 2)
            }
            RoundedRectangle(cornerRadius: 10)
                .stroke(style: StrokeStyle(lineWidth: 2, dash: [2, 6]))
                .stroke(.red, lineWidth: 1.0)
                .frame(width: self.guideWidth, height: self.guideHeight, alignment: .center)
            VStack {
                Button(action: {
                    let renderer = ImageRenderer(content: imageView.frame(width: self.guideWidth, height: self.guideHeight).clipped())
                    if let snapshotImage = renderer.uiImage {
                        closure(snapshotImage)
                    } else {
                        closure(nil)
                    }
                    dismiss()
                }, label: {
                    Image(systemName: "checkmark")
                        .bold()
                        .imageScale(.large)
                        .padding(8)
                }).padding(.trailing).padding(.bottom, 8)
                    .buttonStyle(FloatButtonStyle(foregroundColor: .white, backgroundColor: .green))
                Button(action: {
                    closure(nil)
                    dismiss()
                }, label: {
                    Image(systemName: "xmark")
                        .bold()
                        .imageScale(.large)
                        .padding(8)
                }).padding(.trailing).padding(.bottom, 8)
                    .buttonStyle(FloatButtonStyle(foregroundColor: .white, backgroundColor: .red))
                Button(action: {
                    self.offset = .zero
                    self.lastOffset = .zero
                    self.scale = 1.0
                    self.lastScale = 1.0
                    self.angle = .zero
                    self.lastAngle = .zero
                }, label: {
                    Image(systemName: "arrow.counterclockwise")
                        .imageScale(.large)
                        .padding(8)
                }).padding(.trailing).padding(.bottom, 8)
                    .buttonStyle(FloatButtonStyle())
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
        }
    }
}

struct FloatButtonStyle: ButtonStyle {
    @State var foregroundColor: Color = .blue
    @State var backgroundColor: Color = .white
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundStyle(self.foregroundColor)
            .background(self.backgroundColor)
            .clipShape(Circle())
            .shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 1)
            .opacity(configuration.isPressed ? 0.2 : 1)
    }
}

当初、画像のリサイズをどうしたものかと思い色々調べて行きましたが、表示領域と実際の画像のサイズの整合性をとる調整がスキル不足でなかなかうまくいかず、最終的に上記の方法に落ち着きました。
対応のポイントとしてはいくつかありました。

  1. ZStack を使って調整対象となる画像レイヤーと、ガイドラインとなる枠レイヤー、決定や取り消しリセットなどの操作系のレイヤーを設けています。
  2. ZStack の特性として alignment などのレイアウトの基準が「一番大きい View を基準にする」ようなので、調整対象のイメージが画面サイズより大きい場合に他のレイヤーのコンポーネントの配置に注意が必要です。何もしないと画像サイズが大きい場合に他のレイヤーが大きい画像の View に引きずられて画面外に追いやられてしまいます。多分。
    そのため、一番大きい画像レイヤーを GeometryReader で囲った後に配置の調整を行うため .position で初期表示位置を中央にしています(後の画像切り抜き時にも関係します)。
  3. 調整と切り抜く対象となる画像レイヤーについて、任意のタイミングで切り抜きを行いたいため、modifier を指定した View として扱えるようにするために19行目で変数 (imageView) として切り出しています。
  4. 切り抜き時に ImageRenderer を使いますが、View の指定こそしますが切り抜き位置などの細かい調整を行なっていません。これは View が中心に配置された時を基準にして計算されることを利用しています。多分。違ってたらすみません。
    ので、View 上に表示されたものをそのまま利用しますが、UIImage や CIImage などを使って本格的に加工したりするわけではありません。本格的な加工が必要な場合は先人の事例 (“UIImage リサイズ” などの検索ワードで出てくると思います) が沢山あるかと思いますので目的のものを探してみてください。

PhotosPicker の利用後に画像を今回の View に渡して使う想定なので、View 内の @State var image: UIImage は ? つけて Optional の方がそのまま値を渡せて使いやすいかもしれません。

使い方は以下一部抜粋ですが、.fullScreenCover などの中で呼ぶことを想定しているので PhotoResizeView 内では Closure 経由で調整後の画像を返した後に dismiss() で画面を閉じています。この辺りは Closure などではなく @Binding で値をやりとりするなどでも良いかと思います。処理をキャンセルした場合は Closure で nil を返すのでそこで判定します。
画像を丸く切り抜きたい場合は、69行目のガイドラインを Circle にして、75行目の imageView の modifier に clipShape などで Circle() を指定すれば行けるんじゃないでしょうか。SwiftUI の場合は表示時の加工が楽なので正方形でも良いかもです。

    .fullScreenCover(isPresented: self.$isPhotoViewPresented, content: {
        if let targetImage = self.selectedImage {
            PhotoResizeView(image: targetImage, closure: { newImage in
                if newImage != nil {
                     self.image = newImage
                 } else {
                     print("newImage is nil.")
                }
            })
        }
    })

本記事がどなたかの参考になれば幸いです。