iOS アプリで自身をドラッグで動かせる UIView を丁寧に作る。

デモ

デモ

概要

ドラッグできる UIView は割とよく作られる類の顔をしているが、実際のところ実はあまり作る機会がないのではないか…という気がする。

実際、ググっても丁寧に作って踏み込んで解説している情報はあまり見当たらないような気がするので、いっちょ書いたるか、ということで筆を取った。

簡単に作るならすぐに実装できるのだが、丁寧に作ると若干めんどくさい。

丁寧に作るとはどういうことかというと、

  • frame を直接弄らない。

  • Storyboard や xib で作って AutoLayout の制約を貼っても破綻しないようにする。

  • コードで生成しても破綻しないようにする。

  • ドラッグできる View が入れ子になっても破綻しないようにする。

  • ピンチやローテーションで拡大縮小したり回転できて、入れ子になりつつ回転・拡大縮小されていても破綻しないようにする。

  • superview から飛び出ないような調整ができるようにする。

こんな感じだろうか。

方針としては UIGestureRecognizer を用いて UIViewtransform に指の動きに応じたアフィン変換行列を放り込む、という感じ。

UIRespondertouches... 系のメソッドを用いてもいいのだが、拡大縮小回転を作ろうとなると UIKit の velocity(in:)scalerotation の秘伝のアルゴリズムを使わせてくれる UIGestureRecognizer を使う方が正しくネイティブアプリとして望ましい挙動を示すことは言うまでもない。

ドラッグ可能な View を入れ子にしつつ拡大縮小と回転が絡むと、行列のかけ算は順番が変わると結果も変わるという遠い昔の知識が蘇えってきたりもするので注意しながら実装していくことになる。

まずは最小限の実装

拡大・縮小・回転はせず、ただ単に指にくっ付いて上下左右に動かしたいだけなら UIPanGestureRecognizer を用いて以下のような数行の実装をすれば良い。

open class DraggableUIView: UIView {

    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        let panGestureRecognizer = UIPanGestureRecognizer()
        panGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        addGestureRecognizer(panGestureRecognizer)
    }

    @objc
    private func handleGestureRecognizer(_ sender: UIPanGestureRecognizer) {
        let translation = sender.translation(in: self)
        transform = transform.translatedBy(x: translation.x, y: translation.y)
        sender.setTranslation(.zero, in: self)
    }

}

別 framework に切る想定なので classopen を付けている。

ちなみに僕自身 10 年以上 iOS アプリを書いているはずなのに UIPanGestureRecognizersetTranslation(_:) を使うと実装はたったこれだけになるというテクニックは今回初めて知りました。便利…。

上の例では UIView をサブクラッシングして実装している。

Protocol extension による実装も考えたが、 UIGestureRecognizer を使用する場合 Target Action パターンなので @objc func が要求されるため単純に相性が悪いし、使う側が実装することが増えてしまい使い勝手が悪い。

これではコードで生成した場合に使えないため、 required init?(coder: NSCoder)init(frame: CGRect) 両方に初期化コードを実装して対応すると

open class DraggableUIView: UIView {

    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    override public init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    private func commonInit() {
        let panGestureRecognizer = UIPanGestureRecognizer()
        panGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        addGestureRecognizer(panGestureRecognizer)
    }

    @objc
    private func handleGestureRecognizer(_ sender: UIPanGestureRecognizer) {
        // ... 中略 ...
    }

}

まぁ大体こういう感じになる。

毎回似たような実装をしていて思うのだが、コードで生成する場合と Storyboard/xib で生成する場合で初期化パスが違うため、こんな感じの実装をせざるを得ないのがスマートではない。SwiftUI を使えということなのでしょうか…。

拡大縮小と回転もできるようにする。

UIPinchGestureRecognizerUIRotationGestureRecognizer を使えば拡大縮小と回転は簡単に実装できる。

これこそ UIGestureRecognizer を使用する利点と言える。

open class DraggableUIView: UIView {

    // ... 中略 ...

