みなさんこんにちは。ラクマでiOSエンジニアをしているdarquroです。 今回はiOSアプリの、セミモーダル/ハーフモーダル(以降セミモーダル)の実装について紹介したいと思います。
はじめに
昨今のアプリではセミモーダルを用いたUI/UXが主流となってきました。 例えば、Facebook、Twitter、Slackなどでは以下のように使われています。
ラクマでも、セミモーダルを使用し、画面遷移せずにユーザーアクションを促したり、補足情報を表示したりしています。
iOSで有名なOSSだとFloatingPanelが有名です。
また、ラクマではSwiftUIはまだ導入できていませんが、SwiftUIだとPartialSheetなどがあります。
もちろん、これらOSSを導入することでも実現可能です。 しかしながら、UIのコンポーネントはカスタマイズ性やAnalyticsのためのイベントトラッキング実装など予測できない将来を考慮すると、自前で実装できる部分は実装した方がリスクを回避できるもしれません。
セミモーダルを実装する
では、実際セミモーダルを実装するにはどのような実装になるのか、サンプルを用いて説明していきます。 また、コンポーネントの機能は最小限に留め、なるべくシンプルな実装になることを目指しました。 今回使用するサンプルコードはGitHubからご確認いただけます。 github.com
今回使用するサンプルアプリは、このようなセミモーダルを表示するシンプルなアプリです。
[ ]
最終的にアプリ側で実装するセミモーダルを表示するコードは以下のようになります。
class MainViewController: UIViewController { private var semiModalPresenter = SemiModalPresenter() @IBAction func openModalButtonDidTap(_ sender: Any) { let viewController = ModalViewController .instantiateInitialViewControllerFromStoryboard() semiModalPresenter.viewController = viewController present(viewController, animated: true) } }
まず、セミモーダルはどのようなViewレイアウトを構成しているかというと、モーダルのViewController、画面全体を覆うオーバーレイ、ドラッグできることを示すインジケーターの3つのViewに別れます。
では、実装クラスは全部で6つになります。
SemiModalPresenter
呼び出す側からはインターフェースとなるメインのクラスです。
SemiModalPresentationController
モーダル表示時に、Viewのレイアウトを実装しているクラスです。
SemiModalDismissAnimatedTransition
UIViewController
のdismissが呼ばれたときに、どのようなアニメーションをするか実装しているクラスです。SemiModalDismissInteractiveTransition
モーダルをドラッグしたときに、どのようなアニメーションでdismissするか実装しているクラスです。
SemiModalOverlayView
画面全体を覆うオーバーレイのViewです。単純なViewなので、説明は割愛します。GitHubからコードをご確認ください。
SemiModalIndicatorView
ドラッグできることを示すインジケーターのViewです。こちらも単純なViewなので、GitHubのコードをご確認ください。
それでは、詳細にポイントを絞って実装を見ていきましょう。
SemiModalPresenter
// ------ 省略 ------ extension SemiModalPresenter: UIViewControllerTransitioningDelegate { /// 画面遷移開始時に呼ばれる。カスタムビューを使用して表示する。 /// - Parameters: /// - presented: 呼び出し先ViewController /// - presenting: 呼び出し元ViewController /// - source: presentメソッドがプレゼンテーションプロセスを開始するために呼び出されたViewController /// - Returns: UIPresentationController public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { return SemiModalPresentationController( presentedViewController: presented, presenting: presenting, overlayView: overlayView, indicator: indicator) } /// dismiss時に呼ばれる。dismissのアニメーション指定。 /// - Parameter dismissed: dismissされるViewController /// - Returns: UIViewControllerAnimatedTransitioning public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return SemiModalDismissAnimatedTransition() } /// インタラクティブなdismissを制御する。 /// - Parameter animator: animationController(forDismissed:)で指定したアニメーター /// - Returns: UIViewControllerInteractiveTransitioning public func interactionControllerForDismissal( using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { guard dismissInteractiveTransition.isInteractiveDismalTransition else { return nil } return dismissInteractiveTransition } }
このクラスでポイントとなるのは、UIViewControllerTransitioningDelegate
の実装部分です。
presentationController(forPresented:, presenting:, source:)
animationController(forDismissed:)
interactionControllerForDismissal(using:)
の3つメソッドを実装し、それぞれ、対応する
SemiModalPresentationController
SemiModalDismissAnimatedTransition
SemiModalDismissInteractiveTransition
をreturnで返してあげます。
SemiModalPresentationController
final class SemiModalPresentationController: UIPresentationController { // MARK: Override Properties /// 表示transitionの終わりのViewのframe override var frameOfPresentedViewInContainerView: CGRect { guard let containerView = containerView else { return CGRect.zero } var presentedViewFrame = CGRect.zero let containerBounds = containerView.bounds presentedViewFrame.size = self.size(forChildContentContainer: self.presentedViewController, withParentContainerSize: containerBounds.size) presentedViewFrame.origin.x = containerBounds.size.width - presentedViewFrame.size.width presentedViewFrame.origin.y = containerBounds.size.height - presentedViewFrame.size.height return presentedViewFrame } // ------ 省略 ------ // MARK: Override Functions /// 表示されるViewのサイズ /// - Parameters: /// - container: コンテナ /// - parentSize: 親Viewのサイズ /// - Returns: サイズ override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize { // delegateで高さが指定されていれば、そちらを優先する if let delegate = presentedViewController as? SemiModalPresenterDelegate { return CGSize(width: parentSize.width, height: delegate.semiModalContentHeight) } // 上記でなければ、高さは比率で計算する return CGSize( width: parentSize.width, height: parentSize.height * self.presentedViewControllerHeightRatio) } /// Subviewsのレイアウト override func containerViewWillLayoutSubviews() { guard let containerView = containerView else { return } // overlay // containerViewと同じ大きさで、一番上のレイヤーに挿入する overlay.frame = containerView.bounds containerView.insertSubview(overlay, at: 0) // presentedView // frameの大きさ設定、左上と右上を角丸にする presentedView?.frame = frameOfPresentedViewInContainerView presentedView?.layer.cornerRadius = 10.0 presentedView?.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // indicator // 中央上部に配置する indicator.frame = CGRect(x: 0, y: 0, width: 60, height: 8) presentedViewController.view.addSubview(indicator) indicator.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ indicator.centerXAnchor.constraint( equalTo: presentedViewController.view.centerXAnchor), indicator.topAnchor.constraint( equalTo: presentedViewController.view.topAnchor, constant: -16), indicator.widthAnchor.constraint( equalToConstant: indicator.frame.width), indicator.heightAnchor.constraint( equalToConstant: indicator.frame.height) ]) } /// presentation transition 開始 override func presentationTransitionWillBegin() { presentedViewController.transitionCoordinator?.animate( alongsideTransition: { _ in self.overlay.isActive = true }, completion: nil) } /// dismiss transition 開始 override func dismissalTransitionWillBegin() { self.presentedViewController.transitionCoordinator?.animate( alongsideTransition: { _ in self.overlay.isActive = false }, completion: nil) } /// dismiss transition 終了 /// - Parameter completed: override func dismissalTransitionDidEnd(_ completed: Bool) { if completed { overlay.removeFromSuperview() } } }
このクラスは、UIPresentationControllerのoverrideの実装になります。
frameOfPresentedViewInContainerView
のプロパティで最終的にモーダルを表示した後のframeを返します。
中で呼ばれているsize(forChildContentContainer:,withParentContainerSize:)
もoverrideのメソッドとして用意されているものです。
こちらで、実際にセミモーダルの高さを決定しています。
containerViewWillLayoutSubviews()
はその名の通り、呼び出し元のsubViewsを決定するメソッドなので、オーバーレイとインジケーターをaddViewしています。
presentationTransitionWillBegin()
dismissalTransitionWillBegin()
dismissalTransitionDidEnd(_:)
の3つのメソッドでオーバーレイの表示/非表示/削除を制御します。
SemiModalDismissAnimatedTransition
// ------ 省略 ------ extension SemiModalDismissAnimatedTransition: UIViewControllerAnimatedTransitioning { /// transitionの時間 /// - Parameter transitionContext: /// - Returns: func transitionDuration( using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.4 } /// アニメーションtransition /// - Parameter transitionContext: func animateTransition( using transitionContext: UIViewControllerContextTransitioning) { UIView.animate( withDuration: transitionDuration(using: transitionContext), delay: 0.0, options: .curveEaseInOut, animations: { guard let fromView = transitionContext.view(forKey: .from) else { return } // Viewを下にスライドさせる fromView.center.y = UIScreen.main.bounds.size.height + fromView.bounds.height / 2 }, completion: { _ in transitionContext.completeTransition( !transitionContext.transitionWasCancelled) } ) } }
こちらは、UIViewControllerAnimatedTransitioning
のprotocolに準拠し、モーダルのViewControllerのdismissが呼ばれたときのアニメーションを実装します。
0.4杪は感覚値なのですが、より拘るなら、表示しているセミモーダルの高さに応じて変えてみてもいいかもしれません。
SemiModalDismissInteractiveTransition
final class SemiModalDismissInteractiveTransition: UIPercentDrivenInteractiveTransition { // MARK: Private Properties /// 完了閾値(0 ~ 1.0) private let percentCompleteThreshold: CGFloat = 0.3 // MARK: Override functions override func cancel() { completionSpeed = self.percentCompleteThreshold super.cancel() } override func finish() { completionSpeed = 1.0 - self.percentCompleteThreshold super.finish() } // ----- 省略 ----- /// Panジェスチャー /// - Parameter recognizer: @objc private func dismissalPanGesture(recognizer: UIPanGestureRecognizer) { guard let viewController = viewController else { return } isInteractiveDismalTransition = recognizer.state == .began || recognizer.state == .changed switch recognizer.state { case .began: gestureDirection = GestureDirection(recognizer: recognizer, view: viewController.view) if gestureDirection == .down { viewController.dismiss(animated: true, completion: nil) } case .changed: // インタラクティブな制御のために、Viewの高さに応じた画面更新を行う let translation = recognizer.translation(in: viewController.view) var progress = translation.y / viewController.view.bounds.size.height switch gestureDirection { case .up: progress = -max(-1.0, max(-1.0, progress)) case .down: progress = min(1.0, max(0, progress)) } update(progress) case .cancelled, .ended: if percentComplete > percentCompleteThreshold { finish() } else { cancel() } default: break } } }
こちらのクラスでは、インタラクティブなdismissの処理を実装します。
UIPercentDrivenInteractiveTransition
に準拠し、cancel()
、finish()
の閾値設定や、UIPanGestureRecognizer
で、ジェスチャーに応じた変化量を与えています。
また、最後に、delegateプロトコルを用意して、モーダル側の画面サイズに応じて高さが決まるようにしました。
extension ModalViewController: SemiModalPresenterDelegate { var semiModalContentHeight: CGFloat { return contentView.frame.height } }
上記以外の説明しきれなかった部分については、GitHubをご確認ください。
おわりに
今回は、iOSでセミモーダル/ハーフモーダルの実装方法について、紹介させて頂きました。 アプリに導入できると、UI/UXの幅がぐっと広がり、使い勝手のいいコンポーネントとなると思います。 参考になれば幸いです。
また、ラクマでは、User Firstをコア・バリューの一つに掲げ、一緒にアプリ開発をしてくれるメンバーを募集しています。 www.wantedly.com カジュアル面談で、私達のチームについて紹介させて頂きます。