見出し画像

SwiftUIでImageをピンチイン・ピンチアウト・ダブルタップでズームさせる

SwiftUIで写真Appのように、Imageをピンチイン・ピンチアウト・ダブルタップでの操作を実装してみた。

実装

Imageのピンチイン・ピンチアウトジェスチャーはMagnificationGestureを利用し、onChangedで拡大率を取得できるので、scaleEffectに渡すことでViewを拡大できる。また、ダブルタップはTapGesture(count: 2)を使って、onEndedで拡大・縮小後のスケールを同じようにscaleEffectに渡せばよい。
そして、拡大したImageはScrollView([.vertical, .horizontal])に入れ、contentSizeを拡大したImageのサイズで更新することでスクロール操作ができるようになる。

struct ContentView: View {
    @State private var aspectRatio: CGFloat = 1.0
    
    var body: some View {
        GeometryReader { proxy in
            Image("mountain")
                .resizable()
                .scaledToFit()
                .frame(width: proxy.size.width)
                // backgroundでGeometryReaderを使うことで、対象のViewのサイズを取得できる
                .background(GeometryReader { imageGeometry in
                    Color.clear
                        .onAppear {
                            aspectRatio = imageGeometry.size.width / imageGeometry.size.height
                        }
                })
                .modifier(ImageMagnificationModifier(contentSize: proxy.size, aspectRatio: aspectRatio))
                .background(.black)
                .ignoresSafeArea()
        }
    }
}
struct ImageMagnificationModifier: ViewModifier {
    private var contentSize: CGSize
    private var aspectRatio: CGFloat
    private var minScale: CGFloat = 1.0
    private var maxScale: CGFloat = 5.0
    
    @State private var currentScale: CGFloat = 1.0
    @State private var lastMagnificationValue: CGFloat = 1.0
    
    init(contentSize: CGSize, aspectRatio: CGFloat) {
        self.contentSize = contentSize
        self.aspectRatio = aspectRatio
    }
    
    var magnification: some Gesture {
        MagnificationGesture()
            .onChanged { value in
                // 前回の拡大率に対する今回の拡大率の割合
                let changeRate = value / lastMagnificationValue
                
                DispatchQueue.main.async {
                    // 前回からの拡大率の変化分を考慮した現在のスケールを計算
                    currentScale *= changeRate
                    // 最小・最大スケールの範囲内に収める
                    currentScale = min(max(minScale, currentScale), maxScale)
                }
                
                lastMagnificationValue = value
            }
            .onEnded { value in
                // ジェスチャー開始時は1.0から始まるため、ジェスチャー終了時に1.0に戻す
                lastMagnificationValue = 1.0
            }
    }
    
    var doubleTap: some Gesture {
        TapGesture(count: 2).onEnded {
            // 最小・最大スケールを切り替える
            currentScale = currentScale < maxScale ? maxScale : minScale
        }
    }
    
    func body(content: Content) -> some View {
        ScrollView([.horizontal, .vertical], showsIndicators: false) {
            content
                .scaleEffect(currentScale)
                .gesture(magnification)
                .simultaneousGesture(doubleTap)
                .frame(width: contentSize.width * currentScale,
                       height: contentSize.width / aspectRatio * currentScale,
                       alignment: .center)
        }
    }
}

問題点

上のコードでは、SwiftUIで簡単にImageのズーム操作ができるが、操作性に少し問題点がある。

  • MagnificationGestureの場合、ジェスチャー位置を取得する方法が調べた限りではなさそうで、scaleEffect(_:anchor:)のanchorに渡すことができないため、ジェスチャー位置を中心に拡大することができない。

  • ジェスチャーしながらドラッグすると、ズームが中断されることがある。

改善策

これらの問題点を改善するには、現状UIScrollViewを利用することが良さそう。UIScrollViewの場合、minimumZoomScaleやmaximumZoomScaleを設定すれば、ジェスチャー位置を中心に拡大でき、ズーム操作もスムーズにできる。
ただし、scrollViewの中心にimageViewを配置したり(ズーム時も)、scrollView.contentSizeの更新も煩雑な計算が必要になってしまう。
なお、こちらの改善対応はfeature/uiscrollviewブランチを参照