    private func commonInit() {
        let panGestureRecognizer = UIPanGestureRecognizer()
        let pinchGestureRecognizer = UIPinchGestureRecognizer()
        let rotationGestureRecognizer = UIRotationGestureRecognizer()
        panGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        pinchGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        rotationGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        addGestureRecognizer(panGestureRecognizer)
        addGestureRecognizer(pinchGestureRecognizer)
        addGestureRecognizer(rotationGestureRecognizer)
    }

    @objc
    private func handleGestureRecognizer(_ sender: UIGestureRecognizer) {
        if let pan = sender as? UIPanGestureRecognizer {
            let translation = pan.translation(in: self)
            transform = transform.translatedBy(x: translation.x, y: translation.y)
            pan.setTranslation(.zero, in: self)
        } else if let pinch = sender as? UIPinchGestureRecognizer {
            transform = transform.scaledBy(x: pinch.scale, y: pinch.scale)
            pinch.scale = 1.0
        } else if let rotation = sender as? UIRotationGestureRecognizer {
            transform = transform.rotated(by: rotation.rotation)
            rotation.rotation = 0.0
        }
    }

}

個人的に今まで毎回 UIGestureRecognizer.State を見て .began の時の値をインスタンス変数に格納して .changed で来た値と比較しつつ .ended, .cancelled, .failed で終了処理をするという実装をよくしていたのだが、そんなことしなくても setScale(_:)setRotation(_:) を使うと簡単に実装できる。このテクニックも同様に今回初めて知りました…。

上の例では gestureRecognizer の Action に同じメソッドを指定して引数の sender の型を見て判別する実装をしているが、コーディング規約などによっては 1 Target 1 Action としている現場もあると思うので、その辺はよしなに実装すると良い。

平行移動と拡大縮小と回転を同時にできるようにする。

UIGestureRecognizerDelegategestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) にて true を返してやるだけで良い。

これこそ UIGestureRecognizer を使用する利点と言える。 (2回目)

しかし雑に true を返してしまうと View 階層によっては親や子のスクロールの動きと競合して意図しない動作をする可能性があるため、通知元の gestureRecognizer を丁寧に区別してやった方が良いユーザー体験が得られる。

UIView をサブクラッシングして作っているので、 selfUIGestureRecognizerDelegate に指定してしまうとちょっと良くないことが起こりそうな予感がする。
実際 UIView には gestureRecognizerShouldBegin(_:) が実装されていて、ドキュメントにも記載されている。これは UIGestureRecognizerDelegate で指定されているメソッドと同名であり、経験豊かな iOS アプリエンジニアならちょっとイヤな予感がするのは共感してもらえるだろう。
実際 UIKit も内部で gestureRecognizer を使っているクラスは多数存在し、例えば UIScrollView などではプライベートな UIGestureRecognizer のサブクラスが使われていたりする

将来的にも問題が起きないように delegate メソッドを受けるためだけの private class を切って、そちらで通知を受けるようにすると良さそうだ。

また delegate オブジェクトから gestureRecognizer を区別できるようにインスタンスの定数に fileprivate スコープで出しておく。

open class DraggableUIView: UIView {

    fileprivate let panGestureRecognizer = UIPanGestureRecognizer()
    fileprivate let pinchGestureRecognizer = UIPinchGestureRecognizer()
    fileprivate let rotationGestureRecognizer = UIRotationGestureRecognizer()

    private let gestureRecognizerDelegateObject = DraggableUIViewGestureRecognizerDelegateObject()

    // ... 中略 ...

    private func commonInit() {
        // ... 中略 ...
        gestureRecognizerDelegateObject.parent = self
        panGestureRecognizer.delegate = gestureRecognizerDelegateObject
        pinchGestureRecognizer.delegate = gestureRecognizerDelegateObject
        rotationGestureRecognizer.delegate = gestureRecognizerDelegateObject
    }

    // ... 中略 ...

}

private final class DraggableUIViewGestureRecognizerDelegateObject: NSObject, UIGestureRecognizerDelegate {

