How to use Combine with MVVM for UIKit and SwiftUI – A practical example project with fetching tweet

Greetings, fellow developers! 🖥️ In this blog post, we’re about to embark on a technical journey that holds immense potential for your app architecture skills. Our focus? The seamless integration of Combine and MVVM—two pillars of modern application development. Our goal? To build a practical example project centered around fetching tweets, designed for both UIKit and SwiftUI environments. 🐦📦

In this tutorial, I want to show you how to create data streams with Combine. You will see an example project that implements MVVM. A Twitter example.

For those keen on expanding their knowledge further, The Swifty Combine Framework Course offers an in-depth exploration of Combine and MVVM, ensuring you’re well-equipped to tackle real-world scenarios. So, if you’re ready to elevate your app architecture game, let’s delve into the technical realm of Combine with MVVM. 💻🏗️

https://youtu.be/O8vY5LUDagY

Project Set-up with STTwitter

If you want to  use STTwitter, you can use CocoPods with:

				
					pod 'STTwitter'
pod 'LBTATools'
pod 'SDWebImage'
				

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.

				
					I am first going to show you how to fetch tweets with STTwitter (https://github.com/nst/STTwitter). Then we will build the view model and last I will show you how to connect the view model with UIKit and SwiftUI.

				

Model definition for Tweets

We need two different model definitions for Tweet and User

				
					import Foundation
struct Tweet: Codable {       
   let text: String        
   let user: User       
}
struct User: Codable {        
   let name, profileImageUrl: String
}

				

We will use a different struct to store the secret and key for the Twitter API. You can request a secrete and key at https://apps.twitter.com/.

				
					import Foundation
import STTwitter
import Combine 
struct TwitterAPI {        
   let key = "xxxxxxxxxxxxxx" // use your own key here    
   let key = "xxxxxxxxxxxxxx" // use your own secrete here    
   var api: STTwitterAPI        
   init() {        
      api = STTwitterAPI(appOnlyWithConsumerKey: key, consumerSecret: secret)    
   }    
   func verifyCredentials() -> Future<(String?, String?), Error> {              
       Future { promise in                        
           api.verifyCredentials(userSuccessBlock: { (username, userId) in                       
               promise(.success((username, userId)))                        
           }, errorBlock: { (err) in              
               promise(Result.failure(err!))                    
          })              
      }       
   }    
   func getSearchTweets(with query: String) -> AnyPublisher<[Tweet], Error> {        
      Future { promise in            
         api.getSearchTweets(withQuery: query, successBlock: { (data, res) in
               promise(.success(res))           
         }, errrBlock: { (err) in            
               promise(.failure(err!))            
         })
       }       
       .compactMap({ $0 })        
       .tryMap { try JSONSerialization.data(withJSONObject: $0,  options: .prettyPrinted)  }        
       .decode(type: [Tweet].self, decoder: jsonDecoder)        
       .eraseToAnyPublisher()    
  } 
   var jsonDecoder: JSONDecoder {        
      let decoder = JSONDecoder()        
      decoder.keyDecodingStrategy = .convertFromSnakeCase        
      return decoder    
   }
}
				

We want to use these 2 API calls with Combine.

				
					api.getSearchTweets(withQuery: query, successBlock: { (data, res) in          
   //....
}, errorBlock: { (err) in        
   //....
} 
api.verifyCredentials(userSuccessBlock: { (username, userId) in        
   //.... 
}, errorBlock: { (err) in        
   //....
}
				

If you want to use ad-hoc callbacks with Combine, you can wrap them in Future publishers. This works if you only expect one-time callbacks. For callbacks or listeners that will be continuously called multiple times, it is better to define your own publisher.     This struct serves as the data provider.  You can extend this to use dependency injection.

View Model with CurrentValueSubject or @Published

In the view model, we hold all data that is the array of tweets and the search text term. We will also use it to fetch the tweets. The Combine data streams are set-up in the initializer of the view model.

Here is the implementation for UIKit with CurrentValueSubject, whereas SwiftUI works best with @Published. Please note that @Published probably uses a CurrentValueSubject as a publisher which you can access with the $ sign.

				
					import Foundation
import Combine 
class TwitterViewModel {    
   // SwiftUI implementation with @Published
   // @Published var tweets = [Tweet]()
   // @Published var searchText = "Paul Hudson"
   //UIKit implementation with CurrentValueSubject
   let tweets = CurrentValueSubject<[Tweet], Never>([Tweet]())
   let searchText = CurrentValueSubject<String, Never>("Paul Hudson")   
   let twitterAPI = TwitterAPI()    
   var subsciptions = Set<AnyCancellable>()        
   let isTwitterConnected = CurrentValueSubject<Bool, Never>(false)          
   let errorMessage = CurrentValueSubject<String?, Never>(nil)        
   init() {        
      twitterAPI.verifyCredentials()            
         .sink { [unowned self] (completion) in            
            switch completion {           
            case .failure(let error):                                
               self.errorMessage.send(error.localizedDescription)                           
            case .finished: return            
            }        
         } receiveValue: { [unowned self] (username, id) in                           
            print("success")            
            self.isTwitterConnected.send(true)            
            self.setupSearch()        
         }.store(in: &subsciptions)    
   }       
   func setupSearch() {    
      // $searchText     
      // with @Published         
      searchText
         .removeDuplicates()            
         .debounce(for: .milliseconds(500), scheduler: RunLoop.main)            
         .map { [unowned self] (searchText) -> AnyPublisher<[Tweet], Never> in
               self.twitterAPI.getTweets(with: searchText)                    
                  .catch { (error) in                       
                      Just([Tweet]())                    
                  }                    
                  .eraseToAnyPublisher()            
         }            
         .switchToLatest()            
         .sink { [unowned self] (tweets) in                         
            self.tweets.send(tweets)            
         }.store(in: &subsciptions)  
   }
}
				

FlatMap, SwitchToLatest, and error handling with catch

A data stream is set up from the searchText publisher that fetches tweets. Because we want to use the getSearchTweets function that returns an AnyPublisher with the tweets, we need to have a data stream inside a stream. You have 2 possibilities:

  • flatmap: executes mulitple data streams in parallel and publishes all values downstream
  • map + switchToLatest: execute only the data stream that was initiated the latest

For this example, we only want to show the tweets for the latest search request. All earlier search request are discarded. Therefor switchToLast is a good fit for our example.

In order to catch errors in a Combine data stream we can use the catch operator. The catch operator expects a replacement publisher, that will be used in place of an occuring error. In this example we use a Just publisher, that just publishes a value. We publish an empty array of tweets with  Just([Tweet]()). The tweets property is then also set to an empty array. If you don’t want to pass an actual value or just ignore the error, you can use an Empty publisher, which does not pass a value und only completes the stream.

				
					    .catch { (error) in            
        //.  Just([Tweet]())            
        Empty(completeImmediately: true)    
    }
				

Integrating with an UIKit project

For UIKit the implementation is more work than for SwiftUI. First (1) we create a data stream from the UITextField (search field) to update the connected CurrentValueSubject search text in the view model. Every time the user types a new letter in the textbox a new value is passed to the CurrentValueSubject and passed in the data stream that fetches tweets. We create another data stream from the view model back to the UIViewController (2). When the tweet CurrentValueSubject has a new value, we call tableView.reloadData().

				
					import UIKit
import SDWebImage //loading image url
import LBTATools //stack easy layout
import Combine
class ViewController: UITableViewController {        
   let twitterViewModel = TwitterViewModel()
   let searchController = UISearchController(searchResultsController: nil)    
   var subscriptions = Set<AnyCancellable>()     
   override func viewDidLoad() {        
      super.viewDidLoad()                
      setupSearchBarListeners()        
      navigationItem.searchController = searchController        
      navigationController?.navigationBar.prefersLargeTitles = true        
      navigationItem.title = "Search Tweets"        
      searchController.obscuresBackgroundDuringPresentation = false    
   }
    fileprivate func setupSearchBarListeners() {
       // 1. data stream form UITextField to bind property in view model
        let publisher = NotificationCenter.default
                    .publisher(for: UISearchTextField.textDidChangeNotification,
                            object: searchController.searchBar.searchTextField)
        publisher
            .compactMap {
                ($0.object as? UISearchTextField)?.text
            }
            .sink { [unowned self] (str) in
                self.twitterViewModel.searchText.send(str)
            }.store(in: &subscriptions)
        // 2. update UITableView whenever tweets are updated
        twitterViewModel.tweets.sink { [unowned self] (_) in
            self.tableView.reloadData()
        }.store(in: &subscriptions)
    }
   override func tableView(_ tableView: UITableView,
                    numberOfRowsInSection section: Int) -> Int {
            return twitterViewModel.tweets.value.count
   }
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = TweetCell(style: .subtitle, reuseIdentifier: nil)
        let tweet = twitterViewModel.tweets.value[indexPath.row]
        cell.tweetTextLabel.text = tweet.text
        cell.nameLabel.text = tweet.user.name
        cell.profileImageView.sd_setImage(with: URL(string:tweet.user.profileImageUrl.replacingOccurrences(of: "http", with: "https")))
        return cell
    }
} 
				
				
					    class TweetCell: UITableViewCell {
        let nameLabel = UILabel(text: "Username",
                                                font: .boldSystemFont(ofSize: 16),
                                                textColor: .black)
        let tweetTextLabel = UILabel(text: "Tweet Text MultiLines",
                                                 font: .systemFont(ofSize: 16),
                                                textColor: .darkGray,
                                                numberOfLines: 0)
        let profileImageView = UIImageView(image: nil,
                                                          contentMode: .scaleAspectFill)
        override init(style: UITableViewCell.CellStyle,
                        reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            profileImageView.layer.cornerRadius = 8
            profileImageView.layer.borderWidth = 0.5
            hstack(
                profileImageView.withSize(.init(width: 50, height: 50)),
                stack(nameLabel, tweetTextLabel, spacing: 8),
                spacing: 20,
                alignment: .top
            ).withMargins(.allSides(24))
        }
        required init?(coder: NSCoder) {
            fatalError()
        }
    }
				

Integrating with a SwiftUI project

In SwiftUI we can use the binding between @Published properties in ObservableObject and views. For example, we can bind the search text to a text field.

				
					import SwiftUI
struct ContentView: View {   
    @StateObject var tweetViewModel = TweetViewModel()       
    let grayColor = Color(hue: 0, saturation: 0, brightness: 0.9)   
    var body: some View {       
        VStack(alignment: .leading, spacing: 20) {                       
            Text("Search Tweets").font(.title)           
            HStack(spacing: 20) {               
                HStack {                   
                    Image(systemName: "magnifyingglass")
                           .foregroundColor(Color(.lightGray))                                   
                    TextField("Search", text: $tweetViewModel.searchText)               
                }.padding(5)               
                 .background(RoundedRectangle(cornerRadius: 5).fill(grayColor))               
               Button(action: {                   
                  tweetViewModel.searchText = ""               
                }, label: { Text("Cancel")  })           
            }    
            List(tweetViewModel.tweets, id: .self) { tweet  in              
                VStack(alignment: .leading) {               
                     Text(tweet.user.name).bold()               
                     Text(tweet.text).foregroundColor(.gray)           
                }
            }.animation(.easeInOut)                   
        }.padding()   
    }   
}
				

Leave a Comment

Subscribe to My Newsletter

Want the latest iOS development trends and insights delivered to your inbox? Subscribe to our newsletter now!

Newsletter Form