Successfully reported this slideshow.
Your SlideShare is downloading. ×

MVVM with SwiftUI and Combine

Ad
Ad
Ad
Ad
Ad
Ad
Ad
Ad
Ad
Ad
Loading in …3
×

Check these out next

1 of 68 Ad

MVVM with SwiftUI and Combine

Download to read offline

A brief wrap up and introduction to SwiftUI and Combine brought by Apple in WWDC 2019, as well as utilizing both to propose a View-Model-ViewModel (MVVM) structure.

A brief wrap up and introduction to SwiftUI and Combine brought by Apple in WWDC 2019, as well as utilizing both to propose a View-Model-ViewModel (MVVM) structure.

Advertisement
Advertisement

More Related Content

Similar to MVVM with SwiftUI and Combine (20)

Advertisement
Advertisement

MVVM with SwiftUI and Combine

  1. 1. MVVM with SwiftUI and Combine Tai-Lun Tseng 2019.11.15, Apple Taiwan
  2. 2. Agenda • SwiftUI • Combine • MVVM
  3. 3. SwiftUI import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } }
  4. 4. SwiftUI • Declarative • Merges code and visual design, instead of separation (like storyboard) • Prevents complex UIViewController codes
  5. 5. Traditional Wayimport UIKit class ChecklistCell: UITableViewCell { @IBOutlet var doneSwitch: UISwitch! @IBOutlet var titleLabel: UILabel! @IBOutlet var createdAtLabel: UILabel! func configure(for item: CheckItem) { self.titleLabel.text = item.title self.createdAtLabel.text = item.createdAt self.doneSwitch.isOn = item.done } } class ChecklistTableViewController : UIViewController, UITableViewDataSource { private var checklist = sampleChecklist func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { checklist.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "checklist_cell", for: indexPath) if let checklistCell = cell as? ChecklistCell { checklistCell.configure(for: checklist[indexPath.row]) } return cell } // ... } import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } }
  6. 6. Traditional Wayimport UIKit class ChecklistCell: UITableViewCell { @IBOutlet var doneSwitch: UISwitch! @IBOutlet var titleLabel: UILabel! @IBOutlet var createdAtLabel: UILabel! func configure(for item: CheckItem) { self.titleLabel.text = item.title self.createdAtLabel.text = item.createdAt self.doneSwitch.isOn = item.done } } class ChecklistTableViewController : UIViewController, UITableViewDataSource { private var checklist = sampleChecklist func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { checklist.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "checklist_cell", for: indexPath) if let checklistCell = cell as? ChecklistCell { checklistCell.configure(for: checklist[indexPath.row]) } return cell } // ... } import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } } • 40+ lines of code • Requires Storyboard setup and linking • Adjust layout in both codes and Storyboard/Nibs • Supports all iOS versions
  7. 7. Traditional Wayimport UIKit class ChecklistCell: UITableViewCell { @IBOutlet var doneSwitch: UISwitch! @IBOutlet var titleLabel: UILabel! @IBOutlet var createdAtLabel: UILabel! func configure(for item: CheckItem) { self.titleLabel.text = item.title self.createdAtLabel.text = item.createdAt self.doneSwitch.isOn = item.done } } class ChecklistTableViewController : UIViewController, UITableViewDataSource { private var checklist = sampleChecklist func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { checklist.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "checklist_cell", for: indexPath) if let checklistCell = cell as? ChecklistCell { checklistCell.configure(for: checklist[indexPath.row]) } return cell } // ... } import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } } • 15 lines of code • No Nib or Storyboard • Design layout in code directly, with the support of Canvas • Supports iOS 13+
  8. 8. New Syntax? import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } }
  9. 9. import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } } Property Wrapper • "Wraps" original property with power-ups • Work on class/struct properties
  10. 10. import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } } Property Wrapper Type: [CheckItem] Type: Binding<[CheckItem]>
  11. 11. import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } } Opaque Type • Reversed generics • See associatedtype and typealias https://docs.swift.org/swift-book/LanguageGuide/OpaqueTypes.html
  12. 12. import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } } Function Builder What is the return value of the closure?
  13. 13. import SwiftUI struct ChecklistView: View { @Binding var checklist: [CheckItem] var body: some View { List(checklist.indices) { index in Toggle(isOn: self.$checklist[index].done) { VStack(alignment: .leading) { Text(self.checklist[index].title) .bold() Text(self.checklist[index].createdAt) .foregroundColor(.gray) } } } } } Function Builder VStack(alignment: .leading) { let view1 = Text(self.checklist[index].title) .bold() let view2 = Text(self.checklist[index].createdAt) .foregroundColor(.gray) return ContentBuilder.buildBlock(view1, view2) }
  14. 14. Function Builder public struct VStack<Content> : View where Content : View { @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) // ... } https://developer.apple.com/documentation/swiftui/viewbuilder
  15. 15. SwiftUI Canvas
  16. 16. SwiftUI Canvas
  17. 17. SwiftUI Canvas
  18. 18. Styling struct ContentView: View { @State var text = "Hello World!" var body: some View { VStack(alignment: .trailing, spacing: nil) { TextField("Enter text", text: $text) .border(Color.black) .multilineTextAlignment(.trailing) .padding() Text(text.uppercased()) .foregroundColor(.white) .bold() .padding() }.background(Rectangle().foregroundColor(.blue)) } }
  19. 19. Styling struct ContentView: View { @State var text = "Hello World!" var body: some View { VStack(alignment: .trailing, spacing: nil) { TextField("Enter text", text: $text) .border(Color.black) .multilineTextAlignment(.trailing) .padding() Text(text.uppercased()) .foregroundColor(.white) .bold() .padding() }.background(Rectangle().foregroundColor(.blue)) } }
  20. 20. @State struct ContentView: View { @State var text = "Hello World!" var body: some View { VStack(alignment: .trailing, spacing: nil) { TextField("Enter text", text: $text) .border(Color.black) .multilineTextAlignment(.trailing) .padding() Text(text.uppercased()) .foregroundColor(.white) .bold() .padding() }.background(Rectangle().foregroundColor(.blue)) } } • When state is updated, view is invalidated automatically • @State values are managed by the view
  21. 21. class SearchViewModel: ObservableObject { @Published var searchResult: [SearchResultItem] = [] @Published var searchText: String = "" } ObservableObject • Present a single state by combining multiple state values • Use @Published instead of @State
  22. 22. class SearchViewModel: ObservableObject { @Published var searchResult: [SearchResultItem] = [] @Published var searchText: String = "" } struct ContentView: View { @ObservedObject var model = SearchViewModel() } ObservableObject and @ObservedObject
  23. 23. Single Source of Truth? struct BadgeView: View { @State var unreadCount = 0 // ... } struct UnreadListView: View { @State var unreadList: [String] = [] // ... } struct SocialMediaView: View { var body: some View { VStack { BadgeView() UnreadListView() } } } SocialMediaView BadgeView UnreadListView unreadCount unreadList
  24. 24. Single Source of Truth struct BadgeView: View { var unreadCount: Int // ... } struct UnreadListView: View { @Binding var unreadList: [String] // ... } struct SocialMediaView: View { @State var unreadList: [String] = [] var body: some View { VStack { BadgeView(unreadCount: unreadList.count) UnreadListView(unreadList: $unreadList) } } } SocialMediaView BadgeView UnreadListView unreadList.count unreadList unreadList • Use @Binding to pass down states
  25. 25. View State and ObservedObject @State ObservableObject View View View View • Use @Binding to pass down states • Use @ObservedObject instead of @State @ObservedObject
  26. 26. View EnvironmentObject ObservableObject View View View View .environmentObject() • Use @EnvironmentObject instead of @State • Indirectly pass values for more flexibility @EnvironmentObject @EnvironmentObject
  27. 27. Add SwiftUI to UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Create the SwiftUI view that provides the window contents. let contentView = ContentView() // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: contentView) self.window = window window.makeKeyAndVisible() } } // ... }
  28. 28. In Playground... let contentView = ContentView() let host = UIHostingController(rootView: contentView) host.preferredContentSize = CGSize(width: 320, height: 480) // Present the view controller in the Live View window PlaygroundPage.current.liveView = host
  29. 29. Preview and Test Data
  30. 30. Preview and Test Data Design and write component here
  31. 31. Preview and Test Data Provide test data to the preview component
  32. 32. Combine • Process asynchronous events easily • Swift's official reactive programming library • 3rd libraries: • ReactiveCocoa • RxSwift
  33. 33. Basic Concepts • Publisher • Subscriber • Transformations
  34. 34. Publisher: Data Source • Publishers create a series of data over time • Think as an event stream 3 4 20 6 0-32 Type: Int time
  35. 35. Publisher Examples Just<Int>(1) 1 • Creates an event stream with only 1 value, and then finishes immediately
  36. 36. Timer.publish(every: 1, on: .main, in: .common) 14:20: 36 14:20: 37 14:20: 38 14:20: 39 14:20: 40 Publisher Examples • Creates an event stream that emits a Date object every second
  37. 37. NotificationCenter.default.publisher(for: NSControl.textDidChangeNotification, object: textField) HelloH He Hel Hell Publisher Examples • Listens to text changes on a NSTextField with Notification Center • Whenever text changes, it emits an event whose value is the NSTextField object
  38. 38. Subscriber: event listener struct TimerView : View { @ObservedObject var timerState: TimerState var body: some View { Text(timerState.timeText) } } Timer .publish(every: 1, on: .main, in: .common) .autoconnect() .sink { date in timerState.timeText = df.string(from: date) } Timer.publish(every: 1, on: .main, in: .common) 14:20: 36 14:20: 37 14:20: 38 14:20: 39 14:20: 40
  39. 39. Transformations NotificationCenter.default .publisher(for: NSControl.textDidChangeNotification, object: textField) .map { ($0 as! NSTextField).stringValue } .filter { $0.count > 2 } HelloH He Hel Hell "Hello""" "H" "He" "Hel" "Hell" "Hello""Hel" "Hell" map filter
  40. 40. Showcase: Search • Requirements • Send network request after user stopped key in for 1 second • Don't send request for same search texts
  41. 41. class SearchViewModel: ObservableObject { @Published var searchResult: [SearchResultItem] = [] @Published var searchText: String = "" init(searchRepository: SearchRepository) { $searchText .dropFirst(1) // ... .sink { result in self.searchResult = result } } } @Published as Publisher
  42. 42. Transformations $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable)
  43. 43. dropFirst $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable)
  44. 44. dropFirst $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) "G "Gu" "Gun" "Gund" "Gunda" "Gundam" "" "G "Gu" "Gun" "Gund" "Gunda" "Gundam" dropFirst(1)
  45. 45. debounce $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable)
  46. 46. debounce $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) "Gun" "Gundam" "G" "Gu" "Gun" "Gund" "Gunda" "Gundam" debounce(for: 1, scheduler: RunLoop.main)
  47. 47. removeDuplicates & filter $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable)
  48. 48. $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) removeDuplicates & filter "G "Gun" "Gun" "" removeDuplicates() "G "Gun" "" "G "Gun" filter { $0.count > 0 }
  49. 49. $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) URLSession.DataTaskPublisher
  50. 50. URLSession.DataTaskPublisher URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https://en.wikipedia.org/w/api.php?action=opensearch&search= (searchText)&limit=(self.limit)&namespace=0&format=json")!), session: .shared) (Data, Response) .map { $0.data } <5b, 22, 4b, 61, ...>
  51. 51. $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) flatMap
  52. 52. flatMap "Gun" "Gundam" <5b, 22, 4b, 61, ...> [SearchResultItem] <5b, 22, 4b, 61, ...> [SearchResultItem] compactMap compactMap URLSession.DataTaskPublisher URLSession.DataTaskPublisher
  53. 53. flatMap "Gun" "Gundam" [SearchResultItem] [SearchResultItem] .flatMap { searchText in URLSession.DataTaskPublisher(... }
  54. 54. $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) compactMap
  55. 55. Optional([SearchResultItem]) compactMap <5b, 22, 4b, 61, ...> <00, 00, 00, ...> Optional([SearchResultItem]) nil .map { self.parseSearchResult(data: $0) } [SearchResultItem] .filter( $0 != nil ) .map { $0! }
  56. 56. compactMap <5b, 22, 4b, 61, ...> <00, 00, 00, ...> [SearchResultItem] .compactMap { self.parseSearchResult(data: $0) }
  57. 57. $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) sink
  58. 58. $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) .removeDuplicates() .filter { $0.count > 0 } .compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) } .flatMap { searchText in URLSession.DataTaskPublisher(request: URLRequest(url: URL(string: "https:// en.wikipedia.org/w/api.php?action=opensearch&search=(searchText)&limit= (self.limit)&namespace=0&format=json")!), session: .shared) .map { $0.data } .catch { err -> Just<Data?> in print(err) return Just(nil) } .compactMap { $0 } } .compactMap { self.parseSearchResult(data: $0) } .receive(on: RunLoop.main) .sink { result in self.searchResult = result } .store(in: &cancellable) sink [SearchResultItem]
  59. 59. store • .sink() returns a subscription which conforms to Cancellable • Call cancellable.cancel() to cancel the subscription • Use .store() to manage subscriptions let cancellable = $searchText .dropFirst(1) ... .sink { result in self.searchResult = result } cancellable.store(in: &cancellableSet) $searchText .dropFirst(1) ... .sink { result in self.searchResult = result } .store(in: &cancellableSet)
  60. 60. Model-View-ViewModel (MVVM) • Variation of model-view-presenter (MVP) • More concise codes and data flow • View knows existence of ViewModel, but not vise-versa • ViewModel sends data to View via subscription • Same as ViewModel and Model • Non-UI logics and data layers sit in Models
  61. 61. Model-View-ViewModel (MVVM) View • Subscribe and present data from view model • Handle user actions (e.g. two-way binding) Model • Handle data and business logic • Talk to network / storage ViewModel • Bind data between model and view • Manage "UI states" • Subscribe states • Forward user actions • Read / store data • Subscribe changes
  62. 62. MVVM in iOS 13 • View: SwiftUI • ViewModel: Bindable Object and Combine • Model: existing SDK features (URLSession, Core Model, etc.) • Communication: subscription via Combine
  63. 63. SwiftUI as View struct SearchView: View { @EnvironmentObject var model: SearchViewModel var body: some View { VStack { TextField("Search Wiki...", text: $model.searchText) if model.searchResult.count > 0 { List(model.searchResult) { result in NavigationLink(destination: SearchResultDetail(searchResult: result)) { Text(result.name) } } } else { Spacer() Text("No Results") } } } }
  64. 64. ObservableObject as ViewModel class SearchViewModel: ObservableObject { private let searchRepository: SearchRepository @Published var searchResult: [SearchResultItem] = [] @Published var searchText: String = "" // ... init(searchRepository: SearchRepository) { self.searchRepository = searchRepository $searchText .dropFirst(1) .debounce(for: 1, scheduler: RunLoop.main) // ... .flatMap { searchText in self.searchRepository.search(by: searchText, limit: self.limit) } // ... .sink { result in self.searchResult = result } .store(in: &cancellable) } }
  65. 65. MVVM Flow Example SearchView SearchViewModel SearchRepository (model) User keys in texts TextField changes searchText value (via binding) Transforms searchText into search keyword Fetches Wikipedia search data with keyword Parses search results Sets result to searchResult Invalidate view
  66. 66. Conclusion • Adapt SwiftUI for declarative view structure • Use Combine to handle asynchronous flows and event streams • Implement MVVM with SwiftUI and Combine • Write less codes, but more concise and predictable
  67. 67. WWDC 2019 References • 204 - Introducing SwiftUI: Building Your First App • 216 - SwiftUI Essentials • 226 - Data Flow Through SwiftUI • 721 - Combine in Practice • 722 - Introducing Combine * Some APIs have been renamed since between WWDC and official release
  68. 68. References • https://developer.apple.com/documentation/swiftui • https://developer.apple.com/documentation/combine • https://github.com/teaualune/swiftui_example_wiki_search • https://github.com/heckj/swiftui-notes • https://www.raywenderlich.com/4161005-mvvm-with- combine-tutorial-for-ios

×