SlideShare a Scribd company logo
MVVM with SwiftUI
and Combine
Tai-Lun Tseng

2019.11.15, Apple Taiwan
Agenda
• SwiftUI

• Combine

• MVVM
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)
}
}
}
}
}
SwiftUI
• Declarative

• Merges code and visual design, instead of separation (like
storyboard)

• Prevents complex UIViewController codes
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)
}
}
}
}
}
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
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+
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)
}
}
}
}
}
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
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]>
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
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?
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)
}
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
SwiftUI Canvas
SwiftUI Canvas
SwiftUI Canvas
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))
}
}
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))
}
}
@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
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
class SearchViewModel: ObservableObject {
@Published var searchResult: [SearchResultItem] =
[]
@Published var searchText: String = ""
}
struct ContentView: View {
@ObservedObject var model = SearchViewModel()
}
ObservableObject and
@ObservedObject
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
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
View
State and ObservedObject
@State
ObservableObject
View View View View
• Use @Binding to pass down states

• Use @ObservedObject instead of @State
@ObservedObject
View
EnvironmentObject
ObservableObject
View View View View
.environmentObject()
• Use @EnvironmentObject instead of @State

• Indirectly pass values for more flexibility
@EnvironmentObject @EnvironmentObject
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()
}
}
// ...
}
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
Preview and Test Data
Preview and Test Data
Design and write
component here
Preview and Test Data
Provide test data to the
preview component
Combine
• Process asynchronous events easily

• Swift's official reactive programming library

• 3rd libraries:

• ReactiveCocoa

• RxSwift
Basic Concepts
• Publisher

• Subscriber

• Transformations
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
Publisher Examples
Just<Int>(1)
1
• Creates an event stream with only 1 value, and then
finishes immediately
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
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
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
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
Showcase: Search
• Requirements

• Send network request
after user stopped key in
for 1 second

• Don't send request for
same search texts
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
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)
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)
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)
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)
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)
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)
$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 }
$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
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, ...>
$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
flatMap
"Gun" "Gundam"
<5b, 22, 4b, 61, ...>
[SearchResultItem]
<5b, 22, 4b, 61, ...>
[SearchResultItem]
compactMap compactMap
URLSession.DataTaskPublisher URLSession.DataTaskPublisher
flatMap
"Gun" "Gundam"
[SearchResultItem] [SearchResultItem]
.flatMap { searchText in
URLSession.DataTaskPublisher(...
}
$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
Optional([SearchResultItem])
compactMap
<5b, 22, 4b, 61, ...> <00, 00, 00, ...>
Optional([SearchResultItem]) nil
.map { self.parseSearchResult(data: $0) }
[SearchResultItem]
.filter( $0 != nil )
.map { $0! }
compactMap
<5b, 22, 4b, 61, ...> <00, 00, 00, ...>
[SearchResultItem]
.compactMap { self.parseSearchResult(data: $0) }
$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
$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]
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)
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
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
MVVM in iOS 13
• View: SwiftUI

• ViewModel: Bindable Object and Combine

• Model: existing SDK features (URLSession, Core Model,
etc.)

• Communication: subscription via Combine
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")
}
}
}
}
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)
}
}
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
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
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
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

More Related Content

What's hot (20)

PDF
Angular Advanced Routing
Laurent Duveau
 
PPT
Advanced JavaScript
Fu Cheng
 
PPSX
ASP.NET Web form
Md. Mahedee Hasan
 
PDF
An introduction to React.js
Emanuele DelBono
 
PPTX
Spring boot Introduction
Jeevesh Pandey
 
PDF
Spring mvc
Hamid Ghorbani
 
PPTX
ReactJS presentation.pptx
DivyanshGupta922023
 
PPTX
Spring Boot and REST API
07.pallav
 
PDF
Spring Boot
Jaydeep Kale
 
PPTX
Spring Framework
tola99
 
PDF
Angular
Lilia Sfaxi
 
PPT
React js
Jai Santhosh
 
PPT
The Kotlin Programming Language
intelliyole
 
PPTX
React js
Oswald Campesato
 
PPTX
Intro to React
Justin Reock
 
PPTX
Reactjs
Neha Sharma
 
PPT
GUI Programming In Java
yht4ever
 
PPTX
Sharing Data Between Angular Components
Squash Apps Pvt Ltd
 
PDF
AngularJS - Services
Nir Kaufman
 
PPT
Angular App Presentation
Elizabeth Long
 
Angular Advanced Routing
Laurent Duveau
 
Advanced JavaScript
Fu Cheng
 
ASP.NET Web form
Md. Mahedee Hasan
 
An introduction to React.js
Emanuele DelBono
 
Spring boot Introduction
Jeevesh Pandey
 
Spring mvc
Hamid Ghorbani
 
ReactJS presentation.pptx
DivyanshGupta922023
 
Spring Boot and REST API
07.pallav
 
Spring Boot
Jaydeep Kale
 
Spring Framework
tola99
 
Angular
Lilia Sfaxi
 