    fileprivate weak var parent: DraggableUIView?

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let parent = parent else {
            return false
        }
        // 順番が分からないので全パターン網羅する必要がある。
        // 雑に return true すると入れ子になった場合や scrollView に入れた場合などに問題が発生するため、きちんと同時に呼ばれるものを見分ける必要がある。
        switch (gestureRecognizer, otherGestureRecognizer) {
        case (parent.panGestureRecognizer, parent.pinchGestureRecognizer),
            (parent.panGestureRecognizer, parent.rotationGestureRecognizer),
            (parent.pinchGestureRecognizer, parent.panGestureRecognizer),
            (parent.pinchGestureRecognizer, parent.rotationGestureRecognizer),
            (parent.rotationGestureRecognizer, parent.panGestureRecognizer),
            (parent.rotationGestureRecognizer, parent.pinchGestureRecognizer):
            return true
        default:
            return false
        }
    }

}

それぞれの GestureRecognizer の値からアフィン変換行列を作って自前で合成するような泥臭いやり方しかないかな…と最初はそういう実装を試したのだが、よくよく調べてみると UIKit がよく出来ているためそんなことする必要は全く無かった。

iOS アプリを実装する際、 UI に関する操作は必ずメインスレッドから触るという決まり事があると思うが、 gestureRecognizer もその原則通りきちんとメインスレッドから呼ばれるため、「複数の gestureRecognizer が同時に gesture を受け取れる」とは言ってもそれはマルチスレッドのようなものではなく、あくまで UI 上での同時となる。どちらかと言えば gestureRecognizer を排他利用しない、という感覚の方が近い。

拡大率の上限と縮小率の下限を設定できるようにする。

指定できた方が便利だろうから、拡大縮小の制限ができるような機構も作る。

この辺から状態となるインスタンス変数を var で持っておく必要が出てくる。

open class DraggableUIView: UIView {

    @IBInspectable public var minScale: CGFloat = 0.0
    @IBInspectable public var maxScale: CGFloat = 0.0

    // ... 中略 ...

    private var currentScale: CGFloat = 1.0

    // ... 中略 ...

    @objc
    private func handleGestureRecognizer(_ sender: UIGestureRecognizer) {
        if let pan = sender as? UIPanGestureRecognizer {
            // ... 中略 ...
        } else if let pinch = sender as? UIPinchGestureRecognizer {
            if pinch.scale < 1.0 && minScale > 0.0 && minScale > currentScale * pinch.scale {
                // nop. min limit
            } else if pinch.scale > 1.0 && maxScale >= 1.0 && currentScale * pinch.scale > maxScale {
                // nop. max limit
            } else {
                currentScale *= pinch.scale
                transform = transform.scaledBy(x: pinch.scale, y: pinch.scale)
                pinch.scale = 1.0
            }
        } else if let rotation = sender as? UIRotationGestureRecognizer {
            // ... 中略 ...
        }
    }

}

上の実装例では minScale0.0 以下を入れておけば下限は機能しないし、 maxScale1.0 未満を入れておけば上限は機能しない。

ドラッグして動く範囲を superview 内に制限する。

だんだん実装もややこしくなってくる。superview 内に収まっているかどうかの判定をちゃんとやるには frameboundscentertransform の関係をちゃんと理解しつつ、 CGGeometory の便利関数を上手く使ったり、行列の演算などをなんやかんやしていくことになる。

とは言っても UIView に transform を放り込んだ際の frame の計算式がどうなっているか、実際のところはブラックボックスなためよく分からない。そこで実際に transform を放り込んだ後に frame をチェックしてしまうのが良い。

transform を放り込んでから frame をチェックして、飛び出していたら戻す、あるいは飛び出した分を補正した transform を放り込み直すという方針で実装するなんて無駄が多いように思えるが、実際のところ transform を変更してもすぐに再レイアウトや再描画などが走るということはなく、重い処理は次の画面のリフレッシュのタイミングまで遅延されるため問題はない。

open class DraggableUIView: UIView {

    // ... 中略 ...

    @IBInspectable public var isLimitDragInside: Bool = true
    public var limitInsideInsets: UIEdgeInsets = .zero

    // ... 中略 ...

    private var overX: CGFloat = 0.0
    private var overY: CGFloat = 0.0

