SlideShare una empresa de Scribd logo
1 de 68
Descargar para leer sin conexión
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

Más contenido relacionado

La actualidad más candente

La actualidad más candente (20)

SwiftUI - Performance and Memory Management
SwiftUI - Performance and Memory ManagementSwiftUI - Performance and Memory Management
SwiftUI - Performance and Memory Management
 
Introduction to React JS for beginners | Namespace IT
Introduction to React JS for beginners | Namespace ITIntroduction to React JS for beginners | Namespace IT
Introduction to React JS for beginners | Namespace IT
 
React workshop
React workshopReact workshop
React workshop
 
Building Reusable SwiftUI Components
Building Reusable SwiftUI ComponentsBuilding Reusable SwiftUI Components
Building Reusable SwiftUI Components
 
Its time to React.js
Its time to React.jsIts time to React.js
Its time to React.js
 
ReactJS presentation
ReactJS presentationReactJS presentation
ReactJS presentation
 
Building blocks of Angular
Building blocks of AngularBuilding blocks of Angular
Building blocks of Angular
 
Spring Framework - AOP
Spring Framework - AOPSpring Framework - AOP
Spring Framework - AOP
 
React js programming concept
React js programming conceptReact js programming concept
React js programming concept
 
Spring Boot and REST API
Spring Boot and REST APISpring Boot and REST API
Spring Boot and REST API
 
Introduction to Spring Boot
Introduction to Spring BootIntroduction to Spring Boot
Introduction to Spring Boot
 
React and redux
React and reduxReact and redux
React and redux
 
An introduction to React.js
An introduction to React.jsAn introduction to React.js
An introduction to React.js
 
Introduction To Angular's reactive forms
Introduction To Angular's reactive formsIntroduction To Angular's reactive forms
Introduction To Angular's reactive forms
 
reactJS
reactJSreactJS
reactJS
 
Angular - Chapter 2 - TypeScript Programming
Angular - Chapter 2 - TypeScript Programming  Angular - Chapter 2 - TypeScript Programming
Angular - Chapter 2 - TypeScript Programming
 
React js
React jsReact js
React js
 
Introduction to React JS
Introduction to React JSIntroduction to React JS
Introduction to React JS
 
Introduction to React JS for beginners
Introduction to React JS for beginners Introduction to React JS for beginners
Introduction to React JS for beginners
 
Intro to React
Intro to ReactIntro to React
Intro to React
 

Similar a MVVM with SwiftUI and Combine

iOS for ERREST - alternative version
iOS for ERREST - alternative versioniOS for ERREST - alternative version
iOS for ERREST - alternative version
WO Community
 

Similar a MVVM with SwiftUI and Combine (20)

201104 iphone navigation-based apps
201104 iphone navigation-based apps201104 iphone navigation-based apps
201104 iphone navigation-based apps
 
IOS APPs Revision
IOS APPs RevisionIOS APPs Revision
IOS APPs Revision
 
I os 11
I os 11I os 11
I os 11
 
Роман Иовлев «Open Source UI Automation Tests on C#»
Роман Иовлев «Open Source UI Automation Tests on C#»Роман Иовлев «Open Source UI Automation Tests on C#»
Роман Иовлев «Open Source UI Automation Tests on C#»
 
Formacion en movilidad: Conceptos de desarrollo en iOS (IV)
Formacion en movilidad: Conceptos de desarrollo en iOS (IV) Formacion en movilidad: Conceptos de desarrollo en iOS (IV)
Formacion en movilidad: Conceptos de desarrollo en iOS (IV)
 
Formacion en movilidad: Conceptos de desarrollo en iOS (III)
Formacion en movilidad: Conceptos de desarrollo en iOS (III) Formacion en movilidad: Conceptos de desarrollo en iOS (III)
Formacion en movilidad: Conceptos de desarrollo en iOS (III)
 
Building Reusable SwiftUI Components
Building Reusable SwiftUI ComponentsBuilding Reusable SwiftUI Components
Building Reusable SwiftUI Components
 
Cocoa heads testing and viewcontrollers
Cocoa heads testing and viewcontrollersCocoa heads testing and viewcontrollers
Cocoa heads testing and viewcontrollers
 
