✅ 이번시간에는 BookReview를 MVP로 작성한 코드에 대해서 리뷰해보자
여기 포스팅에는 XCTest에 대한 부분은 제외하고 정리했음. 다음 포스팅에 이 부분도 있으니 확인해보기
✅ 파일의 구조
파일의 구조를 살펴보면 크게 특별한 것은 없어. MVP모델이라서 ViewModel이 보이지 않는 특이점은 있지만..!
그리고 Networking작업을 매번 어떤 파일에서 어떻게 처리할지 고민이었는데, 이번에 보니까 manager로 처리하더라.
물론 UserDefault도!!
그러니까 Manager를 사용하는 습관을 기르자..!
✅ ReviewListViewController.swift
여기서 유심히 봐야하는 점음 property를 생성할때 tableViewDelegate가 어디에 위치하는지 보자.
//
// ViewController.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/07.
//
import UIKit
import SnapKit
final class ReviewListViewController: UIViewController {
private lazy var presenter = ReviewListPresenter(viewController: self)
private lazy var tableView : UITableView = {
let tableView = UITableView()
tableView.dataSource = presenter // presenter에 위임한다.
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
presenter.viewDidLoad()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
presenter.viewWillAppear()
}
}
extension ReviewListViewController: ReviewListProtocol {
func setupNavigationBar() {
navigationItem.title = "도서 리뷰"
navigationController?.navigationBar.prefersLargeTitles = true
let rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(didTapRightBarButtonItem)
)
navigationItem.rightBarButtonItem = rightBarButtonItem
}
func setupViews() {
view.addSubview(tableView)
tableView.snp.makeConstraints {
$0.edges.equalToSuperview()
}
}
func presentToReviewWriteViewController() {
let vc = UINavigationController(rootViewController: ReviewWriteViewController()) // 루트뷰를 바꾸는거라서 stack의 결과다 달라진다.
vc.modalPresentationStyle = .fullScreen
present(vc,animated: true)
}
func reloadTableView() {
tableView.reloadData()
print("새로운 테이블 뷰 리로드")
}
}
extension ReviewListViewController {
@objc func didTapRightBarButtonItem() {
presenter.didTapRightBarButtonItem()
}
}
/*
MVP에서는
dataSource는 presenter에서 처리한다.
데이터를 구성하여 변경하고 호출해주는 것이기 때문에
MVP에서는 presenter가 무조건 viewController 호출을 담당하고
viewController은 실행을 담당한다.
이벤트를 뷰 컨트롤러 내에서 직접 전달(호출)하면 안된다.
*/
👉 navigationController?.navigationBar.prefersLargeTitles = true
func presentToReviewWriteViewController() {
let vc = UINavigationController(rootViewController: ReviewWriteViewController()) // 루트뷰를 바꾸는거라서 stack의 결과다 달라진다.
vc.modalPresentationStyle = .fullScreen
present(vc,animated: true)
}
이 방식을 사용하면 dismiss 상황에서도 원래 코드로 돌아오며, 그리고 화면전환 방식에서 present로 더 다양한 옵션을 사용할 수 있음!
🟠 ReviewListPresenter.swift
//
// BookReviewPresenter.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/07.
//
import Foundation
import UIKit
import Kingfisher
protocol ReviewListProtocol {
func setupNavigationBar()
func setupViews()
func presentToReviewWriteViewController()
func reloadTableView()
}
final class ReviewListPresenter: NSObject { // NSObject 없다면 UITableViewDataSource 상속 시에 그려야 하는 것들이 많다.
private let viewController: ReviewListProtocol
private let userDefaultsManager: UserDefaultsManagerProtocol
private var review: [BookReview] = []
init(
viewController: ReviewListProtocol,
userDefaultsManager: UserDefaultsManagerProtocol = UserDefaultsManager()
) {
self.viewController = viewController
self.userDefaultsManager = userDefaultsManager
}
func viewDidLoad() {
viewController.setupNavigationBar()
viewController.setupViews()
}
func viewWillAppear() {
// TODO: UserDefault 내용 업데이트 하기
review = userDefaultsManager.getReviews()
viewController.reloadTableView()
}
func didTapRightBarButtonItem() {
viewController.presentToReviewWriteViewController()
}
}
extension ReviewListPresenter: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
review.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) // 서브타이틀이 있는 셀 스타일 사용
let review = review[indexPath.row]
cell.textLabel?.text = review.title
cell.detailTextLabel?.text = review.contents
//cell.imageView?.kf.setImage(with: review.imageURL) // 이렇게 사용하면 리로드 중에 그릴 수 없어서 한번 더 레이아웃을 조정해야함
cell.imageView?.kf.setImage(with: review.imageURL, placeholder: .none) { _ in
cell.setNeedsLayout() // 한번 더 레이아웃 업데이트
}
cell.selectionStyle = .none
return cell
}
}
👉 setNeedsLayouts()
apple document를 살펴보니 레이아웃을 재조정하게끔 trigger하는 함수라고 합니다
cell.imageView?.kf.setImage(with: review.imageURL, placeholder: .none) { _ in
cell.setNeedsLayout() // 한번 더 레이아웃 업데이트
}
이와 관련해서는 다른 분께서 정리를 잘 해두신 블로그가 있어서 참고하기로..!
https://baked-corn.tistory.com/105
🟠 ReviewWriteViewController.swift
//
// ReviewWriteViewController.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/07.
//
import Foundation
import UIKit
import Kingfisher
import Then
final class ReviewWriteViewController: UIViewController {
private lazy var presenter = ReviewWritePresenter(viewController: self)
private lazy var bookTitleButton: UIButton = {
let button = UIButton()
button.setTitle("책 제목", for: .normal)
button.setTitleColor(.tertiaryLabel, for: .normal)
button.contentHorizontalAlignment = .left
button.titleLabel?.font = .systemFont(ofSize: 23.0, weight: .bold)
button.addTarget(self, action: #selector(didTapBookTitleButton), for: .touchUpInside)
return button
}()
private lazy var contentsTextView: UITextView = {
let textView = UITextView()
textView.textColor = .tertiaryLabel // 옅은 회색
textView.text = presenter.contentTextViewPlaceHolderText // lazy var라서 가능한 코드!
textView.font = .systemFont(ofSize: 16.0, weight: .medium)
textView.delegate = self
return textView
}()
private lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true // 이 속성은 이미지가 화면보다 크면 넘쳐흐르는 것을 막는다.
imageView.backgroundColor = .secondarySystemBackground
return imageView
}()
override func viewDidLoad() {
super.viewDidLoad()
presenter.viewDidLoad()
}
}
extension ReviewWriteViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
guard textView.textColor == .tertiaryLabel else { return }
textView.text = nil
textView.textColor = .black
}
}
extension ReviewWriteViewController: ReviewWriteProtocol {
func setUpNavigationBar() {
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .close,
target: self,
action: #selector(didTapLeftBarButton)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .save,
target: self,
action: #selector(didTapRightBarButton)
)
}
func showCloseAlertController() {
let alertController = UIAlertController(
title: "작성중인 메시지가 있습니다. 정말 닫으시겠습니까?",
message: nil,
preferredStyle: .alert
)
let closeAction = UIAlertAction(title: "닫기", style: .destructive) { [weak self] _ in
// 클로저 내에서 weak self는 개발자마다 아직도 의견이 분분하나, 혹시 모를 메모리 누수를 막을 수 있어서 사용하는거 권장
self?.dismiss(animated: true)
}
let cancelAction = UIAlertAction(title: "취소", style: .cancel)
[closeAction, cancelAction].forEach {
alertController.addAction($0)
}
present(alertController, animated: true)
}
func close() {
dismiss(animated: true)
}
func setupViews() {
view.backgroundColor = .systemBackground
[bookTitleButton, contentsTextView, imageView]
.forEach { view.addSubview($0) }
bookTitleButton.snp.makeConstraints {
$0.leading.equalToSuperview().inset(20.0)
$0.trailing.equalToSuperview().inset(20.0)
$0.top.equalTo(view.safeAreaLayoutGuide).inset(20.0)
}
contentsTextView.snp.makeConstraints {
$0.leading.equalToSuperview().inset(16.0)
$0.trailing.equalToSuperview().inset(16.0)
$0.top.equalTo(bookTitleButton.snp.bottom).offset(16.0)
}
imageView.snp.makeConstraints {
$0.leading.equalTo(contentsTextView.snp.leading)
$0.trailing.equalTo(contentsTextView.snp.trailing)
$0.top.equalTo(contentsTextView.snp.bottom).offset(16.0)
$0.height.equalTo(200.0)
$0.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
func presentToSearchBookViewController() {
let vc = UINavigationController(rootViewController: SearchBookViewContoller(searchBookDelegate: presenter))
present(vc, animated: true)
}
func updateViews(title: String, imageURL: URL?) {
bookTitleButton.setTitle(title, for: .normal)
bookTitleButton.setTitleColor(.label, for: .normal)
imageView.kf.setImage(with: imageURL)
}
}
private extension ReviewWriteViewController {
@objc func didTapLeftBarButton() {
presenter.didTapLeftBarButton()
}
@objc func didTapRightBarButton() {
presenter.didTapRightBarButton(contentsText: contentsTextView.text)
}
@objc func didTapBookTitleButton() {
presenter.didTapBookTitleButton()
}
}
👉 textView는 placeholder를 갖고 있지 않아서, 글자의 색으로 판단하는 코드인데, 유의깊게 보자
extension ReviewWriteViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
guard textView.textColor == .tertiaryLabel else { return }
textView.text = nil
textView.textColor = .black
}
}
🟠 ReviewWritePresenter.swfit
//
// ReviewWritePresenter.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/07.
//
import Foundation
protocol ReviewWriteProtocol {
func setUpNavigationBar()
func showCloseAlertController()
func close()
func setupViews()
func presentToSearchBookViewController()
func updateViews(title: String, imageURL: URL?)
}
final class ReviewWritePresenter {
private let viewController: ReviewWriteProtocol
private let userDefaultsManager: UserDefaultsManagerProtocol
var book: Book? // private면 좋으나, test작성에서 optional이면 확인을 못하는데, 그렇다면 아이템을 주기 위해 접근이 필요하다. 그래서 private를 해지함
let contentTextViewPlaceHolderText = "내용을 입력해주세요."
init(
viewController: ReviewWriteProtocol,
userDefaultsManager: UserDefaultsManagerProtocol = UserDefaultsManager()
) {
self.viewController = viewController
self.userDefaultsManager = userDefaultsManager
}
func viewDidLoad() {
viewController.setUpNavigationBar()
viewController.setupViews()
}
func didTapLeftBarButton() {
viewController.showCloseAlertController()
}
func didTapRightBarButton(contentsText: String?) {
// TODO: UserDefault에 유저가 작성한 도서리뷰 저장하기
guard
let book = book,
let contentsText = contentsText,
contentsText != contentTextViewPlaceHolderText
else { return }
let bookReview = BookReview(
title: book.title,
contents: contentsText,
imageURL: book.imageURL
)
userDefaultsManager.setReview(bookReview)
viewController.close()
}
func didTapBookTitleButton() {
viewController.presentToSearchBookViewController()
}
}
extension ReviewWritePresenter: SearchBookDelegate {
func selectBook(_ book: Book) {
self.book = book
viewController.updateViews(title: book.title, imageURL: book.imageURL)
print("🟠 \(book.title)")
}
}
🟠 SearchBookController.swift
여긴 delegate를 사용해야해서 조금 더 복잡한 느낌..?
//
// SearchViewController.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/07.
//
import Foundation
import UIKit
import SnapKit
final class SearchBookViewContoller: UIViewController {
private lazy var presenter = SearchBookPresenter(
viewContoller: self,
delegate: searchBookDelegate
)
private let searchBookDelegate: SearchBookDelegate
private lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.delegate = presenter
tableView.dataSource = presenter
return tableView
}()
init(searchBookDelegate: SearchBookDelegate) {
self.searchBookDelegate = searchBookDelegate
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
presenter.viewDidLoad()
}
}
extension SearchBookViewContoller: SearchBookProtocol {
func setupViews() {
view.backgroundColor = .systemBackground
let searchController = UISearchController()
searchController.obscuresBackgroundDuringPresentation = false // 서치바 활성화되면 서치바 외에 화면 어두워지는거 false처리
searchController.searchBar.delegate = presenter // delegate를 self가 아닌 presenter로 하는 이유는 데이터를 가지고 그리는 것은 결과적으로 presenter가 하기 때문에 presenter에 위임해야 한다.
navigationItem.searchController = searchController
view.addSubview(tableView)
tableView.snp.makeConstraints {
$0.edges.equalToSuperview()
}
}
func dismiss() {
navigationItem.searchController?.dismiss(animated: true) // 서치바를 닫고
dismiss(animated: true) // 화면을 내려서 터치 한번으로 끝내기.
}
func reloadView() {
tableView.reloadData()
}
}
특별하게 위에 볼 코드는 없었고 그나마 SeachBar.delegate = presenter로 위임하는 코드가 약간 주의깊게 봐야하는 코드!
🟠 SearchBookPresenter.swift
//
// SearchBookPresenter.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/07.
//
import Foundation
import UIKit
protocol SearchBookProtocol {
func setupViews()
func dismiss()
func reloadView()
}
protocol SearchBookDelegate {
func selectBook(_ book: Book)
}
final class SearchBookPresenter: NSObject {
private let viewContoller: SearchBookProtocol
private let bookSearchManager = BookSearchManager()
private let delegate: SearchBookDelegate
private var books: [Book] = []
init(viewContoller: SearchBookViewContoller,
delegate: SearchBookDelegate
) {
self.viewContoller = viewContoller
self.delegate = delegate
}
func viewDidLoad() {
viewContoller.setupViews()
}
}
extension SearchBookPresenter: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let searchText = searchBar.text else { return }
bookSearchManager.request(from: searchText) { [weak self] newBooks in
self?.books = newBooks
self?.viewContoller.reloadView()
}
}
}
extension SearchBookPresenter: UITableViewDelegate {
}
extension SearchBookPresenter: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
books.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = books[indexPath.row].title
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let selectedBook = books[indexPath.row]
delegate.selectBook(selectedBook)
viewContoller.dismiss()
}
}
여기서는 BookManager를 통해서 query요청하는 로직을 꼼꼼하게보자
🟠 Book.swift
//
// Book.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/08.
//
import Foundation
struct Book: Codable {
let title: String
private let image: String?
var imageURL: URL? { URL(string: image ?? "" ) }
init(title: String, imageURL: String?) {
self.title = title
self.image = imageURL
}
}
여긴 좀 유심히 봐야하겠다 싶었음. 왜나하면 Model에 init키워드를 사용했다는 점인데, 예전에는 이런 경운 EMPTY 변수를 하나 선언해서 사용했었음. 하지만 더욱 좋은 코드를 위해서는 EMPTY가 아니라 그냥 init을 구현해서 사용하는 것이겠지?
그 이유는 유닛 테스트 과정에서 이렇게 사용해야 접근해서 사용할 수 있다는 것임.
옵셔널 값을 넣어주기 위해서!!
🟠 BookSearchManager.swift
//
// BookSearchManager.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/08.
//
import Foundation
import Alamofire
struct BookSearchManager {
func request(from keyword: String, completionHandler: @escaping (([Book]) -> Void)) {
guard let url = URL(string: "https://openapi.naver.com/v1/search/book.json") else { return }
let parameters = BookSearchRequestModel(query: keyword)
let headers: HTTPHeaders = [
"X-Naver-Client-Id": "{YOUR API KEY}",
"X-Naver-Client-Secret": "{YOUR API KEY}"
]
AF
.request(url, method: .get, parameters: parameters, headers: headers)
.responseDecodable(of: BookSearchResponseModel.self) { response in // decodable로 받은 데이터를 파싱. 타입은 of안에 있는 모델로
switch response.result {
case .success(let result):
completionHandler(result.items) // complectionHandler가 있어야 받는 쪽에서 바로 사용할 수 있도록 하기 위함 -> 테이블 뷰를 바로 그릴 수 있게끔
case .failure(let error):
print(error.localizedDescription)
}
}
.resume()
}
}
매니저 부분이 조금 신기했는데, 이렇게 작성하면 디코딩이랑 핸들러 처리까지 너무 깔끔해서 배워가는 코드이다..!
🟠 UserDefaultsManager.swift
//
// UserDefaultsManager.swift
// BookReview
//
// Created by Hamlit Jason on 2022/01/09.
//
import Foundation
protocol UserDefaultsManagerProtocol {
func getReviews() -> [BookReview]
func setReview(_ newValue: BookReview)
}
struct UserDefaultsManager: UserDefaultsManagerProtocol {
enum Key: String {
case review
}
func getReviews() -> [BookReview] {
guard let data = UserDefaults.standard.data(forKey: Key.review.rawValue) else { return [] }
return (try? PropertyListDecoder().decode([BookReview].self, from: data)) ?? []
}
func setReview(_ newValue: BookReview) {
var currentReviews: [BookReview] = getReviews()
currentReviews.insert(newValue, at: 0)
UserDefaults.standard.setValue(
try? PropertyListEncoder().encode(currentReviews),
forKey: Key.review.rawValue
)
}
}
이 코드도 상당히 유심히 보게 되었는데...! 그 이유는 바로..! 값이 인코딩이랑 디코딩하는 부분도 처음봐서 신기했고, insert하는 것도 그렇고 배울점이 많은 코드라서..!
'Archive > 패캠(초격차)' 카테고리의 다른 글
part5. (ch6) SwiftLint 알아보기 (0) | 2022.01.17 |
---|---|
part5. (ch5) BookReview XCTest 코드리뷰 (0) | 2022.01.09 |
part5. (ch5) XCTest (0) | 2022.01.09 |
part5. (ch5) forEach를 이용하여 addSubView (0) | 2022.01.07 |
part 5. (ch5) MVP 패턴의 기본모습 (0) | 2022.01.07 |