Xcode
Swift
LivesenseDay 10

SwiftでMaster&Detail構成のテーブルビューをモーダル表示させる

More than 1 year has passed since last update.

Livesense Advent Calendar 2016 10日目の記事です。

いまいちネタが思いつかず、SwiftでMaster&Detail構成のテーブルビューを、モーダル風に表示させる方法、という至極普通の記事でお茶を濁すことにします...

今回のサンプルでは、すべてをコードで制御するのではなく、なるべくStoryboradも使いつつ、最終的にはシンプルで分かりやすい実装を考えてみました。

実際にはこの記事以外にも実現方法は様々あると思いますが、何かの参考になればと思います。


全体の構成

全体の構成ですが、まず、モーダルの呼び出し側をStoryboardで実装し、その中にContainerViewControllerを持たせます。

そのContainerViewControllerにUITableViewを持った別のViewを表示させ、UITableViewのMaster&Detail構成についてはコードから制御するようにします。

Xcode 8.1、Swift2.3での動作を前提としています。


呼び出し側の実装

まず、モーダルを呼び出すためのボタンを設置したStoryboardを準備します。

img01.png

次にこの上にモーダルを表示させるContainerViewControllerを設置しますが、その際にContainerViewControllerの下に画面全体の大きさを持つViewを置いておきます。

このVIewは半透明にしておいて、呼び出されたUITableViewがよりモーダルっぽい見た目になるように調整する役割をさせます。また、このViewをタップするとモーダルが閉じられる役割も持たせるようにしたいと思います。

img02.png

画面全体の大きさでUIViewを設置します。背景色は黒で、Alphaを0.5に設定しています。

img03.png

UIViewの上にContainerViewControllerを置きます。ここでは上下50、左右30の余白を持たせるように制約をつけています。

ちなみに、この時にContainerViewControllerを先に置いたUIViewの配下にしてしまうと、UIView側のAlphaが適用されてしまい、表示する予定のUITableViewまで半透明になってしまうので、UIViewとは並列の階層に置いてください。

今回はモーダルを呼び出すためのボタンのみ置いていますが、実際には他にも色々なVIewを設置したりするケースもあると思います。

その時に、最前面にContainerViewControllerなどがあるとStoryboardの操作の邪魔になるため、一番下のレイヤーに移動させておき、あとでコードから最前面に移動させるようにします。

img04.png


テーブルビューの実装

次に呼び出す側のUITableViewを持つStoryboardを作ります。

img05.png

こちらはUITableViewを置くだけです。

ただ、今回はこの1つのUITableViewでMaster&Detailの挙動をさせたいので、ヘッダーを設置し、そこにDetailからMasterに戻るためのボタンと、Detail側で選択した値を決定させるボタンを置きたいと思います。

img06.png

UITableViewの上にViewを設置し、その中に2つのボタンを設置しておきます。


ViewControllerクラスの準備

Storyboardの準備ができたので、それぞれに対応するViewControllerクラスを実装します。

まず、モーダルを呼び出す側のViewContorllerを作り、先ほど設置したボタンと2つのViewに対するアウトレットを結びつけます。

img07.png

モーダルとして呼び出す側のViewContorllerも作ります。こちらはUITableViewとヘッダーに置いた2つのボタンそれぞれのアウトレット、2つのボタンに対するアクションアウトレットを結びつけておきます。

img08.png


UITableViewの実装

次にモーダルとして呼び出されるUITableViewのコードを実装していきます。

今回は汎用性を考えて、表示するデータは呼び出し元から受け渡される想定にしたいと思います。

また、選択された値は、呼び出し元に返却するようにします。

ちなみに、タイトルにMaster&Detail構成のテーブルビューを、と書いていますが、親要素を持たないケースにも対応できるようにしておこうと思います。

汎用性という観点から、Detail側でセルの複数選択を許可するかどうかも、呼び出し元からコントロールできるようにします。


ModalViewController.swift

var isDetail = true

var allowsMultiple = false
var tableData = [String]()
var selectedItem = [String]()
var masterData = [String]()
var detailData = [[String]]()

