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()
}
}