
MVVM(Model-View-ViewModel) with Swift application 3/3
This is the last post of creating an MVVM (Model-View-ViewModel) application with Swift series. In the first and second part, we went through the basic principles of MVVM, covered data binding pattern and also handled presenting errors to the user. At the end of the second post, we were able to download and present friend data to the user using table view. This last post adds functionality to the application. We’ll create, update and delete users from the backend and we will also pass a ViewModel into View when we are updating friends information.
As you might have guessed, we will continue to use the FriendService built in the first post: Server-side Swift, how to set up a backend. You can either set up the backend at your localhost by following the instructions in the post, or you can use the service I have running on Heroku. For example to list all friends you should use HTTP-get with this URL: http://friendservice.herokuapp.com/listFriend.
You can download the complete source code to the mobile client here: Friend. It contains complete code for the Friend application. But without further ado let’s get to it!
Adding a new Friend
1 2 3 4 5 6 7 8 9 10 11 12 13 |
protocol FriendViewModel { var title: String { get } var firstname: String? { get set } var lastname: String? { get set } var phonenumber: String? { get set } var showLoadingHud: Bindable { get } var updateSubmitButtonState: ((Bool) -> ())? { get set } var navigateBack: (() -> ())? { get set } var onShowError: ((_ alert: SingleButtonAlert) -> Void)? { get set } func submitFriend() } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
final class AddFriendViewModel: FriendViewModel { var title: String { return "Add Friend" } var firstname: String? { didSet { validateInput() } } var lastname: String? { didSet { validateInput() } } var phonenumber: String? { didSet { validateInput() } } let showLoadingHud: Bindable = Bindable(false) var updateSubmitButtonState: ((Bool) -> ())? var navigateBack: (() - ())? var onShowError: ((_ alert: SingleButtonAlert) -> Void)? private let appServerClient = AppServerClient() private var validInputData: Bool = false { didSet { if oldValue != validInputData { updateSubmitButtonState?(validInputData) } } } func submitFriend() { guard let firstname = firstname, let lastname = lastname, let phonenumber = phonenumber else { return } updateSubmitButtonState?(false) showLoadingHud.value = true appServerClient.postFriend(firstname: firstname, lastname: lastname, phonenumber: phonenumber) { [weak self] result in self?.showLoadingHud.value = false self?.updateSubmitButtonState?(true) switch result { case .success(_): self?.navigateBack?() case .failure(let error): let okAlert = SingleButtonAlert( title: error?.getErrorMessage() ?? "Could not connect to server. Check your network and try again later.", message: "Could not add \(firstname) \(lastname).", action: AlertAction(buttonTitle: "OK", handler: { print("Ok pressed!") }) ) self?.onShowError?(okAlert) } } } func validateInput() { let validData = [firstname, lastname, phonenumber].filter { ($0?.characters.count ?? 0) 1 } validInputData = (validData.count == 0) ? true : false } } private extension AppServerClient.PostFriendFailureReason { func getErrorMessage() -> String? { switch self { case .unAuthorized: return "Please login to add friends friends." case .notFound: return "Failed to add friend. Please try again." } } } |
FriendViewModel code
SingleButtonAlert
1 2 3 4 5 6 7 8 9 10 |
struct AlertAction { let buttonTitle: String let handler: (() -> Void)? } struct SingleButtonAlert { let title: String let message: String? let action: AlertAction } |
Send a new friend to the backend
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// MARK: - PostFriend enum PostFriendFailureReason: Int, Error { case unAuthorized = 401 case notFound = 404 } typealias PostFriendResult = EmptyResult typealias PostFriendCompletion = (_ result: PostFriendResult) -> Void func postFriend(firstname: String, lastname: String, phonenumber: String, completion: @escaping PostFriendCompletion) { let param = ["firstname": firstname, "lastname": lastname, "phonenumber": phonenumber] Alamofire.request("https://friendservice.herokuapp.com/addFriend", method: .post, parameters: param, encoding: JSONEncoding.default) .validate() .responseJSON { response in switch response.result { case .success: completion(.success) case .failure(_): if let statusCode = response.response?.statusCode, let reason = PostFriendFailureReason(rawValue: statusCode) { completion(.failure(reason)) } completion(.failure(nil)) } } } |
1 2 3 4 |
enum EmptyResult<u> where U: Error { case success case failure(U?) }</u> |
FriendViewController
FriendViewController works as a view for the friend creation part of the application. Create a new file inside the ViewController folder and put the code below inside it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
import UIKit import PKHUD final class FriendViewController: UIViewController { @IBOutlet weak var textFieldFirstname: UITextField! { didSet { textFieldFirstname.delegate = self textFieldFirstname.addTarget(self, action: #selector(firstnameTextFieldDidChange), for: .editingChanged) } } @IBOutlet weak var textFieldLastname: UITextField! { didSet { textFieldLastname.delegate = self textFieldLastname.addTarget(self, action: #selector(lastnameTextFieldDidChange), for: .editingChanged) } } @IBOutlet weak var textFieldPhoneNumber: UITextField! { didSet { textFieldPhoneNumber.delegate = self textFieldPhoneNumber.addTarget(self, action: #selector(phoneNumberTextFieldDidChange), for: .editingChanged) } } @IBOutlet weak var buttonSubmit: UIButton! var updateFriends: (() -> Void)? var viewModel: FriendViewModel? fileprivate var activeTextField: UITextField? override func viewDidLoad() { super.viewDidLoad() bindViewModel() } func firstnameTextFieldDidChange(textField: UITextField){ viewModel?.firstname = textField.text ?? "" } func lastnameTextFieldDidChange(textField: UITextField){ viewModel?.lastname = textField.text ?? "" } func phoneNumberTextFieldDidChange(textField: UITextField){ viewModel?.phonenumber = textField.text ?? "" } func bindViewModel() { title = viewModel?.title textFieldFirstname?.text = viewModel?.firstname ?? "" textFieldLastname?.text = viewModel?.lastname ?? "" textFieldPhoneNumber?.text = viewModel?.phonenumber ?? "" viewModel?.showLoadingHud.bind { PKHUD.sharedHUD.contentView = PKHUDSystemActivityIndicatorView() $0 ? PKHUD.sharedHUD.show() : PKHUD.sharedHUD.hide() } viewModel?.updateSubmitButtonState = { [weak self] state in self?.buttonSubmit?.isEnabled = state } viewModel?.navigateBack = { [weak self] in self?.updateFriends?() let _ = self?.navigationController?.popViewController(animated: true) } viewModel?.onShowError = { [weak self] alert in let alertController = UIAlertController(title: alert.title, message: alert.message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: alert.action.buttonTitle, style: .default, handler: { _ in alert.action.handler?() })) self?.present(alertController, animated: true, completion: nil) } } } // MARK: - Actions extension FriendViewController { @IBAction func rootViewTapped(_ sender: Any) { activeTextField?.resignFirstResponder() } @IBAction func submitButtonTapped(_ sender: Any) { viewModel?.submitFriend() } } // MARK: - UITextFieldDelegate extension FriendViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return false } func textFieldDidBeginEditing(_ textField: UITextField) { activeTextField = textField } func textFieldDidEndEditing(_ textField: UITextField) { activeTextField = nil } } |
Connecting the ViewModel to the View
Navigate back
1 2 3 4 |
viewModel?.navigateBack = { [weak self] in self?.updateFriends?() let _ = self?.navigationController?.popViewController(animated: true) } |
Presenting error dialog to the user
1 2 3 4 5 6 7 8 9 |
viewModel?.onShowError = { [weak self] alert in let alertController = UIAlertController(title: alert.title, message: alert.message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: alert.action.buttonTitle, style: .default, handler: { _ in alert.action.handler?() })) self?.present(alertController, animated: true, completion: nil) } |
Open the AddFriendView
1 2 3 4 5 6 7 8 9 |
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "friendsToAddFriend", let destinationViewController = segue.destination as? FriendViewController { destinationViewController.viewModel = AddFriendViewModel() destinationViewController.updateFriends = { [weak self] in self?.viewModel.getFriends() } } } |
Updating a friend
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
final class UpdateFriendViewModel: FriendViewModel { var friend: Friend var title: String { return "Update Friend" } var firstname: String? { didSet { validateInput() } } var lastname: String? { didSet { validateInput() } } var phonenumber: String? { didSet { validateInpu } } var validInputData: Bool = false { didSet { if oldValue != validInputData { updateSubmitButtonState?(validInputData) } } } var updateSubmitButtonState: ((Bool) -> ())? var navigateBack: (() -> ())? var onShowError: ((_ alert: SingleButtonAlert) -> Void)? let showLoadingHud = Bindable(false) let appServerClient = AppServerClient() init(friend: Friend) { self.friend = friend self.firstname = friend.firstname self.lastname = friend.lastname self.phonenumber = friend.phonenumber } func submitFriend() { guard let firstname = firstname, let lastname = lastname, let phonenumber = phonenumber else { return } updateSubmitButtonState?(false) showLoadingHud.value = true appServerClient.patchFriend(firstname: firstname, lastname: lastname, phonenumber: phonenumber, id: friend.id) { [weak self] result in self?.updateSubmitButtonState?(true) self?.showLoadingHud.value = false switch result { case .success(_): self?.navigateBack?() case .failure(let error): let okAlert = SingleButtonAlert( title: error?.getErrorMessage() ?? "Could not connect to server. Check your network and try again later.", message: "Failed to update information.", action: AlertAction(buttonTitle: "OK", handler: { print("Ok pressed!") }) ) self?.onShowError?(okAlert) } } } func validateInput() { let validData = [firstname, lastname, phonenumber].filter { ($0?.characters.count ?? 0) > 1 } validInputData = (validData.count == 0) ? true : false } } fileprivate extension AppServerClient.PatchFriendFailureReason { func getErrorMessage() -> String? { switch self { case .unAuthorized: return "Please login to update friends friends." case .notFound: return "Failed to update friend. Please try again." } } } |
Send friend information to the backend
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// MARK: - PatchFriend enum PatchFriendFailureReason: Int, Error { case unAuthorized = 401 case notFound = 404 } typealias PatchFriendResult = Result<Friend, PatchFriendFailureReason> typealias PatchFriendCompletion = (_ result: PatchFriendResult) -> Void func patchFriend(firstname: String, lastname: String, phonenumber: String, id: Int, completion: @escaping PatchFriendCompletion) { let param = ["firstname": firstname, "lastname": lastname, "phonenumber": phonenumber] Alamofire.request("https://friendservice.herokuapp.com/editFriend/\(id)", method: .patch, parameters: param, encoding: JSONEncoding.default) .validate() .responseJSON { response in switch response.result { case .success: guard let friendJSON = response.result.value as? JSON, let friend = Friend(json: friendJSON) else { completion(.failure(nil)) return } completion(.success(payload: friend)) case .failure(_): if let statusCode = response.response?.statusCode, let reason = PatchFriendFailureReason(rawValue: statusCode) { completion(.failure(reason)) } completion(.failure(nil)) } } } |
Opening update friend view
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
if segue.identifier == "friendToUpdateFriend", let destinationViewController = segue.destination as? FriendViewController, let indexPath = tableView.indexPathForSelectedRow { switch viewModel.friendCells.value[indexPath.row] { case .normal(let viewModel): destinationViewController.viewModel = UpdateFriendViewModel(friend:viewModel.friendItem) destinationViewController.updateFriends = { [weak self] in self?.viewModel.getFriends() } case .empty, .error: // nop break } } |
Deleting a friend
1 2 3 4 5 6 7 8 9 |
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return true } override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { viewModel.deleteFriend(at: indexPath.row) } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
func deleteFriend(at index: Int) { switch friendCells.value[index] { case .normal(let vm): appServerClient.deleteFriend(id: vm.friendItem.id) { [weak self] result in switch result { case .success(_): self?.getFriends() case .failure(let error): let okAlert = SingleButtonAlert( title: error?.getErrorMessage() ?? "Could not connect to server. Check your network and try again later.", message: "Could not remove \(vm.fullName).", action: AlertAction(buttonTitle: "OK", handler: { print("Ok pressed!") }) ) self?.onShowError?(okAlert) } } default: // nop break } } |
1 |
var onShowError: ((_ alert: SingleButtonAlert) -> Void)? |
1 2 3 4 5 6 7 8 9 |
viewModel.onShowError = { [weak self] alert in let alertController = UIAlertController(title: alert.title, message: alert.message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: alert.action.buttonTitle, style: .default, handler: { _ in alert.action.handler?() })) self?.present(alertController, animated: true, completion: nil) } |
1 2 3 4 5 6 7 8 9 10 11 |
// MARK: - AppServerClient.DeleteFriendFailureReason fileprivate extension AppServerClient.DeleteFriendFailureReason { func getErrorMessage() -> String? { switch self { case .unAuthorized: return "Please login to remove friends." case .notFound: return "Friend not found." } } } |
Deleting friend from the server
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// MARK: - DeleteFriend enum DeleteFriendFailureReason: Int, Error { case unAuthorized = 401 case notFound = 404 } typealias DeleteFriendResult = EmptyResult typealias DeleteFriendCompletion = (_ result: DeleteFriendResult) -> Void func deleteFriend(id: Int, completion: @escaping DeleteFriendCompletion) { Alamofire.request("https://friendservice.herokuapp.com/editFriend/\(id)", method: .delete, parameters: nil, encoding: JSONEncoding.default) .validate() .responseJSON { response in switch response.result { case .success: completion(.success) case .failure(_): if let statusCode = response.response?.statusCode, let reason = DeleteFriendFailureReason(rawValue: statusCode) { completion(.failure(reason)) } completion(.failure(nil)) } } } |
Application is finished
Read your articles about MVVM and it was great help to me to understand this topic and implement into my project. There is a lot of resources on other third part libraries but none of them explain core concept of MVVM explained in code. This is a 5 star article. Thank you a lot for this awesome blog, keep up with good work 🙂 cheers
Thanks Filip!
I am glad that you liked the post and that I was able to help you 🙂 cheers
Thanks. MVVM Like MVP, but different between theirs that in MVP all logic we holding in Presenter, and UI implementation in ViewController. In MVVM logic and UI implementation we holding in ViewModel.
If I understand right.
Hi Sergey,
I haven’t used MVP in any of my projects but what I have understood that is pretty much how it works.
MVVM is very close to MVP. ViewModel holds all the business logic and ViewController acts as a View and implements the UI. As I said, I am not that familiar with MVP so I cannot tell you what are the fundamental differences between the two, other than the 2 letters in the name 🙂
Very good article!!! This helped me a lot in understanding MVVM.
Thanks Michael, I am happy that I could help you! 🙂
thanks for the artical. Hers i had one doubt how to assign AddfriendViewmodel with in the FriendViewController in the example your passing from different controller but i need to assign with in the view controller.
help me.
Hi Karthik,
If I understand your problem correctly you can just create a new viewmodel inside your view controller like this:
final class ViewController: UIViewController {
let viewModel = AddFriendViewModel()
…
}
Does that work in your situation?
hi jimmy it’s working fine,
how to search the elements in tableview using mvvm , can you please help me out.
Hi Karthik,
Add a searchbar to the tableview. Then implement the callback methods, in the viewcontroller, that get’s called when ever searchbar input changes. When user types in a new character call a
‘sortData(input: String)’ function in the view model from the delegate method. Inside it filter the array that holds your tableview data with the input the user typed.
Hope this helps! 🙂
can you share the example (search )please with your friend application .
I’ll put in on my todo list, but I have many things to write about before that. I think you can find examples about searchbar tableview filtering after a little googling. Just implement the filtering part in the view model side and you should be good to go 🙂
hi jimmy , it was nice tutorial, i am new to ios development. i am facing issues in search with tableview. can you share any code for search table view with mvvm. i am using your application as reference.
It will help me lot if you share search with mvvm.
Thanks