React js
Jai Santhosh
 
The Kotlin Programming Language
intelliyole
 
Intro to React
Justin Reock
 
Reactjs
Neha Sharma
 
GUI Programming In Java
yht4ever
 
Sharing Data Between Angular Components
Squash Apps Pvt Ltd
 
AngularJS - Services
Nir Kaufman
 
Angular App Presentation
Elizabeth Long
 

Similar to MVVM with SwiftUI and Combine (20)

PDF
Some Stuff I was thinking about state machines and types
gillygize
 
PDF
Building Reusable SwiftUI Components
Peter Friese
 
PDF
Building Reusable SwiftUI Components
Peter Friese
 
PDF
Writing Your App Swiftly
Sommer Panage
 
PDF
Swift
Larry Ball
 
PDF
Unit testing UIView
Pierre Felgines
 
PDF
20180721 code defragment
Chiwon Song
 
PDF
Using a model view-view model architecture for iOS apps
allanh0526
 
PDF
Swift ui userinput
joonjhokil
 
PDF
Component-driven UIs - Mobile Era 2016
John Sundell
 
PPTX
When You Can’t Go All In on SwiftUI, Build a Hybrid UI App Instead!
Vui Nguyen
 
PDF
Getting Started with Combine And SwiftUI
Scott Gardner
 
PDF
Сергій Міськів, «SwiftUI: Animations»
Sigma Software
 
PDF
Property wrapper and how to use them with mvvm in swift ui i copy
WannitaTolaema
 
PDF
Minimizing Decision Fatigue to Improve Team Productivity
Derek Lee
 
PDF
Swift - One step forward from Obj-C
Nissan Tsafrir
 
PDF
Do iOS Presentation - Mobile app architectures
David Broža
 
PDF
Advanced Swift Generics
Max Sokolov
 
PDF
Protocol Oriented MVVM - Auckland iOS Meetup
Natasha Murashev
 
PDF
Protocol-Oriented MVVM (extended edition)
Natasha Murashev
 
Some Stuff I was thinking about state machines and types
gillygize
 
Building Reusable SwiftUI Components
Peter Friese
 
Building Reusable SwiftUI Components
Peter Friese
 
Writing Your App Swiftly
Sommer Panage
 
Swift
Larry Ball
 
Unit testing UIView
Pierre Felgines
 
20180721 code defragment
Chiwon Song
 
Using a model view-view model architecture for iOS apps
allanh0526
 
Swift ui userinput
joonjhokil
 
Component-driven UIs - Mobile Era 2016
John Sundell
 
When You Can’t Go All In on SwiftUI, Build a Hybrid UI App Instead!
Vui Nguyen
 
Getting Started with Combine And SwiftUI
Scott Gardner
 
Сергій Міськів, «SwiftUI: Animations»
Sigma Software
 
Property wrapper and how to use them with mvvm in swift ui i copy
WannitaTolaema
 
Minimizing Decision Fatigue to Improve Team Productivity
Derek Lee
 
Swift - One step forward from Obj-C
Nissan Tsafrir
 
Do iOS Presentation - Mobile app architectures
David Broža
 
Advanced Swift Generics
Max Sokolov
 
Protocol Oriented MVVM - Auckland iOS Meetup
Natasha Murashev
 
Protocol-Oriented MVVM (extended edition)
Natasha Murashev
 
Ad

Recently uploaded (20)

PPTX
Agentforce World Tour Toronto '25 - MCP with MuleSoft
Alexandra N. Martinez
 
PDF
UiPath DevConnect 2025: Agentic Automation Community User Group Meeting
DianaGray10
 
PDF
The Rise of AI and IoT in Mobile App Tech.pdf
IMG Global Infotech
 
PDF
AI Agents in the Cloud: The Rise of Agentic Cloud Architecture
Lilly Gracia
 
PDF
Automating Feature Enrichment and Station Creation in Natural Gas Utility Net...
Safe Software
 
DOCX
Cryptography Quiz: test your knowledge of this important security concept.
Rajni Bhardwaj Grover
 
PPTX
Q2 FY26 Tableau User Group Leader Quarterly Call
lward7
 
PDF
Newgen Beyond Frankenstein_Build vs Buy_Digital_version.pdf
darshakparmar
 
PDF
“Computer Vision at Sea: Automated Fish Tracking for Sustainable Fishing,” a ...
Edge AI and Vision Alliance
 
PPTX
The Project Compass - GDG on Campus MSIT
dscmsitkol
 
PDF
Reverse Engineering of Security Products: Developing an Advanced Microsoft De...
nwbxhhcyjv
 
PDF
UPDF - AI PDF Editor & Converter Key Features
DealFuel
 
PDF
POV_ Why Enterprises Need to Find Value in ZERO.pdf
darshakparmar
 
PDF
Kit-Works Team Study_20250627_한달만에만든사내서비스키링(양다윗).pdf
Wonjun Hwang
 
PDF
LOOPS in C Programming Language - Technology
RishabhDwivedi43
 