Swift Delhi: Practical POP
Swift Delhi: Practical POPSwift Delhi: Practical POP
Swift Delhi: Practical POP
 
iOS for ERREST - alternative version
iOS for ERREST - alternative versioniOS for ERREST - alternative version
iOS for ERREST - alternative version
 
Swift Tableview iOS App Development
Swift Tableview iOS App DevelopmentSwift Tableview iOS App Development
Swift Tableview iOS App Development
 
Practical Protocol-Oriented-Programming
Practical Protocol-Oriented-ProgrammingPractical Protocol-Oriented-Programming
Practical Protocol-Oriented-Programming
 
Practialpop 160510130818
Practialpop 160510130818Practialpop 160510130818
Practialpop 160510130818
 
MCE^3 - Natasha Murashev - Practical Protocol-Oriented Programming in Swift
MCE^3 - Natasha Murashev - Practical Protocol-Oriented Programming in SwiftMCE^3 - Natasha Murashev - Practical Protocol-Oriented Programming in Swift
MCE^3 - Natasha Murashev - Practical Protocol-Oriented Programming in Swift
 
Understanding backbonejs
Understanding backbonejsUnderstanding backbonejs
Understanding backbonejs
 
Net conf BG xamarin lecture
Net conf BG xamarin lectureNet conf BG xamarin lecture
Net conf BG xamarin lecture
 
Getting Started with Combine And SwiftUI
Getting Started with Combine And SwiftUIGetting Started with Combine And SwiftUI
Getting Started with Combine And SwiftUI
 
APPlause - DemoCamp Munich
APPlause - DemoCamp MunichAPPlause - DemoCamp Munich
APPlause - DemoCamp Munich
 
Viastudy ef core_cheat_sheet
Viastudy ef core_cheat_sheetViastudy ef core_cheat_sheet
Viastudy ef core_cheat_sheet
 
iOS_Presentation
iOS_PresentationiOS_Presentation
iOS_Presentation
 

Último

Cloud Frontiers: A Deep Dive into Serverless Spatial Data and FME
Cloud Frontiers:  A Deep Dive into Serverless Spatial Data and FMECloud Frontiers:  A Deep Dive into Serverless Spatial Data and FME
Cloud Frontiers: A Deep Dive into Serverless Spatial Data and FME
Safe Software
 
Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024
Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024
Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024
Victor Rentea
 
+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...
+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...
+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...
?#DUbAI#??##{{(☎️+971_581248768%)**%*]'#abortion pills for sale in dubai@
 
Finding Java's Hidden Performance Traps @ DevoxxUK 2024
Finding Java's Hidden Performance Traps @ DevoxxUK 2024Finding Java's Hidden Performance Traps @ DevoxxUK 2024
Finding Java's Hidden Performance Traps @ DevoxxUK 2024
Victor Rentea
 

Último (20)

presentation ICT roal in 21st century education
presentation ICT roal in 21st century educationpresentation ICT roal in 21st century education
presentation ICT roal in 21st century education
 
Strategize a Smooth Tenant-to-tenant Migration and Copilot Takeoff
Strategize a Smooth Tenant-to-tenant Migration and Copilot TakeoffStrategize a Smooth Tenant-to-tenant Migration and Copilot Takeoff
Strategize a Smooth Tenant-to-tenant Migration and Copilot Takeoff
 
ICT role in 21st century education and its challenges
ICT role in 21st century education and its challengesICT role in 21st century education and its challenges
ICT role in 21st century education and its challenges
 
TrustArc Webinar - Unlock the Power of AI-Driven Data Discovery
TrustArc Webinar - Unlock the Power of AI-Driven Data DiscoveryTrustArc Webinar - Unlock the Power of AI-Driven Data Discovery
TrustArc Webinar - Unlock the Power of AI-Driven Data Discovery
 
AWS Community Day CPH - Three problems of Terraform
AWS Community Day CPH - Three problems of TerraformAWS Community Day CPH - Three problems of Terraform
AWS Community Day CPH - Three problems of Terraform
 
