#はじめに
MVPについて詳しく説明している記事がありますので、お時間がある方はぜひそちらもご覧ください。
また、今回は下記の書籍を参考にYoutubeの動画検索アプリを作成しました。
とても読みやすい本でしたのでぜひご覧ください。
今回のサンプルアプリのコードはGitHubにて共有しています。
筆者は独学で学習中のエンジニアでして、ツッコミどころ満載のコードを書くかもしれませんが、
その時は指摘していただけますと幸いです。
#Passive Viewによるサンプルアプリの実装
今回は、Passive Viewでアプリを実装しようと思います。
アプリの内容としては、一つ目のViewでキーワードを入力し検索をかけると、
二つ目の画面に遷移し、Youtubeの該当する動画一覧を表示するアプリを作成したいと思います。
##コードの解説
まず必要なものをインストールします。
今回はAlamofire
とSDWebImage
です。
ということでPodfileに下記を追記してpod install
を実行します。
pod "Alamofire"
pod "SDWebImage"
今回のサンプルアプリを作成するにあたり、
ファイルの構造などは書籍のものを参考に作成したいと思います。
書籍では、一つの画面につき一つのフォルダを作成し、その中にStoryboard、Model、View、Presenterを作成しています。
今回は、動画検索画面(SearchVideo)と動画一覧画面(VideoList)を作成します。
Main.storyboardからSearchVideo.storyboardに変更したので、
Info.plistの値を一部変更しなければ表示されません。
下記の二箇所を自分で決めたStoryboardの名前に変更してください。
また、各画面のUIは次のようになります。
SearchVideoViewController(Navigation Controllerに変更)
VideoListViewController(tableViewのみでセルはカスタムセルを使用)
とりあえずUIはこのようにします。
各View全てAutoLayoutで制約を付けています。
次にコードを記述していきます。
MVPを意識したコードの記述、難しかったです・・・。
まずはSearchVideo関連のファイルから進めていきます。
各オブジェクトをIBOutletなどで紐付けると下記のようになります。
また、Delegateなど別のプロトコルに準拠させる場合はextensionで追加しています。
(そっちの方が見やすい気がします!)
ここから先の説明ですが、書籍の内容と個人的な解釈で記載しているので、
間違ったことを行っていたらすみません!!!
private var presenter: SearchVideoPresenterInput!
について。
SearchVideoPresenter
には
Input時に使うSearchVideoPresenterInput
プロトコルと、
Output時に使うSearchVideoPresenterOutput
プロトコルを定義しています。
つまり、Inputプロトコルには、ViewControllerからPresenterに値を渡したい時に必要な処理を、
OutputプロトコルにはPresenterからViewControllerに値を渡したい時に必要な処理を記述しています。
値を渡す時にはInputプロトコルに定義されているメソッドを使うので、
private var presenter: SearchVideoPresenterInput!
のように宣言し値を渡せるようにします。
ですが、この時点では初期化をしていない状態なので、
viewDidLoad内のinject(presenter: presenter)
で初期化しています。
この時、let presenter = SearchVideoPresenter(view: self)
で
ViewControllerの情報をPresenterに与えておきます。
import UIKit
class SearchVideoViewController: UIViewController {
// 検索用のフィールド
@IBOutlet weak var searchTextField: UITextField!
private var presenter: SearchVideoPresenterInput!
func inject(presenter: SearchVideoPresenterInput) {
self.presenter = presenter
}
override func viewDidLoad() {
super.viewDidLoad()
let presenter = SearchVideoPresenter(view: self)
inject(presenter: presenter)
searchTextField.delegate = self
}
// 検索ボタンが押された時の処理
@IBAction func pushSearchButton(_ sender: Any) {
}
}
extension SearchVideoViewController: UITextFieldDelegate {
// キーボードが閉じられたら実行される
func textFieldDidEndEditing(_ textField: UITextField) {
}
// return を押したらキーボードを閉じる
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
// キーボード意外をタッチしたらキーボードを閉じる
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
searchTextField.resignFirstResponder()
}
}
次に、一旦Presenterを記述していきます。
先ほど記述したように2つのプロトコルを定義してます。
プロトコルの中身はコメントの通りです。
Viewで何かの処理を行うたびにSearchVideoPresenterInput
のメソッドが動きます。
そしてViewの画面を変更する際にはSearchVideoPresenterOutput
のメソッドが動きます。
ここら辺のプロトコルに関してですが、
デリゲートなどに関してある程度理解していないとごちゃごちゃになりそうなので、
わからなかったらデリゲートについて調べてください...。
import Foundation
// SearchVideoViewControllerからのInputに必要な処理を記述
protocol SearchVideoPresenterInput {
// 入力された文字を格納するための変数
var inputText: String {get set}
// inputTextに文字を代入するメソッド
func setTextToInputText(text: String?)
// 検索ボタンを押した時の処理
func pushSearchButton()
}
// SearchVideoViewControllerへのOutputに必要な処理を記述
protocol SearchVideoPresenterOutput {
// 画面遷移をさせるメソッド
func transitionToVideoList(searchText: String)
}
final class SearchVideoPresenter: SearchVideoPresenterInput {
private var view: SearchVideoPresenterOutput!
var inputText: String = ""
init(view: SearchVideoViewController) {
self.view = view
}
func pushSearchButton() {
view.transitionToVideoList(searchText: inputText)
}
func setTextToInputText(text: String?) {
guard let inputText = text else {
self.inputText = ""
return
}
self.inputText = inputText
}
}
MVPは基本的に下記の流れになります。
Viewでユーザーからの操作を受け付ける。
-> 「操作されましたよ」とPresenterに伝える。
-> 必要に応じてModelで計算等を行い、結果を返してもらう
-> Viewに「〇〇してください」と指示を出す。
Viewは操作を受け付けるだけなので、
検索ボタンが押されたら、検索欄に〇〇と入力されているから画面遷移しながら〇〇の値を渡す。
と言った流れはよろしくないと思います。
なので、もっと早い段階で入力情報をPresenterで保管しておき、
画面遷移のタイミングでPresenterからViewに入力情報と画面遷移の指示を出す流れにする必要があります。
Presenterである程度プロトコルとその中身を定義したので
ViewControllerに戻って処理を記述していきます。
追記した箇所のみ記載しています。
import UIKit
class SearchVideoViewController: UIViewController {
// 検索ボタンが押された時の処理
@IBAction func pushSearchButton(_ sender: Any) {
presenter.pushSearchButton()
}
}
extension SearchVideoViewController: UITextFieldDelegate {
// キーボードが閉じられたら実行される
func textFieldDidEndEditing(_ textField: UITextField) {
// PresenterのinputTextに値を代入
presenter.setTextToInputText(text: textField.text)
}
}
extension SearchVideoViewController: SearchVideoPresenterOutput {
// 画面遷移の処理
func transitionToVideoList(searchText: String) {
let videoListVC = UIStoryboard(
name: "VideoList",
bundle: nil)
.instantiateViewController(withIdentifier: "VideoList") as! VideoListViewController
videoListVC.inputText = presenter.inputText
navigationController?.pushViewController(videoListVC, animated: true)
}
}
最終的な流れとしては下記のようになります。
文字が入力される
-> キーボードが閉じられた時にtextFieldDidEndEditing(textField:)
実行
-> presenterにsetTextToInputText(text:)
を実行してもらう
-> setTextToInputText(text:)
実行
-> presenterのinputTextに入力情報が格納される
検索ボタンが押される
-> IBActionのpushSearchButton(sender:)
が実行される
-> presenterにpushSearchButton()
を実行してもらう
-> pushSearchButton()
実行
-> viewにtransitionToVideoList(searchText:)
を実行してもらう
-> transitionToVideoList(searchText:)
実行
-> presenterの値を渡してから画面遷移
行ったり来たりして大変です(笑)
ただ、これこそがMVPの流れなのかな?とも思います。
次に画面遷移先での処理です。
画面遷移する際に入力情報の値だけ渡しているので、
それを元にYoutubeで検索し情報を取得します。
処理は先ほどの流れのような処理なので一部説明は省きます。
先ほどと同じようにviewDidLoad()でpresenteとModelの初期化を行います。
また、tableViewを使うのでdelegate = self
を忘れずに
NavigationControllerで画面を戻ることができるので、
再度VideoListViewControllerが開かれた時に再度APIを取得できるように
viewWillAppear()に記述しました。
この画面では操作は受け付けていないので、
画面が読み込まれた時にreloadData(url:, key:, text:)
が
実行されるだけになります。
import UIKit
class VideoListViewController: UIViewController {
@IBOutlet weak var videoListTableView: UITableView!
// youtubeのAPI取得のためのURLとKEY
private let url = "https://www.googleapis.com/youtube/v3/search"
private let key = "AIzaSyDqWqDc0lL633AinIHA9JkeVEvLR1kz1KU&part=snippet"
var inputText: String = ""
private var presenter: VideoListPresenterInput!
func inject(presenter: VideoListPresenter) {
self.presenter = presenter
}
override func viewDidLoad() {
super.viewDidLoad()
let model = VideoListModel()
let presenter = VideoListPresenter(view: self, model: model)
inject(presenter: presenter)
videoListTableView.delegate = self
videoListTableView.dataSource = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 画面読み込みと同時にAPIデータ取得
presenter.reloadData(url: url, key: key, text: inputText)
}
}
extension VideoListViewController: VideoListPresenterOutput {
func reloadTableView() {
videoListTableView.reloadData()
}
}
extension VideoListViewController: UITableViewDelegate {
// セルがタップされた時の処理などを記述
}
extension VideoListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let data = presenter.data else {
return 0
}
return data.items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
videoListTableView.register(UINib(nibName: "VideoDetailCell", bundle: nil), forCellReuseIdentifier: "VideoDetailCell")
let cell = videoListTableView.dequeueReusableCell(withIdentifier: "VideoDetailCell") as! VideoDetailCell
if let data = presenter.data {
cell.configure(data: data.items[indexPath.row].snippet)
}
return cell
}
}
Viewが読み込まれた時にreloadData()
が実行され、
reloadData()内では、ModelのgetYoutubeData()
が実行されます。
getYoutubeData()はクロージャになっており、情報取得後にクロージャの内容が実行されます。
なので、取得した情報を変数dataに入れてViewにreloadTableView()
を実行するよう指示します。
import Foundation
import Alamofire
protocol VideoListPresenterInput {
// Viewが表示されるたびに呼ばれるメソッド
func reloadData(url: String, key: String, text: String)
var data: VideoModel? {get set}
}
protocol VideoListPresenterOutput {
func reloadTableView()
}
final class VideoListPresenter: VideoListPresenterInput {
var data: VideoModel?
private var view: VideoListPresenterOutput!
private var model: VideoListModelInput!
init(view: VideoListViewController, model: VideoListModel) {
self.view = view
self.model = model
}
func reloadData(url: String, key: String, text: String) {
model.getYoutubeData(url: url, key: key, text: text) { (data) in
self.data = data
self.view.reloadTableView()
}
}
}
Modelのコードは下記のようになります。
getYoutubeData()メソッドはもらったURLと入力の情報、Keyを元にデータを取得しています。
データの取得にはAlamofireが必須なので必ずインストールしましょう。
「Youtubeのデータを格納するモデル」ですが、取得したJSONのデータを格納するためのクラスになります。
ここら辺の説明はMVPとは関係ないので省略します。
import Foundation
import Alamofire
// Youtubeのデータを格納するモデル
final class VideoModel: Decodable {
let kind: String
let items: [Item]
}
final class Item: Decodable {
let snippet: Snippet
}
final class Snippet: Decodable {
let publishedAt: String
let channelId: String
let title: String
let description: String
let thumbnails: Thumbnail
}
final class Thumbnail: Decodable {
let medium: ThumbnailsInfo
let high: ThumbnailsInfo
}
final class ThumbnailsInfo: Decodable {
let url: String
let width: Int?
let height: Int?
}
// ここまでYoutubeのデータを格納するモデル
protocol VideoListModelInput {
func getYoutubeData(url: String, key: String, text: String, completion: @escaping (_ data: VideoModel) -> Void)
}
protocol VideoListModelOutput {
func resultAPIData(data: VideoModel)
}
final class VideoListModel: VideoListModelInput {
// youtubeのデータを取得する
func getYoutubeData(url: String, key: String, text: String, completion: @escaping (VideoModel) -> Void) {
let urlString = "\(url)?q=\(text)&key=\(key)"
AF.request(urlString).responseJSON { (response) in
do {
guard let data = response.data else { return }
let decode = JSONDecoder()
let video = try decode.decode(VideoModel.self, from: data)
completion(video)
} catch {
print("変換に失敗しました。\n【エラー内容】", error)
}
}
}
}
情報を取得したらPresenterのクロージャ内の処理を実行。
その処理はtableViewのリロードになっているのでtableViewが更新される。
という流れになっています。
ちなみにカスタムセルのコードは下記のようになります。
SDWebImageでサムネの画像を取得しています。
import UIKit
import SDWebImage
class VideoDetailCell: UITableViewCell {
@IBOutlet weak var thumbnail: UIImageView!
@IBOutlet weak var channelIcon: UIImageView!
@IBOutlet weak var title: UILabel!
@IBOutlet weak var date: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
}
// セルにデータを入れていく
func configure(data: Snippet) {
thumbnail.sd_setImage(with: URL(string: data.thumbnails.medium.url), completed: nil)
title.text = data.title
date.text = data.publishedAt
}
}
#さいごに
今回は簡単な処理だったのでMVPの良さが出ているかわかりませんが、
個人的には結構面白いなと思いました。
慣れれば処理をどこに書くか迷わないでしょうし、
どこに処理を書いたかがわかりやすくなりそうです。
また、処理の流れなども掴みやすくなるかも?と思いました。
そもそも記述方法があっていなかったらすみません・・・。
MVVMなども理解すれば便利そうなので、
MVPでしっかりとコードが書けるようになったら覚えてみます!
以上、最後までご覧いただきありがとうございました。