「FloatingPanel」を利用したiOSアプリの半モーダルビュー実装
はじめに
最近アプリで見る機会が多くなった半モーダルビュー。👇記事にあるように画面巨大化に伴ういまのところベストな解決案だと思っていて個人的にも大好きです。
Apple製のアプリでも多用されているので(たとえば👇Mapアプリ)iOS13から標準機能になるのでは、、という噂もあったのですが、結局そうはならなかったので自前で実装したというお話です。
ちなみに、こういった画面の呼び方は定まってなく「セミモーダル」「ハーフモーダル」「フローティングパネル」などいろいろあるようですが、こちらの記事にあわせ「半モーダルビュー」と呼ぶことにします。
ライブラリの選定
自前で実装と書きましたがすでに世の中に優れたライブラリが多数存在し、その中でも要件に合い使いやすそうということで Shin Yamamoto さんの「FloatingPanel」を利用させていただきました。iOS標準アプリの「Map」と「株価」をライブラリ利用のサンプルとして提供されていてこういうことがしたい!という動機だったのでとてもわかりやすかったです。
とりあえず導入
Cocoapodsでインストールしました。Podfileに下記を追加
// Podfile
pod 'FloatingPanel'
半モーダルで表示したいViewControllerを作成する
import UIKit
class ContentViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
}
}
半モーダル"を"表示するViewControllerを作成する
import UIKit
import FloatingPanel
class ViewController: UIViewController {
var fpc = FloatingPanelController()
override func viewDidLoad() {
super.viewDidLoad()
let contentVC = ContentViewController()
fpc.set(contentViewController: contentVC)
fpc.addPanel(toParent: self)
}
}
たったこれだけで次のような半モーダルビューの導入ができます。素晴らしい 👏👏👏
緑のものが半モーダルビュー部分です。上下にスワイプすることで高さを変更しています。Mapアプリのように高さが低くなったり半分くらいになったりといい感じの位置で止まります。ちなみにこのときViewControllerもContentViewControllerもどちらもユーザーアクションが受け付けれる状態です。
レイアウトの変更
レイアウトをカスタマイズする仕組みも準備されています。FloatingPanelLayout を継承したclassを作成し、それをFloatingPanelControllerDelegate のdelegateメソッドで返却します。この辺りはUICollectionViewの仕組みと同様です。
import UIKit
import FloatingPanel
class ViewController: UIViewController {
var fpc = FloatingPanelController()
override func viewDidLoad() {
super.viewDidLoad()
// 先にdelegateを設定しておく
fpc.delegate = self
let contentVC = ContentViewController()
fpc.set(contentViewController: contentVC)
// この後でdelegateを設定するとinitialPositionが効かない
fpc.addPanel(toParent: self)
}
}
// MARK: - FloatingPanel Delegate
extension ViewController: FloatingPanelControllerDelegate {
// カスタマイズしたレイアウトに変更
func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
return CustomFloatingPanelLayout()
}
}
// MARK: - FloatingPanel Layout
class CustomFloatingPanelLayout: FloatingPanelLayout {
// 初期位置
var initialPosition: FloatingPanelPosition {
return .tip
}
// カスタマイズした高さ
func insetFor(position: FloatingPanelPosition) -> CGFloat? {
switch position {
case .full: return 16.0
case .half: return 216.0
case .tip: return 44.0
default: return nil
}
}
// サポートする位置
var supportedPositions: Set<FloatingPanelPosition> {
return [.full, .half, .tip]
}
}
半モーダルの表示位置が3段階あり、それぞれ
full:画面の全面を覆うタイプ(ちょっと隙間を作れる)
half:画面の半分くらい表示されるタイプ
tip:画面の下に少しだけ表示されるタイプ
という名前がついています。
supportedPositions変数でサポートする位置を指定できます。たとえばこの配列が[.full, .tip]であればhalfの位置で半モーダルViewが止まらないことになります。
insetForメソッドでそれぞれの位置におけるinsetを指定できます。
initialPosition変数で初期の半モーダルビューの位置を指定できます。
このレイアウト変更をすると👇のようになります。最初と高さが少し違うのがわかりますでしょうか。
表示位置の変更に応じた処理をする
表示位置の変更を検知してフックしてくれる各種メソッドが FloatingPanelControllerDelegate に準備されています。スワイプ開始や表示位置変更完了など各イベント時に好きな処理を行えます。このあたりはUIKitを踏襲しているのでとても分かり易かったです。
半モーダルビューの中にTableViewを入れる
半モーダルビューのコンテンツにTableViewのようなScrollableなものを含んでいた場合の対応もされています。まずはContentViewControllerを次のように修正。100行のTableViewを入れているだけです。
import UIKit
class ContentViewController: UIViewController {
lazy var tableView: UITableView = {
let tableView = UITableView(frame: .zero)
tableView.backgroundColor = .white
tableView.dataSource = self
tableView.contentInsetAdjustmentBehavior = .always
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
extension ContentViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 100
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = "\(indexPath.row)行目"
return cell
}
}
そしてViewControllerのviewDidLoadに次の行を追加します。
// trackingするScrollViewを指定
fpc.track(scrollView: contentVC.tableView)
すると次のように、fullの場合だけスワイプがTableViewに効くようになり、一番上の行を表示中は下スワイプで半モーダルビューを閉じる挙動をしてくれるようになります。簡単にいうと標準アプリと同じなためユーザーの期待通りの動きになります!
アプリに組み込んでみた
FloatingPanelを実際に組み込んだアプリです。地図検索の条件指定を画面下部のUIパーツを使って片手でできるようにしてみました。地図表示などと相まって複雑そうに見えますがこれまで記載した基本機能を組み合わせてるだけで難しいことはしていません。GIF画像がぼやけてますね。。
FloatingPanelを利用した半モーダルビュー実装のまとめ
まとめですが、とにかく FloatingPanel が最高すぎる。ライブラリとして程よい抽象度、わかりやすいインターフェース、必要な機能など言うことなしでした。
1点、実現したいUIのため横ScrollのCollectionViewをContentViewControllerに入れてみたのですがこれはいまいちでした。CollectionView上で縦スワイプ(半モーダルビューを操作したい)した際にまずCollectionViewがイベントを拾って判断するため、スワイプの反応が一瞬遅延し気持ちの良い動作にならなかったため断念しました。
最後に宣伝
スペースマーケットではアプリエンジニアを募集しています!興味のある方はWantedlyからご連絡ください!