Spring Boot vs Quarkus the ultimate battle - DevoxxUK
Spring Boot vs Quarkus the ultimate battle - DevoxxUKSpring Boot vs Quarkus the ultimate battle - DevoxxUK
Spring Boot vs Quarkus the ultimate battle - DevoxxUK
 
Rising Above_ Dubai Floods and the Fortitude of Dubai International Airport.pdf
Rising Above_ Dubai Floods and the Fortitude of Dubai International Airport.pdfRising Above_ Dubai Floods and the Fortitude of Dubai International Airport.pdf
Rising Above_ Dubai Floods and the Fortitude of Dubai International Airport.pdf
 
Cloud Frontiers: A Deep Dive into Serverless Spatial Data and FME
Cloud Frontiers:  A Deep Dive into Serverless Spatial Data and FMECloud Frontiers:  A Deep Dive into Serverless Spatial Data and FME
Cloud Frontiers: A Deep Dive into Serverless Spatial Data and FME
 
Web Form Automation for Bonterra Impact Management (fka Social Solutions Apri...
Web Form Automation for Bonterra Impact Management (fka Social Solutions Apri...Web Form Automation for Bonterra Impact Management (fka Social Solutions Apri...
Web Form Automation for Bonterra Impact Management (fka Social Solutions Apri...
 
Apidays New York 2024 - Accelerating FinTech Innovation by Vasa Krishnan, Fin...
Apidays New York 2024 - Accelerating FinTech Innovation by Vasa Krishnan, Fin...Apidays New York 2024 - Accelerating FinTech Innovation by Vasa Krishnan, Fin...
Apidays New York 2024 - Accelerating FinTech Innovation by Vasa Krishnan, Fin...
 
Emergent Methods: Multi-lingual narrative tracking in the news - real-time ex...
Emergent Methods: Multi-lingual narrative tracking in the news - real-time ex...Emergent Methods: Multi-lingual narrative tracking in the news - real-time ex...
Emergent Methods: Multi-lingual narrative tracking in the news - real-time ex...
 
"I see eyes in my soup": How Delivery Hero implemented the safety system for ...
"I see eyes in my soup": How Delivery Hero implemented the safety system for ..."I see eyes in my soup": How Delivery Hero implemented the safety system for ...
"I see eyes in my soup": How Delivery Hero implemented the safety system for ...
 
Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024
Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024
Modular Monolith - a Practical Alternative to Microservices @ Devoxx UK 2024
 
Axa Assurance Maroc - Insurer Innovation Award 2024
Axa Assurance Maroc - Insurer Innovation Award 2024Axa Assurance Maroc - Insurer Innovation Award 2024
Axa Assurance Maroc - Insurer Innovation Award 2024
 
Apidays New York 2024 - Scaling API-first by Ian Reasor and Radu Cotescu, Adobe
Apidays New York 2024 - Scaling API-first by Ian Reasor and Radu Cotescu, AdobeApidays New York 2024 - Scaling API-first by Ian Reasor and Radu Cotescu, Adobe
Apidays New York 2024 - Scaling API-first by Ian Reasor and Radu Cotescu, Adobe
 
Corporate and higher education May webinar.pptx
Corporate and higher education May webinar.pptxCorporate and higher education May webinar.pptx
Corporate and higher education May webinar.pptx
 
+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...
+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...
+971581248768>> SAFE AND ORIGINAL ABORTION PILLS FOR SALE IN DUBAI AND ABUDHA...
 
Finding Java's Hidden Performance Traps @ DevoxxUK 2024
Finding Java's Hidden Performance Traps @ DevoxxUK 2024Finding Java's Hidden Performance Traps @ DevoxxUK 2024
Finding Java's Hidden Performance Traps @ DevoxxUK 2024
 
Strategies for Landing an Oracle DBA Job as a Fresher
Strategies for Landing an Oracle DBA Job as a FresherStrategies for Landing an Oracle DBA Job as a Fresher
Strategies for Landing an Oracle DBA Job as a Fresher
 
DBX First Quarter 2024 Investor Presentation
DBX First Quarter 2024 Investor PresentationDBX First Quarter 2024 Investor Presentation
DBX First Quarter 2024 Investor Presentation
 

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