PDF
Transforming Utility Networks: Large-scale Data Migrations with FME
Safe Software
 
PDF
Book industry state of the nation 2025 - Tech Forum 2025
BookNet Canada
 
PPTX
From Sci-Fi to Reality: Exploring AI Evolution
Svetlana Meissner
 
PPTX
Mastering ODC + Okta Configuration - Chennai OSUG
HathiMaryA
 
PDF
NLJUG Speaker academy 2025 - first session
Bert Jan Schrijver
 
Agentforce World Tour Toronto '25 - MCP with MuleSoft
Alexandra N. Martinez
 
UiPath DevConnect 2025: Agentic Automation Community User Group Meeting
DianaGray10
 
The Rise of AI and IoT in Mobile App Tech.pdf
IMG Global Infotech
 
AI Agents in the Cloud: The Rise of Agentic Cloud Architecture
Lilly Gracia
 
Automating Feature Enrichment and Station Creation in Natural Gas Utility Net...
Safe Software
 
Cryptography Quiz: test your knowledge of this important security concept.
Rajni Bhardwaj Grover
 
Q2 FY26 Tableau User Group Leader Quarterly Call
lward7
 
Newgen Beyond Frankenstein_Build vs Buy_Digital_version.pdf
darshakparmar
 
“Computer Vision at Sea: Automated Fish Tracking for Sustainable Fishing,” a ...
Edge AI and Vision Alliance
 
The Project Compass - GDG on Campus MSIT
dscmsitkol
 
Reverse Engineering of Security Products: Developing an Advanced Microsoft De...
nwbxhhcyjv
 
UPDF - AI PDF Editor & Converter Key Features
DealFuel
 
POV_ Why Enterprises Need to Find Value in ZERO.pdf
darshakparmar
 
Kit-Works Team Study_20250627_한달만에만든사내서비스키링(양다윗).pdf
Wonjun Hwang
 
LOOPS in C Programming Language - Technology
RishabhDwivedi43
 
Transforming Utility Networks: Large-scale Data Migrations with FME
Safe Software
 
Book industry state of the nation 2025 - Tech Forum 2025
BookNet Canada
 
From Sci-Fi to Reality: Exploring AI Evolution
Svetlana Meissner
 
Mastering ODC + Okta Configuration - Chennai OSUG
HathiMaryA
 
NLJUG Speaker academy 2025 - first session
Bert Jan Schrijver
 
Ad

MVVM with SwiftUI and Combine

  • 1. MVVM with SwiftUI and Combine Tai-Lun Tseng 2019.11.15, Apple Taiwan
  • 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. SwiftUI • Declarative • Merges code and visual design, instead of separation (like storyboard) • Prevents complex UIViewController codes
  • 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. 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. 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. 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. 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. 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. 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. 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. 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. 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
  • 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. 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. @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. 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. class SearchViewModel: ObservableObject { @Published var searchResult: [SearchResultItem] = [] @Published var searchText: String = "" } struct ContentView: View { @ObservedObject var model = SearchViewModel() } ObservableObject and @ObservedObject
  • 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. 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. View State and ObservedObject @State ObservableObject View View View View • Use @Binding to pass down states • Use @ObservedObject instead of @State @ObservedObject
  • 26. View EnvironmentObject ObservableObject View View View View .environmentObject() • Use @EnvironmentObject instead of @State • Indirectly pass values for more flexibility @EnvironmentObject @EnvironmentObject
  • 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. 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
  • 30. Preview and Test Data Design and write component here
  • 31. Preview and Test Data Provide test data to the preview component
  • 32. Combine • Process asynchronous events easily • Swift's official reactive programming library • 3rd libraries: • ReactiveCocoa • RxSwift
  • 33. Basic Concepts • Publisher • Subscriber • Transformations
  • 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. Publisher Examples Just<Int>(1) 1 • Creates an event stream with only 1 value, and then finishes immediately
  • 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. 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. 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. 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. Showcase: Search • Requirements • Send network request after user stopped key in for 1 second • Don't send request for same search texts
  • 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. 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. 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. 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. 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. 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. 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. $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. $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
  • 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. flatMap "Gun" "Gundam" <5b, 22, 4b, 61, ...> [SearchResultItem] <5b, 22, 4b, 61, ...> [SearchResultItem] compactMap compactMap URLSession.DataTaskPublisher URLSession.DataTaskPublisher
  • 53. flatMap "Gun" "Gundam" [SearchResultItem] [SearchResultItem] .flatMap { searchText in URLSession.DataTaskPublisher(... }
  • 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. 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. compactMap <5b, 22, 4b, 61, ...> <00, 00, 00, ...> [SearchResultItem] .compactMap { self.parseSearchResult(data: $0) }
  • 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. $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. 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. 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. 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. MVVM in iOS 13 • View: SwiftUI • ViewModel: Bindable Object and Combine • Model: existing SDK features (URLSession, Core Model, etc.) • Communication: subscription via Combine
  • 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. 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. 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. 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. 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. 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