Edited at

UIKitにある機能でWebで見かけるようなUI達を作る

More than 1 year has passed since last update.

完全に遅くなりましたが、iOS Adevent Calendar 2016の20日目の分です。


はじめに

標準のUIだけではどうしても間に合わない、WebっぽいUIをアプリの中に作る方法をまとめてみました。


ドロップダウン

UIPresentationControllerでモーダルをカスタマイズするのがよさそう。

まずはUIPresentationControllerを継承して、ボタンを押した時にその下へViewが表示されるようにします。

import UIKit

class DropDownPresentationController: UIPresentationController {
private lazy var overlayView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
let rec = UITapGestureRecognizer(target: self, action: #selector(DropDownPresentationController.overlayViewDidTap(sender:)))
view.addGestureRecognizer(rec)
return view
}()

private let maskLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillColor = UIColor.black.withAlphaComponent(0.5).cgColor
layer.fillRule = kCAFillRuleEvenOdd
return layer
}()

var targetFrame = CGRect.zero

override func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()

maskLayer.frame = overlayView.bounds
overlayView.frame = containerView?.frame ?? .zero
containerView?.insertSubview(overlayView, at: 0)

maskLayer.path = {
let maskPath = UIBezierPath(rect: overlayView.bounds)
maskPath.append(UIBezierPath(rect: targetFrame))
return maskPath.cgPath
}()
overlayView.layer.mask = maskLayer

overlayView.alpha = 0
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [weak self] (context) in
self?.overlayView.alpha = 0.5
}, completion: {(_) in })
}

override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()

presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [weak self] (context) in
self?.overlayView.alpha = 0
}, completion: { [weak self] (context) in
self?.overlayView.removeFromSuperview()
})
}

override func dismissalTransitionDidEnd(_ completed: Bool) {
super.dismissalTransitionDidEnd(completed)
}

override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
return CGSize(width: targetFrame.width, height: 200)
}

override var frameOfPresentedViewInContainerView: CGRect {
let containerSize = size(forChildContentContainer: presentedViewController, withParentContainerSize: containerView?.bounds.size ?? .zero)
return CGRect(origin: CGPoint(x: targetFrame.minX, y: targetFrame.maxY), size: containerSize)
}

override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()

overlayView.frame = containerView?.frame ?? .zero
}

func overlayViewDidTap(sender: UITapGestureRecognizer) {
presentedViewController.dismiss(animated: true, completion: nil)
}
}

さらに、上から下にViewが降りてくるようなカスタムトランジションを作成します。

import UIKit

class DropDownAnimator: NSObject, UIViewControllerAnimatedTransitioning {
enum Direction {
case present
case dismiss
}

let direction: Direction

init(direction: Direction) {
self.direction = direction
}

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
switch direction {
case .present:
present(using: transitionContext)
case .dismiss:
dismiss(using: transitionContext)
}
}

func present(using transitionContext: UIViewControllerContextTransitioning) {
guard let toVC = transitionContext.viewController(forKey: .to) else {
return
}

let finalFrame = transitionContext.finalFrame(for: toVC)
toVC.view.frame = finalFrame
let containerView = transitionContext.containerView
containerView.addSubview(toVC.view)

toVC.view.frame.size.height = 0
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .curveEaseInOut, animations: {
toVC.view.frame.size.height = finalFrame.height
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}

func dismiss(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) else {
return
}

UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .curveEaseInOut, animations: {
fromVC.view.frame.size.height = 0
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}

これらのオブジェクトを呼び出してやれば、それっぽいのができます。

class ViewController: UIViewController, UIViewControllerTransitioningDelegate {

@IBOutlet weak var dropdownButton: UIButton!

override func viewDidLoad() {
super.viewDidLoad()
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}

@IBAction func dropDownButtonDidTap(_ sender: UIButton) {
let vc = UITableViewController()
vc.transitioningDelegate = self
vc.modalPresentationStyle = .custom
vc.view.backgroundColor = .white
present(vc, animated: true, completion: nil)
}

// MARK:- UIViewControllerTransitioningDelegate
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let controller = DropDownPresentationController(presentedViewController: presented, presenting: presenting)
controller.targetFrame = dropdownButton.frame
return controller
}

func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return DropDownAnimator(direction: .present)
}

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return DropDownAnimator(direction: .dismiss)
}
}