import SwiftUI

struct ContentView: View {
    var body: some View {
        ImageViewer(imageName: "mountain")
            .ignoresSafeArea()
    }
}

struct ImageViewer: UIViewRepresentable {
    let imageName: String
    
    func makeUIView(context: Context) -> UIImageViewerView {
        let view = UIImageViewerView(imageName: imageName)
        return view
    }
    
    func updateUIView(_ uiView: UIImageViewerView, context: Context) {}
}
import UIKit
import Combine
import CombineCocoa

public class UIImageViewerView: UIView {
    private let imageName: String
    private let scrollView: UIScrollView = UIScrollView()
    private let imageView: UIImageView = UIImageView()
    
    private var cancellables = Set<AnyCancellable>()
    
    required init(imageName: String) {
        self.imageName = imageName
        super.init(frame: .zero)
        
        scrollView.delegate = self
        scrollView.maximumZoomScale = 10.0
        scrollView.minimumZoomScale = 1.0
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.showsVerticalScrollIndicator = false
        
        imageView.image = UIImage(named: imageName)
        imageView.contentMode = .scaleAspectFit
        imageView.isUserInteractionEnabled = true
        imageView.addGestureRecognizer(tapGestureRecognizer)
        
        scrollView.addSubview(imageView)
        addSubview(scrollView)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    public override func layoutSubviews() {
        super.layoutSubviews()
        scrollView.frame = bounds
        
        adjustImageViewSize()
        updateContentSize()
        updateContentInset()
    }
    
    /// imageViewのサイズを調整する
    ///
    /// - note: scrollView.boundsをもとに拡大率を計算して、imageViewのサイズを調整する。
    private func adjustImageViewSize() {
        guard let size = imageView.image?.size else { return }
        let rate = min(scrollView.bounds.width / size.width,
                       scrollView.bounds.height / size.height)
        imageView.frame.size = CGSize(width: size.width * rate,
                                      height: size.height * rate)
    }
    
    /// scrollView.contentSizeを更新する
    ///
    /// - note: scrollView.contentSizeもimageViewのサイズに合わせることで、imageViewの範囲外はスクロールできないようにする。
    private func updateContentSize() {
        scrollView.contentSize = imageView.frame.size
    }
    
    /// scrollView.contentInsetを更新する
    ///
    /// - note: imageViewをscrollViewの中心に表示させる。
    private func updateContentInset() {
        let edgeInsets = UIEdgeInsets(
            top: max((self.frame.height - imageView.frame.height) / 2, 0),
            left: max((self.frame.width - imageView.frame.width) / 2, 0),
            bottom: 0,
            right: 0)
        scrollView.contentInset = edgeInsets
    }
    
    private var tapGestureRecognizer: UITapGestureRecognizer {
        let tapGestureRecognizer = UITapGestureRecognizer()
        tapGestureRecognizer.numberOfTapsRequired = 2
        tapGestureRecognizer
            .tapPublisher
            .sink { [weak self] recognizer in
                self?.onDoubleTap(recognizer: recognizer)
            }
            .store(in: &cancellables)
        return tapGestureRecognizer
    }
    
    private func onDoubleTap(recognizer: UITapGestureRecognizer) {
        let maximumZoomScale = scrollView.maximumZoomScale

        if maximumZoomScale != scrollView.zoomScale {
            let tapPoint = recognizer.location(in: imageView)
            let size = CGSize(
                width: scrollView.frame.size.width / maximumZoomScale,
                height: scrollView.frame.size.height / maximumZoomScale)
            let origin = CGPoint(
                x: tapPoint.x - size.width / 2,
                y: tapPoint.y - size.height / 2)
            scrollView.zoom(to: CGRect(origin: origin, size: size), animated: true)
        } else {
            scrollView.zoom(to: scrollView.frame, animated: true)
        }
    }
}

extension UIImageViewerView: UIScrollViewDelegate {
    public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }
    
    public func scrollViewDidZoom(_ scrollView: UIScrollView) {
        // ズーム終了時にscrollView.contentInsetを更新して、imageViewをscrollViewの中心に表示させる。
        updateContentInset()
    }
}

リポジトリ

参考


この記事が気に入ったらサポートをしてみませんか?