3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

iOS13系からデフォルトになったpageSheetのdismiss風の動きをさせる

Last updated at Posted at 2020-08-17

はじめに

iOS13系からUIViewControllerをpresentで遷移させると、modalPresentationStyleの.pageSheetがデフォルトの挙動となりました。
たまに見る、覆い被さる系の、アレですね。

closableVC4.gif

これの気持ち良さは良きだなと思った反面、
これやと遷移先のUIViewControllerは上まで到達せず、あくまで一時的な画面としてのレイアウトとなります。

今回やりたかったのは、
presentで全画面表示(iOS12系まで標準やったアレ)、かつdismissの動きはpageSheet風、
という状態を作りたかったわけで、
そうなるともう作るっきゃない!
となったので、実際にやってみましたと。

成果物

こんな感じになりました。
(グラデーションががびがびなのは見ないでください。)

closableVC3.gif

最近、とある事故があり、iPhoneが11になったことで、UINavigationControllerによくある閉じるボタンを押すのが辛くなっていた私でも、簡単に画面を閉じることができました!
UX向上だわこれ、って思いましたね、うんうん。

実装

タッチイベントを利用します。

didTouchesBegan
didTouchesMoved
didTouchesEnded

こいつらですね。
やることは、

  • 最初にタッチした位置から動いた距離分だけ画面を動かす
  • タッチが終わったときに
    • 閾値を超えていたらdismissする
    • 閾値以内やったら画面を元の状態に戻す

って感じ。

なんとなくイメージついたかと思います。

まずは、Storyboardで画面の準備。
Viewの構成としては、

  • 背景用のView (backgroundViewとする)
  • コンテンツ用のView (overViewとする)

の2つです。

スクリーンショット 2020-08-17 13.27.20.png

んで、
実際のコードは以下のようになります。


final class ViewControllerForCloseableVC: UIViewController {

    @IBOutlet private weak var overView: UIView!
    @IBOutlet private weak var backgroundView: UIView!
    @IBOutlet private weak var overViewTopConstraint: NSLayoutConstraint!
    @IBOutlet private weak var overViewBottomConstraint: NSLayoutConstraint!

    private var position: CGPoint = .zero // 最初の触れた位置
    private var isDismiss: Bool = false

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard !self.isDismiss,
            let touch = touches.first else {
                return
        }

        let position = touch.location(in: self.view)
        self.position = position
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard !self.isDismiss,
            let touch = touches.first else {
                return
        }

        let movedPosition = touch.location(in: self.view)

        // 差分計算
        let diff: CGFloat = {
            let diff: CGFloat
            diff = movedPosition.y - self.position.y
            return diff > 0 ? diff : 0
        }()

        // 差分の分だけoverViewをずらす
        self.overViewTopConstraint.constant = diff
        self.overViewBottomConstraint.constant = -diff

        self.view.layoutIfNeeded()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard !self.isDismiss else {
            return
        }

        // dismissの挙動をさせるかどうか
        let needDismiss: Bool = self.overViewTopConstraint.constant > 100 // 閾値は一旦100にしておく

        if needDismiss {
            self.isDismiss = true

            // overViewを画面外へ
            self.overViewTopConstraint.constant = self.view.bounds.height
            self.overViewBottomConstraint.constant = -self.view.bounds.height

            UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseOut, animations: {
                self.view.layoutIfNeeded()
            }, completion: { _ in
                self.dismiss(animated: false, completion: nil)
            })
        }
        else {
            // overViewを元の位置に
            self.overViewTopConstraint.constant = 0
            self.overViewBottomConstraint.constant = 0

            UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut, animations: {
                self.view.layoutIfNeeded()
            }, completion: nil)
        }
    }
}

なお、今回はViewControllerにUIView乗っけてるだけなのでアレですが、
実際のアプリではもっといろんなものが乗っているかと思います。
ときにはタッチイベントがそっちにとられるなんてこともあるだろうなので、そういう場合は例えば一番上に乗っかってるViewを以下のようなカスタムViewにして、delegate伝いで実装するのがいいかなと思います。(様々な場合があると思うので、状況に応じて対応内容は変わるかと思います。)


protocol TouchableViewDelegate: AnyObject {
    func didTouchesBegan(position: CGPoint)
    func didTouchesMoved(position: CGPoint)
    func didTouchesEnded() // touchesEndedに関しては位置を必要としないので、positionは渡していない
}

final class TouchableView: UIView {

    weak var delegate: TouchableViewDelegate?

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)

        guard let touch = touches.first else {
            return
        }

        let position = touch.location(in: self)
        self.delegate?.didTouchesBegan(position: position)
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesMoved(touches, with: event)

        guard let touch = touches.first else {
            return
        }

        let position = touch.location(in: self)
        self.delegate?.didTouchesMoved(position: position)
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        self.delegate?.didTouchesEnded()
    }
}