こんなUIになります。

out.gif


ツールチップ

標準の仕組みとして、UIViewControllerのpresentationStyleに.Popoverというのが付いている。iOS8以降であればiPhoneでも使えます。

もしカスタマイズされたポップオーバーを作りたければ、ドロップダウンと同じくUIPresentationControllerがよさそう。

まずはUIPresentationControllerを継承して、ボタンを押した時にその下へViewが表示されるようにします。

個々の部分でのドロップダウンとの違いは、presentedViewに対して、CGPathで吹き出しを描いて、maskLayerとして設定しています。

import UIKit

class TooltopPresentationController: UIPresentationController {
var presentedViewFrame = CGRect.zero
var pointerSize = CGSize(width: 10, height: 10)
var cornerRadius: CGFloat = 10

private lazy var overlayView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.clear
let rec = UITapGestureRecognizer(target: self, action: #selector(DropDownPresentationController.overlayViewDidTap(sender:)))
view.addGestureRecognizer(rec)
return view
}()

private var balloonMaskLayer: CALayer {
let frame = frameOfPresentedViewInContainerView
let bounds = frame.offsetBy(dx: -frame.minX, dy: -frame.minY)

let balloon = CAShapeLayer()
balloon.frame = bounds
balloon.path = makeBallonPath(bounds: bounds, pointerSize: pointerSize, cornerRadius: cornerRadius)
return balloon
}

override func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()

overlayView.frame = containerView?.frame ?? .zero
containerView?.insertSubview(overlayView, at: 0)

presentedView?.layer.mask = balloonMaskLayer
}

override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
}

override func dismissalTransitionDidEnd(_ completed: Bool) {
super.dismissalTransitionDidEnd(completed)
}

override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
return CGSize(width: presentedViewFrame.width, height: presentedViewFrame.height)
}

override var frameOfPresentedViewInContainerView: CGRect {
let containerSize = size(forChildContentContainer: presentedViewController, withParentContainerSize: containerView?.bounds.size ?? .zero)
return CGRect(origin: CGPoint(x: presentedViewFrame.minX, y: presentedViewFrame.minY), size: containerSize)
}

override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()

overlayView.frame = containerView?.frame ?? .zero
}

func overlayViewDidTap(sender: UITapGestureRecognizer) {
presentedViewController.dismiss(animated: true, completion: nil)
}

private func makeBallonPath(bounds: CGRect, pointerSize: CGSize, cornerRadius: CGFloat) -> CGPath {
let path = CGMutablePath()
path.move(
to: CGPoint(x: bounds.size.width/2, y: bounds.maxY)
)
path.addLine(
to: CGPoint(x: bounds.size.width/2 - pointerSize.width, y: bounds.maxY - pointerSize.height)
)
path.addArc(
tangent1End: CGPoint(x: bounds.origin.x, y: bounds.maxY - pointerSize.height),
tangent2End: CGPoint(x: bounds.origin.x, y: bounds.maxY - pointerSize.height - cornerRadius),
radius: cornerRadius
)
path.addArc(
tangent1End: CGPoint(x: bounds.minX, y: bounds.minY),
tangent2End: CGPoint(x: bounds.minX + cornerRadius, y: bounds.minY),
radius: cornerRadius
)
path.addArc(
tangent1End: CGPoint(x: bounds.maxX, y: bounds.minY),
tangent2End: CGPoint(x: bounds.maxX, y: bounds.minY + cornerRadius),
radius: cornerRadius
)
path.addArc(
tangent1End: CGPoint(x: bounds.maxX, y: bounds.maxY - pointerSize.height),
tangent2End: CGPoint(x: bounds.maxX - cornerRadius, y: bounds.maxY - pointerSize.height),
radius: cornerRadius
)
path.addLine(
to: CGPoint(x: bounds.size.width/2 + pointerSize.width, y: bounds.maxY - pointerSize.height)
)
path.closeSubpath()
return path
}
}