    @objc
    private func handleGestureRecognizer(_ sender: UIGestureRecognizer) {
        guard let superview = superview else {
            return
        }

        if let pan = sender as? UIPanGestureRecognizer {
            let translation = pan.translation(in: self)
            let tMove = CGAffineTransform(translationX: translation.x, y: translation.y)
            let tOver = CGAffineTransform(translationX: overX, y: overY)
            let t = tMove.concatenating(transform).concatenating(tOver)  // 回転してたりすると translatedBy で適当に作った行列では直感的な結果が得られないし破綻する。
            transform = t
            if !isLimitDragInside {
                overX = 0.0
                overY = 0.0
            } else {
                let nextFrame = frame
                let limitFrame = superview.bounds.inset(by: limitInsideInsets)
                if !limitFrame.contains(nextFrame) {
                    // 親からはみ出た分。正負は UIKit の座標系に準ずる。
                    let dx = nextFrame.minX < limitFrame.minX ? nextFrame.minX - limitFrame.minX : nextFrame.maxX > limitFrame.maxX ? nextFrame.maxX - limitFrame.maxX : 0.0
                    let dy = nextFrame.minY < limitFrame.minY ? nextFrame.minY - limitFrame.minY : nextFrame.maxY > limitFrame.maxY ? nextFrame.maxY - limitFrame.maxY : 0.0
                    transform = t.concatenating(CGAffineTransform(translationX: -dx, y: -dy)) // 回転してたりすると translatedBy では直感的な結果が得られないし破綻する。
                    overX = dx
                    overY = dy
                } else {
                    overX = 0.0
                    overY = 0.0
                }
            }
            pan.setTranslation(.zero, in: self)
        } else if let pinch = sender as? UIPinchGestureRecognizer {
            if pinch.scale < 1.0 && minScale > 0.0 && minScale > currentScale * pinch.scale {
                // nop. min limit
            } else if pinch.scale > 1.0 && maxScale >= 1.0 && currentScale * pinch.scale > maxScale {
                // nop. max limit
            } else {
                if !isLimitDragInside {
                    currentScale *= pinch.scale
                    transform = transform.scaledBy(x: pinch.scale, y: pinch.scale)
                } else {
                    let before = transform
                    transform = transform.scaledBy(x: pinch.scale, y: pinch.scale)
                    if superview.bounds.inset(by: limitInsideInsets).contains(frame) {
                        currentScale *= pinch.scale
                    } else {
                        transform = before
                    }
                }
            }
            // pan の .ended が来る前にここに来ることもあるのでリセットが必要…。
            overX = 0.0
            overY = 0.0
            pinch.scale = 1.0
        } else if let rotation = sender as? UIRotationGestureRecognizer {
            if !isLimitDragInside {
                transform = transform.rotated(by: rotation.rotation)
            } else {
                let before = transform
                transform = transform.rotated(by: rotation.rotation)
                if !superview.bounds.inset(by: limitInsideInsets).contains(frame) {
                    transform = before
                }
            }
            // pan の .ended が来る前にここに来ることもあるのでリセットが必要…。
            overX = 0.0
            overY = 0.0
            rotation.rotation = 0.0
        }
    }

}

また状態となる var が増えてしまったがしょうがない。状態が増えた分、リセットのタイミングをちゃんと把握しておかないと容易にバグるため、ちゃんとデバッグする必要がある。

特にめんどくさいのは行列の演算の順番で

// ... 中略 ...
let tMove = CGAffineTransform(translationX: translation.x, y: translation.y)
let tOver = CGAffineTransform(translationX: overX, y: overY)
let t = tMove.concatenating(transform).concatenating(tOver)
// ... 中略 ...

// ... 中略 ...
transform = t.concatenating(CGAffineTransform(translationX: -dx, y: -dy))
// ... 中略 ...

の部分。

もちろん順番を変えると変な動きになる。

外に飛び出さないようにしつつ、指の動きに合わせて動くようにした方が体験が良いように思えたので、そうなるように実装した結果上の様になった。

また、 UIEdgeInsets で保持している limitInsideInsets には負の値を放り込むことができて、その場合は制限が外側に拡張される。

実用上もうちょっと便利なフラグや delegate を用意する。