で、これやとoverViewの制約が反映された後での位置になるので、差分計算のところでそれを考慮します。


let diff: CGFloat = {
    let diff: CGFloat
    diff = movedPosition.y - self.position.y + self.overViewTopConstraint.constant
    return diff > 0 ? diff : 0
}()

さて、ここまでの内容で一応タイトル通りのdismiss風の動きにはなるわけですが、
成果物を見てもらったら、なんか上記の2点だけではなし得ない動きしてね?ってなる。
ここからはプラスアルファの部分ですが、指が動いた分だけ画面が移動する、ってだけでは少し味気なく、ちょっと非連続な遷移アニメーションになってしまうなと思い、今回は、

  • 背景の透明度の調整
  • コンテンツ用のViewの丸み・透明度の調整

の2点を施すことで、より気持ちよく、かつ連続的な画面遷移になるようにします。
このあたりを実装した最終形態が、以下のようになります。


final class ViewControllerForCloseableVC: UIViewController {

    @IBOutlet private weak var overView: UIView!
    @IBOutlet private weak var backgroundView: UIView!
    @IBOutlet private weak var overViewTopConstraint: NSLayoutConstraint!
    @IBOutlet private weak var overViewBottomConstraint: NSLayoutConstraint!

    private var position: CGPoint = .zero // 最初の触れた位置
    private var isDismiss: Bool = false

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard !self.isDismiss,
            let touch = touches.first else {
                return
        }

        let position = touch.location(in: self.view)
        self.position = position
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard !self.isDismiss,
            let touch = touches.first else {
                return
        }

        let movedPosition = touch.location(in: self.view)

        // 差分計算
        let diff: CGFloat = {
            let diff: CGFloat
            diff = movedPosition.y - self.position.y
            return diff > 0 ? diff : 0
        }()

        // 差分の分だけoverViewをずらす
        self.overViewTopConstraint.constant = diff
        self.overViewBottomConstraint.constant = -diff

        // 透明度や丸みの計算(Easingに関してはこちらに。https://qiita.com/haguhoms/items/abc5635e8fa95719cb12)
        let max = self.view.bounds.height
        let radius = Easing.easeIn.quad.getProgress(elapsed: TimeInterval(diff), duration: 100, startValue: 0, endValue: 20)
        let alpha = Easing.easeInOut.quad.getProgress(elapsed: TimeInterval(diff), duration: TimeInterval(max), startValue: 1, endValue: 0)
        let backgroundAlpha = Easing.easeInOut.quad.getProgress(elapsed: TimeInterval(diff), duration: TimeInterval(max), startValue: 0.8, endValue: 0)

        // 透明度や丸みの調整
        self.overView.cornerRadius = radius
        self.overView.alpha = alpha
        self.backgroundView.alpha = backgroundAlpha

        self.view.layoutIfNeeded()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard !self.isDismiss else {
            return
        }

        // dismissの挙動をさせるかどうか
        let needDismiss: Bool = self.overViewTopConstraint.constant > 100

        if needDismiss {
            self.isDismiss = true

            // overViewを画面外へ
            self.overViewTopConstraint.constant = self.view.bounds.height
            self.overViewBottomConstraint.constant = -self.view.bounds.height

            UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseOut, animations: {
                self.overView.cornerRadius = 30.0
                self.overView.alpha = 0.0
                self.backgroundView.alpha = 0.0
                self.view.layoutIfNeeded()
            }, completion: { _ in
                // もろもろアニメーションさせた後、animatedをfalseでdismissさせる
                self.dismiss(animated: false, completion: nil)
            })
        }
        else {
            // overViewを元の位置に
            self.overViewTopConstraint.constant = 0
            self.overViewBottomConstraint.constant = 0

            UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut, animations: {
                self.overView.cornerRadius = 0.0
                self.overView.alpha = 1.0
                self.backgroundView.alpha = 0.8
                self.view.layoutIfNeeded()
            }, completion: nil)
        }
    }
}

(Easingに関してはこちらに。https://qiita.com/haguhoms/items/abc5635e8fa95719cb12)

これで、完成です。

まとめ

タッチイベントを利用して遷移アニメーションを作ったわけですが、なんかちょっとゴリゴリ感は否めない。
まぁそんなぎこちなさも人生のスパイスだよねってことで終わりです。

余談

iPhoneどんどんでかくなるし、もうもはや左上に閉じるボタンとかをつけるだけでは厳しい世界に入っていっているので、工夫が必要になってくる場面も増えてくるかと思います。その内の一つの手法としては今回の記事は有用かと思うので、どんどん使いやすいアプリ目指してがんばりやしょう!

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?