モーダル側に上記の変数を持たせておき、呼び出し時に各変数に値を渡す想定です。

また、モーダルを閉じる際に、選択した値を呼び出し元に戻すためにデリゲートメソッドを使用します。

下記のようにprotocolを記述しておきます。


ModalViewController.swift

protocol ModalViewControllerDelegate {

func modalDidFinished(selectedItem: [String])
}

次に、MasterからDetailにデータが変更される際に、テーブルビューが横にスライドするような効果を見せるアニメーションを定義しておきます。


ModalViewController.swift

// MasterからDetailになる時のアニメーション

let originalRect = CGRectMake(baseTableView.frame.origin.x,
baseTableView.frame.origin.y,
baseTableView.frame.size.width,
baseTableView.frame.size.height
)
let detailRect = CGRectMake(baseTableView.frame.origin.x,
baseTableView.frame.origin.y,
10,
baseTableView.frame.size.height
)

UIView.animateWithDuration(0.1, animations: {
self.baseTableView.frame = detailRect
}, completion: { _ in
// データの変換
UIView.animateWithDuration(0.0, animations: {
self.baseTableView.frame = originalRect
}, completion: { _ in
})
})



ModalViewController.swift

// DetailからMasterになる時のアニメーション

let originalRect = CGRectMake(baseTableView.frame.origin.x,
baseTableView.frame.origin.y,
baseTableView.frame.size.width,
baseTableView.frame.size.height
)
let masterRect = CGRectMake(baseTableView.frame.origin.x + baseTableView.frame.size.width,
baseTableView.frame.origin.y,
10,
baseTableView.frame.size.height
)

UIView.animateWithDuration(0.1, animations: {
self.baseTableView.frame = masterRect
}, completion: { finisihed in
// データの変換
UIView.animateWithDuration(0.0, animations: {
self.baseTableView.frame = originalRect
}, completion: { finisihed in
})
})


アニメーション中の各パラメータ値は、実際に挙動を見ながら調整してください。

ここでは適当な値を入れています。

画面が表示されるタイミングで、呼び出し元から渡された値を元に、データを初期化します。


ModalViewController.swift

override func viewDidLoad() {

super.viewDidLoad()

tableView.delegate = self
tableView.dataSource = self

if isDetail {
btnDone.hidden = false
tableData = detailData[0]
tableView.allowsMultipleSelection = allowsMultiple
} else {
btnDone.hidden = true
tableData = masterData
}
}


isDetailがtrueだった場合は、Master&Detail構成ではないということなので、保存ボタンを表示させます。

また、テーブルに表示させるデータは配列で受け渡される想定にしているため、その場合は0番目のデータを無条件で取るようにします。

isDetailがfalseの場合は、保存ボタンは非表示にし、渡されたMaster側のデータを表示データとしてセットします。

セルのタップ時でもisDetailの状態を判別し、Masterの状態であればDetailへ変更し、Detailの状態であれば選択されたデータを保持するようにします。


ModalViewController.swift

func tableView(table: UITableView, didSelectRowAtIndexPath indexPath:NSIndexPath) {

if isDetail {
cell = tableView.cellForRowAtIndexPath(indexPath)!
cell.accessoryType = UITableViewCellAccessoryType.Checkmark
// 値の選択
selectedItem.append(tableData[indexPath.row])
} else {
let originalRect = CGRectMake(tableView.frame.origin.x,
tableView.frame.origin.y,
tableView.frame.size.width,
tableView.frame.size.height
)
let detailRect = CGRectMake(tableView.frame.origin.x,
tableView.frame.origin.y,
10,
tableView.frame.size.height
)

UIView.animateWithDuration(0.1, animations: {
self.tableView.frame = detailRect
}, completion: { _ in
// Detailの状態に変更
UIView.animateWithDuration(0.0, animations: {
self.tableView.frame = originalRect
}, completion: { _ in
})
})
}
}


同様に、戻るボタンが押された場合もMasterであればモーダルをfinishさせ、DetailであればMasterに戻るようにします。


ModalViewController.swift

