Merpay Advent Calendar 2019 の3日目は、 Merpay iOS チームの @masamichi がお送りします。
メルペイで使っているBottomHalfModal
というUIをOSSにしました。このOSSの紹介と中身の実装について紹介します。
github.com
目次
BottomHalfModalとは
メルペイでは銀行からのチャージや、メルペイスマート払いの利用上限額の選択、メルカリ内購入の確認などにこのUIを使っています。
今回公開したメルペイで使用しているBottomHalfModal
は、ジェスチャーで閉じることができるようには作られていません。現時点では、ApplePayでの購入時に出てくるモーダル画面が同じスタイルになっています。ナビゲーション時の挙動も極めてApplePayのスタイルに近くなっています。
一方、iOS13では、モーダル画面のデフォルトの表示がフルスクーンではなくシートになり、ジェスチャーによってインタラクティブにモーダルを閉じることができるようになりました。またAppleのアプリや、Twitter, Facebook, Slackなどの様々なアプリで、ジェスチャーで閉じることのできるインタラクティブなモーダル画面が最近使用されています。iOS13のシート形式のモーダルで有名はOSSとしては、SlackのPanModalがあります。
BottomHalfModal
でジェスチャーをいれていないのは理由がありまして、メルペイではお客さまがお金に関わる操作をするため、操作の途中で意図せず画面を閉じてしまったり操作を中断してしまうことを避けるためです。なのでインタラクティブな操作はあえて取り入れていません。
使い方
ではBottomHalfModal
の使い方について紹介します。
使い方はとても簡単で、UIViewController
にSheetContentHeightModifiable
protocolを実装して、表示されるViewController
の高さであるsheetContentHeightToModify
をセットします。viewDidAppear
でadjustFrameToSheetContentHeightIfNeeded
を呼び出します。このViewController
をpresentBottomHalfModal
で表示します。デバイスの回転をサポートしている場合は回転時にレイアウトを更新する必要があるので、viewWillTransition
でもadjustFrameToSheetContentHeightIfNeeded
を呼び出すようにしてください。
final class XXXXXViewController: UIViewController, SheetContentHeightModifiable { let sheetContentHeightToModify: CGFloat = SheetContentHeight.default … override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) adjustFrameToSheetContentHeightIfNeeded() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) adjustFrameToSheetContentHeightIfNeeded(with: coordinator) } … }
モーダル画面の中でプッシュ遷移をする必要があり、UINavigationController
を使いたい場合は、BottomHalfModalNavigationController
を使ってください。画面遷移時のレイアウトの変更を調整してくれます。
let vc = XXXXXViewController() let nav = BottomHalfModalNavigationController(rootViewController: vc) presentBottomHalfModal(nav, animated: true, completion: nil)
内部実装
内部の実装について紹介します。
画面遷移をカスタマイズする際に使用するUIViewControllerTransitioningDelegateを使っています。presentBottomHaflModa
l関数で表示するViewController
の遷移のdelegateを設定しています。
public func presentBottomHalfModal(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { viewControllerToPresent.modalPresentationStyle = .custom viewControllerToPresent.transitioningDelegate = SheetPresentationDelegate.default present(viewControllerToPresent, animated: animated, completion: completion) }
SheetPresentationDelegate
で、UIViewControllerTransitioningDelegateを実装しています。delegate関数内で、UIPresentationControllerを継承したSheetPresentationController
と、UIViewControllerAnimatedTransitioningを実装したSheetAnimationController
の2つのクラスを使って、カスタム画面遷移を実現しています。
public func presentationController( forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController ) -> UIPresentationController? { return SheetPresentationController(presentedViewController: presented, presenting: presenting) } public func animationController( forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController ) -> UIViewControllerAnimatedTransitioning? { return SheetAnimationController(forPresenting: true) } public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return SheetAnimationController(forPresenting: false) }
SheetPresentationController
SheetPresentationController
では、UIPresentationControllerの関数やプロパティをoverrideしてアニメーションを実現しています。とくに画面を表示するアニメーションを実現しているのが、preferredContentSizeDidChange
です。ViewController
のviewDidLoad
で呼び出すadjustFrameToSheetContnentHeightIfneeded
関数によって、preferredContentSize
が指定のサイズにセットされ、アニメーションが実行される仕組みになっています。viewDidLoad
で実行することで、push遷移を実行した時にも滑らかにアニメーションするようになっています。
protocol SheetControllerContentSizing { var sheetContentHeight: CGFloat { get set } } extension UIViewController: SheetControllerContentSizing { var sheetContentHeight: CGFloat { get { … } set { ... calculate width and height if let nav = navigationController { nav.preferredContentSize = CGSize(width: width, height: newValue + additionalBottomHeight) } else { preferredContentSize = CGSize(width: view.bounds.width, height: newValue + additionalBottomHeight) } } } ... } final class SheetPresentationController: UIPresentationController { ... private func animateContainerView( with viewController: UIViewController, frame: CGRect, duration: TimeInterval = 0.35, options: UIView.AnimationOptions = [.allowUserInteraction, .beginFromCurrentState], useSpringDamping: Bool = true, safeAreaInsets: UIEdgeInsets = UIEdgeInsets.zero ) { // Animate Frame } override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) { guard let vc = container as? UIViewController, let containerView = containerView else { return } let viewSize = vc.preferredContentSize let containerViewFrame = containerView.bounds let frame = CGRect( x: containerViewFrame.origin.x, y: containerViewFrame.size.height - viewSize.height, width: viewSize.width, height: viewSize.height ) animateContainerView(with: vc, frame: frame) } ... }
他にも背景を黒い半透明にするアニメーションや、キーボード表示時のフレームサイズの対応もしています。
SheetAnimationController
SheetAnimationController
は、UIViewControllerAnimatedTransitioningを実装していてSheetPresentationController
と連動しつつ遷移アニメーションを実現しています。
表示のアニメーションはSheetPresentationController
が担当していますが、SheetAnimationController
では初期表示時のフレームサイズや位置を調整して、SheetPresentationController
のアニメーションが意図通りに動くようにしています。
func animatePresentationWithTransitionContext(_ transitionContext: UIViewControllerContextTransitioning) { guard let presentedController = transitionContext.viewController(forKey: .to), let presentedControllerView = transitionContext.view(forKey: .to) else { return } let containerView = transitionContext.containerView presentedControllerView.frame = transitionContext.finalFrame(for: presentedController) presentedControllerView.frame.size.height = containerView.bounds.size.height presentedControllerView.center.y += containerView.bounds.size.height containerView.addSubview(presentedControllerView) UIView.animate( withDuration: duration, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.5, options: .allowUserInteraction, animations: { presentedControllerView.center.y -= containerView.bounds.size.height }, completion: { (completed: Bool) -> Void in transitionContext.completeTransition(completed) }) }
閉じるアニメーションは、SheetAnimationController
で実行していて、素直にフレームの位置を下げるアニメーションを実行しています。SheetPresentationController
でも閉じるアニメーションを実行できますが、背景透過のアニメーションが同時に実行されているため、モーダル画面も透過されていってしまいます。そのため、SheetAnimationController
を使ってモーダル自体のアルファは変えずにdismissしつつ背景のアルファをアニメーションしています。
func animateDismissalWithTransitionContext(_ transitionContext: UIViewControllerContextTransitioning) { guard let presentedControllerView = transitionContext.view(forKey: .from) else { return } let containerView = transitionContext.containerView UIView.animate( withDuration: duration + 0.5, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.5, options: .allowUserInteraction, animations: { presentedControllerView.center.y += containerView.bounds.size.height }, completion: {(completed: Bool) -> Void in transitionContext.completeTransition(completed) if !self.forPresenting { let fromVC = transitionContext.viewController(forKey: .from) fromVC?.view?.removeFromSuperview() } }) }
終わりに
メルペイで使っているBottomHalfModal
というUIのOSSを紹介しました。お金に関わる機能を実装する際には便利に使えると思うので、活用してみてください。改善ポイントがあればPullRequestもお待ちしております。
github.com
メルペイではミッション・バリューに共感しているiOSエンジニアを募集しています。一緒に働ける仲間をお待ちしております。
明日の執筆担当は、Androidチームの @keithyokoma さんです。引き続きお楽しみください。