SwiftUIでInstagramのような写真のズームを実装する
はじめに
InstagramやFacebookのiOSアプリにはタイムラインにある写真をピンチジェスチャーでズームできる機能があります。
この機能を実現するためにInteractiveZoomDriverというものをUIKitで作成していました。
それをPairsのプロフィール写真でも活用しています。
最近はSwiftUIで実装することが増えてきたのもあって、今回このInteractiveZoomDriverのSwiftUI版を実装しました。
目指す挙動
- 画像のある画面から直接ピンチジェスチャーで画像の拡大・移動ができる(よくある画像ビュワーにように画像をタップして別の画面が開いてそこで拡大・移動をするのではなく)
- ScrollViewの上など途中で場所が移動しても問題なく動作する
- NavigationViewやTabViewがあってもそれよりも前面に表示できる
- 拡大・移動中は背景が暗くなる
実装について
上記の挙動を実現するにあたって、次のような設計にしました。
オレンジの箇所が今回実装した部分になります。
ポイントとしてはジェスチャーのハンドリング周りとNavigationViewやTabViewなどの中に拡大したいViewがある場合にどうやってそれを超えて前面に表示できるようにするかというところです。
Gesture(ZoomGestureView)
最初はSwiftUIのGestureを使おうと思っていたのですが、ピンチをしながらの移動が難しかったので、結論としてはUIPinchGestureRecognizerとUIPanGestureRecognizerを使うようにしました。
SwiftUIでジェスチャーを実装するのを考えた時にDragGestureとかMagnificationGestureを使えばいけそうかなと想像していたのですが、DragGestureとMagnificationGestureのsimultaneously(with:)やsequenced(before:)は期待通りの挙動にはならなかったです。(それぞれハンドリングすることはできるが同時には適応できない)
SwiftUIのRotationGestureとMagnificationGestureのsimultaneously(with:)は同時に動作していたので、どちらも二本指での操作など動作の連続性があるGestureでしか同時には動かないのかなと想像しました。
あと、Gestureをカスタムして実装しようと思っても、指の本数とか位置などを取得・制御する方法がなさそうだったので、SwiftUIでピンチジェスチャーとパンジェスチャーを同時に実現するのは今のところ難しいのかなと思います。
こちらがジェスチャーをハンドリングするためのコードです。
struct ZoomGestureView: UIViewRepresentable {
@Binding var scale: CGFloat
@Binding var offset: CGPoint
@Binding var isPinching: Bool
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let pinchGesture = UIPinchGestureRecognizer(
target: context.coordinator,
action: #selector(context.coordinator.hundlePinchGesture(sender:))
)
view.addGestureRecognizer(pinchGesture)
let panGesture = UIPanGestureRecognizer(
target: context.coordinator,
action: #selector(context.coordinator.hundlePan(sender:))
)
panGesture.maximumNumberOfTouches = 2
panGesture.delegate = context.coordinator
view.addGestureRecognizer(panGesture)
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
private var parent: ZoomGestureView
init(parent: ZoomGestureView) {
self.parent = parent
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
@objc func hundlePinchGesture(sender: UIPinchGestureRecognizer) {
if sender.state == .began || sender.state == .changed, sender.scale > 1 {
parent.isPinching = true
parent.scale = sender.scale
} else {
parent.isPinching = false
parent.scale = 1
}
}
@objc func hundlePanGesture(sender: UIPanGestureRecognizer) {
if sender.state == .began || sender.state == .changed && parent.scale > 1 {
parent.offset = sender.translation(in: sender.view)
} else {
parent.offset = .zero
}
}
}
}
Presentation(InteractiveZoomContainer, ZoomContext)
上記のZoomGestureViewでジェスチャーをハンドリングしてズーム中の状態になったら、拡大するためのViewを前面に表示します。
拡大するViewを表示するために子View(ズーム対象のView)から親View(前面に拡大して表示するView)に情報を伝えるためにPreferenceを使っています。
拡大するViewの表示位置を決めるにあたって、元のViewのFrameを知る必要があるので、それはGeometryReaderを使って取得しています。
struct FramePreferenceKey: PreferenceKey {
typealias Value = CGRect?
static var defaultValue: Value = nil
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
// 拡大する元のView
struct ZoomContext<Content: View>: View {
...
var body: some View {
content
.overlay(GeometryReader { proxy in
Color.clear
.preference(key: FramePreferenceKey.self, value: proxy.frame(in: .global))
})
}
}
// 拡大したViewを表示するView
struct InteractiveZoomContainer<Content: View>: View {
...
var body: some View {
ZStack {
content
overlayView
.offset(x: ..., y: ...)
}
.onPreferenceChange(FramePreferenceKey.self) { value in
originalFrame = value ?? .zero
}
}
}
そのほかにも元のViewはジェスチャーをハンドリングするために使って、実際に拡大される画像は、元のViewのコピーを作成して別のレイヤーで表示するようにしています。
そのため子Viewから対象のView(今回はImage)を親Viewまで渡してあげる必要があります。
ここもPreferenceを使っていますが、そのままAnyViewなどを指定するとEquatableに準拠していないのでonPreferenceChange()を呼び出すことができません。
そこでEquatableViewContainerというものを用意して対応しています。
struct EquatableViewContainer: Equatable {
let id = UUID().uuidString
let view: AnyView?
static func == (lhs: EquatableViewContainer, rhs: EquatableViewContainer) -> Bool {
return lhs.id == rhs.id
}
}
struct AnyViewPreferenceKey: PreferenceKey {
typealias Value = EquatableViewContainer?
static var defaultValue: Value = nil
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
親Viewの表示・非表示を制御するためにAnimationのCompletionを知る必要がありますが、SwiftUIはAnimationのCompletionが取得できないのでAnimatableModifierを使って代替しました。
struct AnimatableCompletionModifier: AnimatableModifier {
private var targetValue: Double
var animatableData: Double {
didSet {
checkIfFinished()
}
}
var completion: () -> ()
init(bindedValue: Double, completion: @escaping () -> ()) {
self.completion = completion
self.animatableData = bindedValue
targetValue = bindedValue
}
func checkIfFinished() -> () {
if (animatableData == targetValue) {
DispatchQueue.main.async {
self.completion()
}
}
}
func body(content: Content) -> some View {
content
}
}
あとは、ZoomContextからハンドリングしたジェスチャーの値をInteractiveZoomContainerがPreferenceで受け取って各Viewに対してScaleやOffset、Opacityなどを設定してあげればいい感じに動くようになります。
実際に使うときは、以下のような感じで実装することができます。
struct DemoApp: App {
var body: some Scene {
WindowGroup {
// ズームするViewを表示したい箇所に設定
// 今回はNavigationViewより上に表示したいのでここに追加
InteractiveZoomContainer {
ContentView()
}
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
Image("xxx")
.addPinchZoom() // <- Zoomして拡大したViewに対して追加
}
}
}
完成したデモアプリ
今回紹介したアプローチとは別で、iOS14から登場したfullScreenCoverを使うアプローチも面白いかなと思い検討したのですが、Transition周りの制御で少し苦労しそうだったので(遷移時のちらつきなど)、今回のアプローチを採用しました。
今回のアプローチの利点としては、どの階層に対して拡大の表示を適応したいのかが指定できるので、もしNavigationよりも下の階層で表示したいとかを実装側で制御することができます。
完成したコードはこちらです。https://github.com/shima11/InteractiveZoomDriver_SwiftUI
目指した挙動はクリアできたのですがまだいくつか課題はあって、たとえば同じ画面内で複数の画像に設定した時や、タブを切り替えた時にうまく動かないなどの課題があります。
今後は、ライブラリとして公開できるところまで整えていく予定です。
まとめ
SwiftUIでInstagramのような写真のズームを実装してみました。
SwiftUIを使うことで実装が簡単になった部分もあれば、まだまだ細かい部分を制御しようとすると機能が足りなくて実現が難しい部分が多いかなという印象でした。
今回実装していて特にGesture周りとかはUIKitの力を借りないと実現が難しい場面がまだまだあるのかなと思います。