LoginSignup
8
4

More than 1 year has passed since last update.

【Swift】MVPでYoutubeの動画検索アプリを開発してみた

Last updated at Posted at 2021-03-02

#はじめに

MVPについて詳しく説明している記事がありますので、お時間がある方はぜひそちらもご覧ください。

【Swift】MVPアーキテクチャについて色々調べてみた

また、今回は下記の書籍を参考にYoutubeの動画検索アプリを作成しました。
とても読みやすい本でしたのでぜひご覧ください。

iOSアプリ設計パターン入門

今回のサンプルアプリのコードはGitHubにて共有しています。

GitHubはこちら

筆者は独学で学習中のエンジニアでして、ツッコミどころ満載のコードを書くかもしれませんが、
その時は指摘していただけますと幸いです。

#Passive Viewによるサンプルアプリの実装

今回は、Passive Viewでアプリを実装しようと思います。

アプリの内容としては、一つ目のViewでキーワードを入力し検索をかけると、
二つ目の画面に遷移し、Youtubeの該当する動画一覧を表示するアプリを作成したいと思います。

##完成形
見づらくてすみません。
SrFpGfIqsMQGEJheybZv1614678956-1614678967.gif

##コードの解説
まず必要なものをインストールします。

今回はAlamofireSDWebImageです。
ということでPodfileに下記を追記してpod installを実行します。

  pod "Alamofire"
  pod "SDWebImage"

今回のサンプルアプリを作成するにあたり、
ファイルの構造などは書籍のものを参考に作成したいと思います。

書籍では、一つの画面につき一つのフォルダを作成し、その中にStoryboard、Model、View、Presenterを作成しています。
今回は、動画検索画面(SearchVideo)と動画一覧画面(VideoList)を作成します。

作成後はこのようになります。
スクリーンショット 2021-02-27 19.04.47.jpg

Main.storyboardからSearchVideo.storyboardに変更したので、
Info.plistの値を一部変更しなければ表示されません。

下記の二箇所を自分で決めたStoryboardの名前に変更してください。
スクリーンショット 2021-02-27 19.07.16.jpg
スクリーンショット 2021-02-27 19.07.34.jpg

また、各画面のUIは次のようになります。

SearchVideoViewController(Navigation Controllerに変更)
スクリーンショット 2021-02-27 19.09.35.jpg

VideoListViewController(tableViewのみでセルはカスタムセルを使用)
スクリーンショット 2021-02-27 19.10.21.jpg

カスタムセル
スクリーンショット 2021-02-27 19.18.27.jpg

とりあえず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に与えておきます。

SearchVideoViewController
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のメソッドが動きます。

ここら辺のプロトコルに関してですが、
デリゲートなどに関してある程度理解していないとごちゃごちゃになりそうなので、
わからなかったらデリゲートについて調べてください...。

SearchVideoPresenter
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に戻って処理を記述していきます。

追記した箇所のみ記載しています。

SearchVideoViewController
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:)
実行されるだけになります。

VideoListViewController
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()を実行するよう指示します。

VideoListPresenter
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とは関係ないので省略します。

VideoListModel
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でサムネの画像を取得しています。

VideoDetailCell
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でしっかりとコードが書けるようになったら覚えてみます!

以上、最後までご覧いただきありがとうございました。

8
4
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4