はじめに
iOS13系からUIViewControllerをpresentで遷移させると、modalPresentationStyleの.pageSheetがデフォルトの挙動となりました。
たまに見る、覆い被さる系の、アレですね。
これの気持ち良さは良きだなと思った反面、
これやと遷移先のUIViewControllerは上まで到達せず、あくまで一時的な画面としてのレイアウトとなります。
今回やりたかったのは、
presentで全画面表示(iOS12系まで標準やったアレ)、かつdismissの動きはpageSheet風、
という状態を作りたかったわけで、
そうなるともう作るっきゃない!
となったので、実際にやってみましたと。
成果物
こんな感じになりました。
(グラデーションががびがびなのは見ないでください。)
最近、とある事故があり、iPhoneが11になったことで、UINavigationControllerによくある閉じるボタンを押すのが辛くなっていた私でも、簡単に画面を閉じることができました!
UX向上だわこれ、って思いましたね、うんうん。
実装
タッチイベントを利用します。
didTouchesBegan
didTouchesMoved
didTouchesEnded
こいつらですね。
やることは、
- 最初にタッチした位置から動いた距離分だけ画面を動かす
- タッチが終わったときに
- 閾値を超えていたらdismissする
- 閾値以内やったら画面を元の状態に戻す
って感じ。
なんとなくイメージついたかと思います。
まずは、Storyboardで画面の準備。
Viewの構成としては、
- 背景用のView (backgroundViewとする)
- コンテンツ用のView (overViewとする)
の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
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どんどんでかくなるし、もうもはや左上に閉じるボタンとかをつけるだけでは厳しい世界に入っていっているので、工夫が必要になってくる場面も増えてくるかと思います。その内の一つの手法としては今回の記事は有用かと思うので、どんどん使いやすいアプリ目指してがんばりやしょう!