そしてツールチップをpresent(_:animated:completion:)で表示させます。表示方法をcrossDisolveにして。先程作成したTooltopPresentationControllerのオブジェクトを返してやれば大丈夫です。

class ViewController: UIViewController, UIViewControllerTransitioningDelegate {

@IBOutlet weak var tooltopButton: UIButton!

override func viewDidLoad() {
super.viewDidLoad()
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}

@IBAction func tooptipButtonDidTap(_ sender: UIButton) {
let vc = UIViewController()
vc.view.backgroundColor = .black

vc.transitioningDelegate = self
vc.modalPresentationStyle = .custom
vc.modalTransitionStyle = .crossDissolve
present(vc, animated: true, completion: nil)
}

func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let controller = TooltopPresentationController(presentedViewController: presented, presenting: presenting)
let additionalHeight: CGFloat = -10
controller.presentedViewFrame = dropdownButton.frame
.insetBy(dx: 0, dy: additionalHeight)
.offsetBy(dx: 0, dy: -dropdownButton.frame.height + additionalHeight)
return controller
}
}

するとこんな感じで表示できます。

out2.gif

ツールチップの表示位置に関してとりあえずごにょごにょやっていますが、汎用的な仕組みを作る場合はもうちょい工夫が必要でしょう。


カルーセル

これはUIScrollViewをそのまま使えばできますね。Autolayoutのみで作る方法を以下の記事で書きました。

Auto Layoutのみを使ったページングUIの実装パターン


アコーディオン

UITableViewのセクションへセルを追加すればそれっぽくなります。

コードはあとで


タブ

横スクロールのCollectionViewとUIPageViewControllerを組み合わせるのが一番楽な気がします。これに関しては以下の記事へまとめました。

UICollectionViewControllerとUIPageViewControllerでSmartNewsっぽいあのUIをお手軽に実現する


ドロワー

発祥はfacebookアプリだったと思うのでどちらかと言うとアプリのUIですね。最近のiOSアプリではGoogle製のものを除いたらほぼ絶滅危惧種状態でWebページのほうがよく見る気がするので扱ってみました。こいつもUIPresentationControllerを使うのが良さそう。

これまでと同じようにUIPresentationControllerを継承したクラスを作る。ただし追加機能として、ドロワー表示状態で右にスワイプした時にドロワーを閉じるインタラクティブトランジションも追加したいため、PanGestureに対するDelegateを追加しました。

import UIKit

protocol DrawerPresentationControllerDelegate: class {
func presentationController(controller: DrawerPresentationController, containerViewDidPan sender: UIPanGestureRecognizer)
}

class DrawerPresentationController: UIPresentationController {
private lazy var overlayView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
let tapRec = UITapGestureRecognizer(target: self, action: #selector(DropDownPresentationController.overlayViewDidTap(sender:)))
view.addGestureRecognizer(tapRec)
return view
}()
weak var gestureDelegate: DrawerPresentationControllerDelegate?

override func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()

overlayView.frame = containerView?.frame ?? .zero
containerView?.insertSubview(overlayView, at: 0)

let panGesture = UIPanGestureRecognizer(target: self, action: #selector(DrawerPresentationController.containerViewDidPan(sender:)))
containerView?.addGestureRecognizer(panGesture)

overlayView.alpha = 0
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [weak self] (context) in
self?.overlayView.alpha = 0.5
}, completion: { (_) in
})
}

override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()

presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [weak self] (context) in
self?.overlayView.alpha = 0
}, completion: { (_) in
})
}

override func dismissalTransitionDidEnd(_ completed: Bool) {
super.dismissalTransitionDidEnd(completed)
}

override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
return CGSize(width: parentSize.width / 2, height: parentSize.height)
}

override var frameOfPresentedViewInContainerView: CGRect {
let containerViewBound = containerView?.bounds ?? .zero
let containerSize = size(forChildContentContainer: presentedViewController, withParentContainerSize: containerViewBound.size)
let origin = CGPoint(x: containerViewBound.maxX - containerSize.width, y: containerViewBound.minY)
return CGRect(origin: origin, size: containerSize)
}

override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()