delegate の用意

実際にドラッグ可能な View を使うにあたって

  • ドラッグが開始された時
  • ドラッグ最中で刻々と変わっている時
  • ドラッグが終わった時

などのタイミングは知りたいことが多いだろうから delegate メソッドとして実装しておくと良いだろう。

(もっと踏み込んで should 系のメソッドも実装した方がいいかもしれないが、まだ作っていない。)

フラグの用意

また「拡大縮小は不要で平行移動と回転だけ使いたい」などのユースケースも考えられるし、有効無効は各々で切り替えられるようにした方が便利だろう。

gestureRecognizer の isEnabled を弄る手法と delegate 内の gestureRecognizerShouldBegin(_:) で真偽値を返す手法が考えられるが、 gesture を受け取ってる最中に isEnabled が弄られると .cancelled が飛んできてすぐ止まるなど、細かい挙動は全然変わるため、要求に応じて実装するのが良いと思う。

リセット機能の用意

完全に好みの問題だが、ダブルタップしたら元の位置にアニメーションで戻る機能などもあると便利かなという気持ちで実装する。

全体像

なんやかんや実装するとこんな感じになった。

import UIKit

public protocol DraggableUIViewDelegate: AnyObject {
    func draggableViewDidBeginDragging(_ sender: DraggableUIView)
    func draggableViewDidDrag(_ sender: DraggableUIView)
    func draggableViewDidEndDragging(_ sender: DraggableUIView)
    func draggableViewDidReset(_ sender: DraggableUIView)
}

open class DraggableUIView: UIView {

    public weak var delegate: DraggableUIViewDelegate?

    @IBInspectable public var isDraggingEnabled: Bool = true

    @IBInspectable public var isPanEnabled: Bool = true
    @IBInspectable public var isPinchEnabled: Bool = true
    @IBInspectable public var isRotationEnabled: Bool = true
    @IBInspectable public var isDoubleTapToResetEnabled: Bool = true

    @IBInspectable public var minScale: CGFloat = 0.1
    @IBInspectable public var maxScale: CGFloat = 10.0

    @IBInspectable public var isLimitDragInside: Bool = true
    public var limitInsideInsets: UIEdgeInsets = .zero
    @IBInspectable public var limitInsideInsetsTop: CGFloat {
        get { limitInsideInsets.top }
        set { limitInsideInsets.top = newValue }
    }
    @IBInspectable public var limitInsideInsetsLeft: CGFloat {
        get { limitInsideInsets.left }
        set { limitInsideInsets.left = newValue }
    }
    @IBInspectable public var limitInsideInsetsBottom: CGFloat {
        get { limitInsideInsets.bottom }
        set { limitInsideInsets.bottom = newValue }
    }
    @IBInspectable public var limitInsideInsetsRight: CGFloat {
        get { limitInsideInsets.right }
        set { limitInsideInsets.right = newValue }
    }

    public private(set) var isDragging = false {
        didSet {
            if oldValue != isDragging {
                if isDragging == true {
                    delegate?.draggableViewDidBeginDragging(self)
                } else {
                    delegate?.draggableViewDidEndDragging(self)
                }
            }
        }
    }

    fileprivate let doubleTapGestureRecognizer = UITapGestureRecognizer()
    fileprivate let panGestureRecognizer = UIPanGestureRecognizer()
    fileprivate let pinchGestureRecognizer = UIPinchGestureRecognizer()
    fileprivate let rotationGestureRecognizer = UIRotationGestureRecognizer()
    private let gestureRecognizerDelegateObject = DraggableUIViewGestureRecognizerDelegateObject()
    private var currentScale: CGFloat = 1.0

    // MARK: - Initializer