@IBAction func actionBack(sender: UIButton) {

if isDetail {
let originalRect = CGRectMake(tableView.frame.origin.x,
tableView.frame.origin.y,
tableView.frame.size.width,
tableView.frame.size.height
)
let masterRect = CGRectMake(tableView.frame.origin.x + tableView.frame.size.width,
tableView.frame.origin.y,
10,
tableView.frame.size.height
)

UIView.animateWithDuration(0.1, animations: {
self.tableView.frame = masterRect
}, completion: { finisihed in
self.changeFromDetailToMaster()
UIView.animateWithDuration(0.0, animations: {
self.tableView.frame = originalRect
}, completion: { finisihed in
})
})
} else {
self.delegate?.modalDidFinished(selectedItem)
}
}


MasterとDetailを切り替える部分は別メソッドにしておき、必要な箇所でこれを呼び出す実装にします。


ModalViewController.swift

func changeFromMasterToDetail(indexPath: NSIndexPath) {

isDetail = true
btnDone.hidden = false
tableData.removeAll()
tableData = detailData[indexPath.row]

tableView.allowsMultipleSelection = allowsMultiple

tableView.reloadData()
tableView.setContentOffset(CGPointZero, animated: false)
}

func changeFromDetailToMaster() {
isDetail = false
btnDone.hidden = true

tableData.removeAll()
tableData = masterData

tableView.reloadData()
}


その他、TableViewに必要なメソッドを追加し、全体は下記のようになります。


ModalViewController.swift

import UIKit

protocol ModalViewControllerDelegate {
func modalDidFinished(selectedItem: [String])
}

class ModalViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var btnBack: UIButton!
@IBOutlet weak var btnDone: UIButton!

var isDetail = true
var allowsMultiple = false

var tableData = [String]()
var selectedItem = [String]()
var masterData = [String]()
var detailData = [[String]]()

var cell = UITableViewCell()
var delegate: ModalViewControllerDelegate?

override func viewDidLoad() {
super.viewDidLoad()

tableView.delegate = self
tableView.dataSource = self

if isDetail {
btnDone.hidden = false
tableData = detailData[0]
tableView.allowsMultipleSelection = allowsMultiple
} else {
btnDone.hidden = true
tableData = masterData
}
}

@IBAction func actionDone(sender: UIButton) {
self.delegate?.modalDidFinished(selectedItem)
}

@IBAction func actionBack(sender: UIButton) {
if isDetail {
let originalRect = CGRectMake(tableView.frame.origin.x,
tableView.frame.origin.y,
tableView.frame.size.width,
tableView.frame.size.height
)
let masterRect = CGRectMake(tableView.frame.origin.x + tableView.frame.size.width,
tableView.frame.origin.y,
10,
tableView.frame.size.height
)

UIView.animateWithDuration(0.1, animations: {
self.tableView.frame = masterRect
}, completion: { finisihed in
self.changeFromDetailToMaster()
UIView.animateWithDuration(0.0, animations: {
self.tableView.frame = originalRect
}, completion: { finisihed in
})
})
} else {
self.delegate?.modalDidFinished(selectedItem)
}
}

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return tableData.count
}

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
cell = UITableViewCell(style: UITableViewCellStyle.Subtitle, reuseIdentifier: "Cell")
cell.textLabel!.text = tableData[indexPath.row]

return cell
}

func tableView(table: UITableView, didSelectRowAtIndexPath indexPath:NSIndexPath) {
if isDetail {
cell = tableView.cellForRowAtIndexPath(indexPath)!
cell.accessoryType = UITableViewCellAccessoryType.Checkmark
// 値の選択
selectedItem.append(tableData[indexPath.row])
} else {
let originalRect = CGRectMake(tableView.frame.origin.x,
tableView.frame.origin.y,
tableView.frame.size.width,
tableView.frame.size.height
)
let detailRect = CGRectMake(tableView.frame.origin.x,
tableView.frame.origin.y,
10,
tableView.frame.size.height
)

UIView.animateWithDuration(0.1, animations: {
self.tableView.frame = detailRect
}, completion: { _ in
self.changeFromMasterToDetail(indexPath)
UIView.animateWithDuration(0.0, animations: {
self.tableView.frame = originalRect
}, completion: { _ in
})
})
}
}