overlayView.frame = containerView?.frame ?? .zero
}

func overlayViewDidTap(sender: UITapGestureRecognizer) {
presentedViewController.dismiss(animated: true, completion: nil)
}

func containerViewDidPan(sender: UIPanGestureRecognizer) {
gestureDelegate?.presentationController(controller: self, containerViewDidPan: sender)
}

}

続いて、またしても同じようにアニメーションのクラスを作成します。ここでは横から出し入れされるようにしたいため、そのようにアニメーションを記述します。注意点としては、インタラクティブトランジションで利用するアニメーションは指の動きに対して線形的であるようにします。

import UIKit

class DrawerAnimator: NSObject, UIViewControllerAnimatedTransitioning {
enum Direction {
case present
case dismiss
}

let direction: Direction

init(direction: Direction) {
self.direction = direction
}

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
switch direction {
case .present:
present(using: transitionContext)
case .dismiss:
dismiss(using: transitionContext)
}
}

func present(using transitionContext: UIViewControllerContextTransitioning) {
guard let toVC = transitionContext.viewController(forKey: .to) else {
return
}

let finalFrame = transitionContext.finalFrame(for: toVC)
toVC.view.frame = finalFrame
let containerView = transitionContext.containerView
containerView.addSubview(toVC.view)

toVC.view.frame.origin.x = containerView.bounds.maxX
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .curveEaseInOut, animations: {
toVC.view.frame.origin.x = finalFrame.minX
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}

func dismiss(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) else {
return
}

let containerView = transitionContext.containerView

if transitionContext.isInteractive {
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
fromVC.view.frame.origin.x = containerView.bounds.maxX
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
} else {
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .curveLinear, animations: {
fromVC.view.frame.origin.x = containerView.bounds.maxX
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}

}
}

そんでもって、ドロワーをpresent(_:animated:completion:)で表示させます。DrawerPresentationControllerのデリゲートとして作成した、DrawerPresentationControllerDelegateを実装します。インタラクティブトランジションを行いたいため、UIPercentDrivenInteractiveTransitionのオブジェクトをそこでアップデートしていきます。

import UIKit

class ViewController: UIViewController, UIViewControllerTransitioningDelegate, DrawerPresentationControllerDelegate {

@IBOutlet weak var drawerButton: UIButton!

var interactiveTransition: UIPercentDrivenInteractiveTransition?

override func viewDidLoad() {
super.viewDidLoad()
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}

@IBAction func drawerButtonDidTap(_ sender: UIButton) {
let vc = UITableViewController()
vc.transitioningDelegate = self
vc.modalPresentationStyle = .custom
vc.view.backgroundColor = .white
present(vc, animated: true, completion: nil)
}

func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let controller = DrawerPresentationController(presentedViewController: presented, presenting: presenting)
controller.gestureDelegate = self
return controller
}

func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return DrawerAnimator(direction: .present)
}

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return DrawerAnimator(direction: .dismiss)
}

func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactiveTransition
}

func presentationController(vc: DrawerPresentationController, containerViewDidPan sender: UIPanGestureRecognizer) {
let translation = sender.translation(in: sender.view)
let prgress: CGFloat = vc.presentedView.flatMap { translation.x / $0.frame.width } ?? 0
switch sender.state {
case .began:
interactiveTransition = UIPercentDrivenInteractiveTransition()
dismiss(animated: true, completion: nil)
case .cancelled:
interactiveTransition?.cancel()
case .changed:
interactiveTransition?.update(prgress)
case .ended:
sender.velocity(in: sender.view).x > 0 ? interactiveTransition?.finish() : interactiveTransition?.cancel()
interactiveTransition = nil
case .failed:
interactiveTransition?.cancel()
interactiveTransition = nil
case .possible:
break
}
}
}

この結果以下のようなUIになります。

out3.gif


(おまけ)ピッカー

アプリ内のUIコンポーネントですが、mobile safariでドロップダウンの代わりに使われるのがピッカーです。これの組み込み方は様々ですが、mobile safariのようにキーボード上へ出したければ昔書いたこちらの記事のようにすればできます。


結論

(できればアプリはアプリらしく)