    override public init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    private func commonInit() {
        doubleTapGestureRecognizer.numberOfTapsRequired = 2
        doubleTapGestureRecognizer.addTarget(self, action: #selector(Self.handleDoubleTapGestureRecognizer(_:)))
        panGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        pinchGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        rotationGestureRecognizer.addTarget(self, action: #selector(Self.handleGestureRecognizer(_:)))
        gestureRecognizerDelegateObject.parent = self
        doubleTapGestureRecognizer.delegate = gestureRecognizerDelegateObject
        panGestureRecognizer.delegate = gestureRecognizerDelegateObject
        pinchGestureRecognizer.delegate = gestureRecognizerDelegateObject
        rotationGestureRecognizer.delegate = gestureRecognizerDelegateObject
        addGestureRecognizer(doubleTapGestureRecognizer)
        addGestureRecognizer(panGestureRecognizer)
        addGestureRecognizer(pinchGestureRecognizer)
        addGestureRecognizer(rotationGestureRecognizer)
    }

    // MARK:

    @objc
    private func handleDoubleTapGestureRecognizer(_ sender: UITapGestureRecognizer) {
        overX = 0.0
        overY = 0.0
        currentScale = 1.0
        UIView.animate(withDuration: 0.2, delay: 0.0, options: .beginFromCurrentState, animations: {
            self.transform = .identity
        }, completion: { (finished: Bool) -> Void in
            self.delegate?.draggableViewDidReset(self)
        })
    }

    private var overX: CGFloat = 0.0
    private var overY: CGFloat = 0.0

    @objc
    private func handleGestureRecognizer(_ sender: UIGestureRecognizer) {
        guard let superview = superview else {
            return
        }
        // Delegate で GestureRecognizer の同時呼び出しを許可しているので、ここの .began は 3 回完全に違うタイミングで呼ばれる場合がある。
        // いずれの場合でも通知パターン的には isDragging の didSet 内で oldValue を見て重複を排除するだけで良い。
        if sender.state == .began {
            isDragging = true
        }

        if let pan = sender as? UIPanGestureRecognizer {
            let translation = pan.translation(in: self)
            let tMove = CGAffineTransform(translationX: translation.x, y: translation.y)
            let tOver = CGAffineTransform(translationX: overX, y: overY)
            let t = tMove.concatenating(transform).concatenating(tOver)  // 回転してたりすると translatedBy で適当に作った行列では直感的な結果が得られないし破綻する。
            transform = t
            if !isLimitDragInside {
                overX = 0.0
                overY = 0.0
            } else {
                let nextFrame = frame
                let limitFrame = superview.bounds.inset(by: limitInsideInsets)
                if !limitFrame.contains(nextFrame) {
                    // 親からはみ出た分。正負は UIKit の座標系に準ずる。
                    let dx = nextFrame.minX < limitFrame.minX ? nextFrame.minX - limitFrame.minX : nextFrame.maxX > limitFrame.maxX ? nextFrame.maxX - limitFrame.maxX : 0.0
                    let dy = nextFrame.minY < limitFrame.minY ? nextFrame.minY - limitFrame.minY : nextFrame.maxY > limitFrame.maxY ? nextFrame.maxY - limitFrame.maxY : 0.0
                    transform = t.concatenating(CGAffineTransform(translationX: -dx, y: -dy)) // 回転してたりすると translatedBy では直感的な結果が得られないし破綻する。
                    overX = dx
                    overY = dy
                } else {
                    overX = 0.0
                    overY = 0.0
                }
            }
            pan.setTranslation(.zero, in: self)
        } else if let pinch = sender as? UIPinchGestureRecognizer {
            if pinch.scale < 1.0 && minScale > 0.0 && minScale > currentScale * pinch.scale {
                // nop. min limit
            } else if pinch.scale > 1.0 && maxScale >= 1.0 && currentScale * pinch.scale > maxScale {
                // nop. max limit
            } else {
                if !isLimitDragInside {
                    currentScale *= pinch.scale
                    transform = transform.scaledBy(x: pinch.scale, y: pinch.scale)
                } else {
                    let before = transform
                    transform = transform.scaledBy(x: pinch.scale, y: pinch.scale)
                    if superview.bounds.inset(by: limitInsideInsets).contains(frame) {
                        currentScale *= pinch.scale
                    } else {
                        transform = before
                    }
                }
            }
            // pan の .ended が来る前にここに来ることもあるのでリセットが必要…。
            overX = 0.0
            overY = 0.0
            pinch.scale = 1.0
        } else if let rotation = sender as? UIRotationGestureRecognizer {
            if !isLimitDragInside {
                transform = transform.rotated(by: rotation.rotation)
            } else {
                let before = transform
                transform = transform.rotated(by: rotation.rotation)
                if !superview.bounds.inset(by: limitInsideInsets).contains(frame) {
                    transform = before
                }
            }
            // pan の .ended が来る前にここに来ることもあるのでリセットが必要…。
            overX = 0.0
            overY = 0.0
            rotation.rotation = 0.0
        }

        delegate?.draggableViewDidDrag(self)

        // Delegate で GestureRecognizer の同時呼び出しを許可しているので、ここの .began は 3 回呼ばれる場合があるが、 iOS の仕様で同じタイミングで sender のクラス違いで 3 回連続で呼ばれる、といった形になる。
        // 1 本指で pan → 2 本指で pinch と rotation → 1 本指を離して pinch と rotation を終了しつつも残りの指を動かし続けて pan → そのまま再び 2 本指に戻して pinch と rotation … としても pinch と rotation の .ended にはならない。
        // また、最後まで 1 本指の pan のままで 2 本指を使う pinch と rotation が呼ばれないまま .ended まで来た時のみ、 .ended と同じタイミングで .failed となる。この時の呼び出しは 1 回のみで .failed が別で来る訳ではない。
        // いずれの場合でも通知パターン的には isDragging の didSet 内で oldValue を見て重複を排除するだけで良い。
        switch sender.state {
        case .ended, .cancelled, .failed:
            overX = 0.0
            overY = 0.0
            isDragging = false
        default:
            break
        }
    }

}

private final class DraggableUIViewGestureRecognizerDelegateObject: NSObject, UIGestureRecognizerDelegate {

    fileprivate weak var parent: DraggableUIView?

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let parent = parent else {
            return true
        }
        switch gestureRecognizer {
        case parent.panGestureRecognizer:
            return parent.isDraggingEnabled && parent.isPanEnabled
        case parent.pinchGestureRecognizer:
            return parent.isDraggingEnabled && parent.isPinchEnabled
        case parent.rotationGestureRecognizer:
            return parent.isDraggingEnabled && parent.isRotationEnabled
        case parent.doubleTapGestureRecognizer:
            if parent.transform.isIdentity {
                return false
            } else if parent.isDragging {
                return false
            } else {
                return parent.isDoubleTapToResetEnabled
            }
        default:
            return true
        }
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let parent = parent else {
            return false
        }
        // 順番が分からないので全パターン網羅する必要がある。
        // 雑に return true すると入れ子になった場合や scrollView に入れた場合などに問題が発生するため、きちんと同時に呼ばれるものを見分ける必要がある。
        switch (gestureRecognizer, otherGestureRecognizer) {
        case (parent.panGestureRecognizer, parent.pinchGestureRecognizer),
            (parent.panGestureRecognizer, parent.rotationGestureRecognizer),
            (parent.pinchGestureRecognizer, parent.panGestureRecognizer),
            (parent.pinchGestureRecognizer, parent.rotationGestureRecognizer),
            (parent.rotationGestureRecognizer, parent.panGestureRecognizer),
            (parent.rotationGestureRecognizer, parent.pinchGestureRecognizer):
            return true
        default:
            return false
        }
    }

}

入れ子にして、親も子も拡大・縮小・回転・平行移動をグリグリ動かして破綻しないことを確認している。

締め

雑でいいなら簡単に実装できるんだけど、丁寧に実装すると結構長くなりますね。

ネイティブアプリ感というのは、即ち UI の微妙な応答性カーブや、ジェスチャに対する感度や、アニメーションの癖であって、つまり UIPanGestureRecognizervelocity(in:) メソッドの秘伝の算出アルゴリズムの実装そのものであり、 UIRotationGestureRecognizerrotation プロパティの秘伝の算出アルゴリズムの実装そのものであり、 UIPinchGestureRecognizerscale プロパティと velocity プロパティの秘伝の算出アルゴリズムの実装そのものが本質なのであって、これらを巧く使いこなしましょう、という話でした。

Recents
2022/1/14 0:50
2019/10/26 18:12