func tableView(tableView: UITableView, didDeselectRowAtIndexPath indexPath: NSIndexPath) {
if isDetail {
cell = tableView.cellForRowAtIndexPath(indexPath)!
cell.accessoryType = UITableViewCellAccessoryType.None
// 値の選択解除
selectedItem.removeAtIndex(selectedItem.indexOf(tableData[indexPath.row])!)
}
}

func changeFromMasterToDetail(indexPath: NSIndexPath) {
isDetail = true
btnDone.hidden = false
tableData.removeAll()
tableData = detailData[indexPath.row]

// 複数選択の許可
tableView.allowsMultipleSelection = allowsMultiple

tableView.reloadData()
tableView.setContentOffset(CGPointZero, animated: false)
}

func changeFromDetailToMaster() {
isDetail = false
btnDone.hidden = true

tableData.removeAll()
tableData = masterData

tableView.reloadData()
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}



呼び出し側の実装

呼び出し側では、まず画面表示のタイミングで最奧に設置しておいたBaseViewとModalViewを最前面に配置しなおします。

ただし、まだこの時点では画面自体は見せたくないため、透明にしておきます。hiddenやtransformを設定しているのは、モーダル自体をポップアップさせて表示するアニメーション効果のためです。


ModalBaseViewController.swift

view.bringSubviewToFront(baseView)

view.bringSubviewToFront(modalView)
baseView.hidden = true
modalView.hidden = true
baseView.alpha = 0
modalView.alpha = 0
modalView.transform = CGAffineTransformMakeScale(0.5, 0.5)

アニメーションについては下記のようにしました。こちらも各パラメータは挙動を見ながら調整してみてください。


ModalBaseViewController.swift

UIView.animateWithDuration(0.3, delay: 0.0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.0, options: .CurveEaseInOut, animations: {

self.viewModalBase.hidden = false
self.viewModal.hidden = false
self.viewModal.alpha = 1.0
self.viewModal.layer.borderWidth = 1.0
self.viewModal.layer.borderColor = UIColor.customColor(UIColor.ColorName.backgroundDarkGrayColor).CGColor
self.viewModalBase.alpha = 0.5
self.viewModal.transform = CGAffineTransformMakeScale(1.01, 1.01)
}, completion: { finished in
self.viewModal.transform = CGAffineTransformIdentity
})

あらかじめアウトレットをつけておいたBaseViewにタップジェスチャーを追加し、モーダルの外側をタップするとモーダルが非表示になるように実装します。


ModalBaseViewController.swift

let gesture = UITapGestureRecognizer(target: self, action: #selector(ModalBaseViewController.gestureTapViewModalBase))

baseView.addGestureRecognizer(gesture)


ModalBaseViewController.swift

func gestureTapViewModalBase() {

}

TableView側で設定したデリゲートメソッドも実装し、テーブルビューで選択した値を受け取れるようにします。


ModalBaseViewController.swift

class ModalBaseViewController: UIViewController, ModalViewControllerDelegate {...

func modalDidFinished(selectedItem: [String]) {
}


モーダル呼び出しのためのボタンアクション内に、ContainerViewControllerへのaddSubViewと、受け渡すパラメータをセットします。

先ほど定義したアニメーションもここに記述します。


ModalBaseViewController.swift

@IBAction func actionBtnShowModal(sender: AnyObject) {

let storyboard = UIStoryboard(name: "ModalView", bundle: nil)
let viewController = storyboard.instantiateInitialViewController()! as! ModalViewController
// Master&Detail構成の場合false, Detailのみの場合はtureを指定
viewController.isDetail = false
// 複数選択の有無によってtureもしくはfalse
viewController.allowsMultiple = true
viewController.masterData = masterData
viewController.detailData = detailData
viewController.view.translatesAutoresizingMaskIntoConstraints = false
self.addChildViewController(viewController)
self.view.addSubview(viewController.view, toView: modalView)
viewController.delegate = self

UIView.animateWithDuration(0.3, delay: 0.0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.0, options: .CurveEaseInOut, animations: {
self.baseView.hidden = false
self.modalView.hidden = false
self.modalView.alpha = 1.0
self.modalView.layer.borderWidth = 1.0
self.modalView.layer.borderColor = UIColor.blackColor().CGColor
self.baseView.alpha = 0.5
self.modalView.transform = CGAffineTransformMakeScale(1.01, 1.01)
}, completion: { _ in
self.modalView.transform = CGAffineTransformIdentity
})
}


全体としては下記のようになります。


ModalBaseViewController.swift

import UIKit

class ModalBaseViewController: UIViewController, ModalViewControllerDelegate {

@IBOutlet weak var baseView: UIView!
@IBOutlet weak var modalView: UIView!
@IBOutlet weak var btnShowModal: UIButton!

var masterData = ["hoge1","hoge2","hoge3"]
var detailData = [["fuga1", "fuga2", "fuga3", "fuga4", "fuga5"],
["fuga6", "fuga7", "fuga8", "fuga9", "fuga10"],
["fuga11", "fuga12", "fuga13", "fuga14", "fuga15"]]

override func viewDidLoad() {
super.viewDidLoad()

view.bringSubviewToFront(baseView)
view.bringSubviewToFront(modalView)
baseView.hidden = true
modalView.hidden = true
baseView.alpha = 0
modalView.alpha = 0
modalView.transform = CGAffineTransformMakeScale(0.5, 0.5)

let gesture = UITapGestureRecognizer(target: self, action: #selector(ModalBaseViewController.gestureTapViewModalBase))
baseView.addGestureRecognizer(gesture)
}

@IBAction func actionBtnShowModal(sender: AnyObject) {
let storyboard = UIStoryboard(name: "ModalView", bundle: nil)
let viewController = storyboard.instantiateInitialViewController()! as! ModalViewController
// Master&Detail構成の場合false, Detailのみの場合はtureを指定
viewController.isDetail = false
// 複数選択の有無によってtureもしくはfalse
viewController.allowsMultiple = true
viewController.masterData = masterData
viewController.detailData = detailData
viewController.view.translatesAutoresizingMaskIntoConstraints = false
self.addChildViewController(viewController)
self.view.addSubview(viewController.view, toView: modalView)
viewController.delegate = self

UIView.animateWithDuration(0.3, delay: 0.0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.0, options: .CurveEaseInOut, animations: {
self.baseView.hidden = false
self.modalView.hidden = false
self.modalView.alpha = 1.0
self.modalView.layer.borderWidth = 1.0
self.modalView.layer.borderColor = UIColor.blackColor().CGColor
self.baseView.alpha = 0.5
self.modalView.transform = CGAffineTransformMakeScale(1.01, 1.01)
}, completion: { _ in
self.modalView.transform = CGAffineTransformIdentity
})
}

func gestureTapViewModalBase() {
hiddenModalView()
}

func modalDidFinished(selectedItem: [String]) {
debugPrint(selectedItem)

hiddenModalView()
}

func hiddenModalView() {
baseView.hidden = true
modalView.hidden = true
baseView.alpha = 0
modalView.alpha = 0
modalView.transform = CGAffineTransformMakeScale(0.5, 0.5)
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}


また、この中で使用しているaddSubViewはextensionとして定義してあります。


ModalBaseViewController.swift

import UIKit

extension UIView {
func addSubview(subView:UIView, toView parentView:UIView) {
parentView.addSubview(subView)

var viewBindingsDict = [String: AnyObject]()
viewBindingsDict["subView"] = subView
parentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[subView]|", options: [], metrics: nil, views: viewBindingsDict))
parentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[subView]|", options: [], metrics: nil, views: viewBindingsDict))
}
}



まとめ

今回実現したかった挙動に関しては、他にも色々な実装方法があると思いますが、Storyboardとコードからの制御を混ぜつつも、全体としてあまり複雑にならないよう工夫してみました。

Swiftにご興味がある方や、実務でSwiftを使われている方など、どなたかの何かしらの参考になればいいなと思います。

[追記]

サンプルプロジェクトをGitHubにあげました。こちらはSwift3対応版です。

https://github.com/SpycWolf/Sfiwt_Modal_TableView_Sample