How to make simple tracker via swizzling in Swift

Issue #568

Code

Swizzle viewDidAppear

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import UIKit

var mapping: [String: (UIViewController) -> Void] = [:]
var hasSwizzled = false

public func track<T: UIViewController>(_ type: T.Type, block: @escaping (T) -> Void) {
let original = #selector(UIViewController.viewDidAppear(_:))
let swizled = #selector(UIViewController.trackers_viewDidAppear(_:))

if !hasSwizzled {
swizzle(kClass: UIViewController.self, originalSelector: original, swizzledSelector: swizled)
hasSwizzled = true
}

mapping[NSStringFromClass(type)] = { controller in
if let controller = controller as? T {
block(controller)
}
}
}

extension UIViewController {
func trackers_viewDidAppear(_ animated: Bool) {
trackers_viewDidAppear(animated)

let string = NSStringFromClass(type(of: self))
mapping[string]?(self)
}
}

func swizzle(kClass: AnyClass, originalSelector: Selector, swizzledSelector: Selector) {
let originalMethod = class_getInstanceMethod(kClass, originalSelector)
let swizzledMethod = class_getInstanceMethod(kClass, swizzledSelector)

let didAddMethod = class_addMethod(kClass, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod))

if didAddMethod {
class_replaceMethod(kClass, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod))
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}

Track in a declarative way

1
2
3
4
5
6
7
8
9
10
11
track(ListController.self) {
print("list controller has appeared")
}

track(DetailController.self) {
print("detail controller has appeared")
}

track(CouponController.self) { controller in
print("coupon controller has appeared with code \(controller.coupon.code)")
}

How to make simple adapter for delegate and datasource for UICollectionView and UITableView

Issue #567

Code

Make open Adapter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
import UIKit

public protocol AdapterDelegate: class {

/// Apply model to view
func configure(model: Any, view: UIView, indexPath: IndexPath)

/// Handle view selection
func select(model: Any)

/// Size the view
func size(model: Any, containerSize: CGSize) -> CGSize
}

/// Act as DataSource and Delegate for UICollectionView, UITableView
open class Adapter: NSObject,
UICollectionViewDataSource, UICollectionViewDelegateFlowLayout,
UITableViewDataSource, UITableViewDelegate {

public var sections: [Section] = []
public weak var collectionView: UICollectionView?
public weak var tableView: UITableView?
public weak var delegate: AdapterDelegate?

let registryService = RegistryService()

// MARK: - Initialiser
public required init(collectionView: UICollectionView) {
self.collectionView = collectionView
super.init()
}

public required init(tableView: UITableView) {
self.tableView = tableView
super.init()
}

// MARK: - UICollectionViewDataSource
open func numberOfSections(in collectionView: UICollectionView) -> Int {
return sections.count
}

open func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
return sections[section].items.count
}

open func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let item = sections[indexPath.section].items[indexPath.row]
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: item.cellType.typeName,
for: indexPath)

delegate?.configure(model: item.model, view: cell, indexPath: indexPath)

return cell
}

open func collectionView(
_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath) -> UICollectionReusableView {

if let header = sections[indexPath.section].header,
kind == UICollectionElementKindSectionHeader {

let view = collectionView.dequeueReusableSupplementaryView(
ofKind: UICollectionElementKindSectionHeader,
withReuseIdentifier: header.viewType.typeName,
for: indexPath
)

delegate?.configure(model: header.model, view: view, indexPath: indexPath)
return view
} else if let footer = sections[indexPath.section].footer,
kind == UICollectionElementKindSectionFooter {

let view = collectionView.dequeueReusableSupplementaryView(
ofKind: UICollectionElementKindSectionFooter,
withReuseIdentifier: footer.viewType.typeName,
for: indexPath
)

delegate?.configure(model: footer.model, view: view, indexPath: indexPath)
return view
} else {
let view = DummyReusableView()
view.isHidden = true
return view
}
}

// MARK: - UICollectionViewDelegate
open func collectionView(
_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {

let item = sections[indexPath.section].items[indexPath.row]
delegate?.select(model: item.model)
collectionView.deselectItem(at: indexPath, animated: true)
}

open func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {

let item = sections[indexPath.section].items[indexPath.row]
if let size = delegate?.size(model: item.model, containerSize: collectionView.frame.size) {
return size
}

if let size = (collectionViewLayout as? UICollectionViewFlowLayout)?.itemSize {
return size
}

return collectionView.frame.size
}

open func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize {

guard let header = sections[section].header else {
return .zero
}

guard let size = delegate?.size(model: header.model, containerSize: collectionView.frame.size) else {
return .zero
}

return size
}

open func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForFooterInSection section: Int) -> CGSize {

guard let footer = sections[section].footer else {
return .zero
}

guard let size = delegate?.size(model: footer.model, containerSize: collectionView.frame.size) else {
return .zero
}

return size
}

// MARK: - Reload
open func reload(sections: [Section]) {
// Registry
registryService.registerIfNeeded(
collectionView: collectionView,
tableView: tableView,
sections: sections
)

self.sections = sections
collectionView?.reloadData()
tableView?.reloadData()
}

// MARK: - UITableViewDataSource
open func numberOfSections(in tableView: UITableView) -> Int {
return sections.count
}

open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sections[section].items.count
}

open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = sections[indexPath.section].items[indexPath.row]
let cell = tableView.dequeueReusableCell(
withIdentifier: item.cellType.typeName,
for: indexPath
)

delegate?.configure(model: item.model, view: cell, indexPath: indexPath)

return cell
}

// MARK: - UITableViewDelegate
open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = sections[indexPath.section].items[indexPath.row]
delegate?.select(model: item.model)
tableView.deselectRow(at: indexPath, animated: true)
}

open func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let item = sections[indexPath.section].items[indexPath.row]
if let size = delegate?.size(model: item.model, containerSize: tableView.frame.size) {
return size.height
}

return 0
}

open func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
guard let header = sections[section].header else {
return 0
}

guard let size = delegate?.size(model: header.model, containerSize: tableView.frame.size) else {
return 0
}

return size.height
}

open func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
guard let footer = sections[section].footer else {
return 0
}

guard let size = delegate?.size(model: footer.model, containerSize: tableView.frame.size) else {
return 0
}

return size.height
}

open func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let header = sections[section].header else {
return nil
}

guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: header.viewType.typeName) else {
return nil
}

delegate?.configure(model: header.model, view: view, indexPath: IndexPath(row: 0, section: section))
return view
}

open func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
guard let footer = sections[section].footer else {
return nil
}

guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: footer.viewType.typeName) else {
return nil
}

delegate?.configure(model: footer.model, view: view, indexPath: IndexPath(row: 0, section: section))
return view
}
}

Declare data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let sections: [Section] = [
Section(
header: Header(model: Model.header("Information"), viewType: HeaderView.self),
items: [
Item(model: Model.avatar(avatarUrl), cellType: AvatarCell.self),
Item(model: Model.name("Thor"), cellType: NameCell.self),
Item(model: Model.location("Asgard"), cellType: NameCell.self)
]
),
Section(
header: Header(model: Model.header("Skills"), viewType: HeaderView.self),
items: [
Item(model: Model.skill("iOS"), cellType: SkillCell.self),
Item(model: Model.skill("Android"), cellType: SkillCell.self)
]
)
]

adapter.reload(sections: sections)

Configure required blocks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
extension ProfileViewController: AdapterDelegate {
func configure(model: Any, view: UIView, indexPath: IndexPath) {
guard let model = model as? Model else {
return
}

switch (model, view) {
case (.avatar(let string), let cell as Avatarcell):
cell.configure(string: string)
case (.name(let name), let cell as NameCell):
cell.configure(string: name)
case (.header(let string), let view as HeaderView):
view.configure(string: string)
default:
break
}
}

func select(model: Any) {
guard let model = model as? Model else {
return
}

switch model {
case .skill(let skill):
let skillController = SkillController(skill: skill)
navigationController?.pushViewController(skillController, animated: true)
default:
break
}
}

func size(model: Any, containerSize: CGSize) -> CGSize {
guard let model = model as? Model else {
return .zero
}

switch model {
case .name:
return CGSize(width: containerSize.width, height: 40)
case .avatar:
return CGSize(width: containerSize.width, height: 200)
case .header:
return CGSize(width: containerSize.width, height: 30)
default:
return .zero
}
}
}

Extending Manager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AccordionManager<T>: Manager<T> {
private var collapsedSections = Set<Int>()

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return collapsedSections.contains(section)
? 0 : sections[section].items.count
}

func toggle(section: Int) {
if collapsedSections.contains(section) {
collapsedSections.remove(section)
} else {
collapsedSections.insert(section)
}

let indexSet = IndexSet(integer: section)
tableView?.reloadSections(indexSet, with: .automatic)
}
}

How to make progress HUD in Swift

Issue #566

Code

Create a container that has blur effect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class HUDContainer: UIVisualEffectView, AnimationAware {
private let innerContentView: UIView & AnimationAware
public let label = UILabel()
public var text: String? {
didSet {
label.text = text
label.sizeToFit()
label.isHidden = text == nil
}
}

public init(contentView: UIView & AnimationAware) {
self.innerContentView = contentView
super.init(effect: UIBlurEffect(style: .light))
self.contentView.addSubview(innerContentView)
self.contentView.addSubview(label)

innerContentView.pinEdgesToSuperview()
configure()
}

public required init?(coder aDecoder: NSCoder) {
fatalError()
}

public func configure() {
layer.cornerRadius = 8
layer.masksToBounds = true

label.isHidden = false
label.font = UIFont.preferredFont(forTextStyle: .body)
label.textColor = UIColor.black
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
label.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 10),
label.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: -10),
])
}

public func startAnimation() {
innerContentView.startAnimation()
}

public func endAnimation() {
innerContentView.stopAnimation()
}
}

Make error view with 2 cross lines

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import UIKit

public class ErrorView: UIView, AnimationAware {
public let line1 = CAShapeLayer()
public let line2 = CAShapeLayer()

public let animation1 = CASpringAnimation(keyPath: #keyPath(CALayer.transform))
public let animation2 = CASpringAnimation(keyPath: #keyPath(CALayer.transform))

public var lineColor: UIColor = UIColor.darkGray
public var duration: TimeInterval = 0.75

public override init(frame: CGRect) {
super.init(frame: frame)
layer.addSublayer(line1)
layer.addSublayer(line2)
configure()
}

public required init?(coder aDecoder: NSCoder) {
fatalError()
}

public override func layoutSubviews() {
super.layoutSubviews()

configureSize()
}

public func configure() {
[line1, line2].forEach {
$0.backgroundColor = lineColor.cgColor
}

[animation1, animation2].forEach {
$0.fromValue = 0
$0.damping = 0.33
$0.initialVelocity = 0.01
$0.mass = 0.2
$0.duration = duration
$0.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ)
$0.timingFunction = CAMediaTimingFunction(name: .easeIn)
}

animation1.toValue = CGFloat.pi / 4
animation2.toValue = -CGFloat.pi / 4
}

private func configureSize() {
guard line1.frame.width <= 0 else {
return
}

[line1, line2].forEach {
$0.cornerRadius = 3
$0.frame.size = CGSize(width: bounds.width*0.6, height: 6)
$0.position = layer.position
}
}

public override func didMoveToWindow() {
super.didMoveToWindow()

line1.transform = CATransform3DIdentity
line2.transform = CATransform3DIdentity
}

public func startAnimation() {
line1.transform = CATransform3DMakeRotation(CGFloat.pi/4, 0, 0, 1.0)
line2.transform = CATransform3DMakeRotation(-CGFloat.pi/4, 0, 0, 1.0)

line1.add(animation1, forKey: nil)
line2.add(animation2, forKey: nil)
}

public func stopAnimation() {
line1.removeAllAnimations()
line2.removeAllAnimations()
}
}

Make loading progress using replicator layers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import UIKit

public class ProgressView: UIView, AnimationAware {
public let replicatorLayer = CAReplicatorLayer()
public let animation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
public let line = CALayer()

public var lineCount: Int = 12
public var duration: TimeInterval = 1.0
public var lineSize: CGSize = CGSize(width: 20, height: 6)
public var lineColor: UIColor = UIColor.darkGray

public override init(frame: CGRect) {
super.init(frame: .zero)

configure()
}

public required init?(coder aDecoder: NSCoder) {
fatalError()
}

public func configure() {
let angle = CGFloat.pi * 2 / CGFloat(lineCount)
let rotation = CATransform3DMakeRotation(angle, 0, 0, 1.0)

replicatorLayer.instanceTransform = rotation
replicatorLayer.instanceCount = lineCount
replicatorLayer.instanceDelay = duration / TimeInterval(lineCount)

line.backgroundColor = lineColor.cgColor
line.frame.size = lineSize
line.cornerRadius = lineSize.height / 2

animation.fromValue = 1.0
animation.toValue = 0.0
animation.repeatCount = Float.greatestFiniteMagnitude
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.duration = duration

replicatorLayer.addSublayer(line)
layer.addSublayer(replicatorLayer)

// x: the larger, the closer to center
// y: half the height, changing affects rotation of lines
line.position = CGPoint(x: 48, y: 75)
}

public override func layoutSubviews() {
super.layoutSubviews()

replicatorLayer.frame = bounds
}

public func startAnimation() {
line.add(animation, forKey: nil)
}

public func stopAnimation() {
line.removeAllAnimations()
}
}

Make success view with check mark animation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import UIKit

public class SuccessView: UIView, AnimationAware {
public let shapeLayer = CAShapeLayer()
public let animation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.strokeEnd))

public var lineColor: UIColor = UIColor.darkGray
public var duration: TimeInterval = 0.25

public override init(frame: CGRect) {
super.init(frame: frame)
layer.addSublayer(shapeLayer)
configure()
}

public required init?(coder aDecoder: NSCoder) {
fatalError()
}

public override func layoutSubviews() {
super.layoutSubviews()

configurePath()
}

public func configure() {
shapeLayer.lineCap = .round
shapeLayer.lineJoin = .round
shapeLayer.fillColor = nil
shapeLayer.strokeColor = lineColor.cgColor
shapeLayer.lineWidth = 6

animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = duration
}

private func configurePath() {
let size = CGSize(width: 80, height: 60)
shapeLayer.frame = CGRect(origin: .zero, size: size)
shapeLayer.position = layer.position

let path = UIBezierPath()
path.move(to: CGPoint(x: size.width * 0, y: size.height * 0.48))
path.addLine(to: CGPoint(x: size.width * 0.38, y: size.height))
path.addLine(to: CGPoint(x: size.width, y: size.height * 0.01))

shapeLayer.path = path.cgPath
}

public override func didMoveToWindow() {
super.didMoveToWindow()

shapeLayer.strokeEnd = 0.0
}

public func startAnimation() {
shapeLayer.strokeEnd = 1.0
shapeLayer.add(animation, forKey: nil)
}

public func stopAnimation() {
shapeLayer.removeAllAnimations()
}
}

How to build static site using Publish

Issue #564

Code

Steps

Step 1: Create executable

1
swift package init --type executable

Step 2: Edit package

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription

let package = Package(
name: "PumaSwiftWeb",
dependencies: [
.package(url: "https://github.com/johnsundell/publish.git", from: "0.1.0")
],
targets: [
.target(
name: "PumaSwiftWeb",
dependencies: [
"Publish"
]
)
]
)

Step 3: Double click Package.swift, Xcode opens that in a generated project
Step 4: Declare website. Go to Sources/main.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import Publish
import Plot
import Ink
import Foundation

struct PumaSwiftWeb: Website {
enum SectionID: String, WebsiteSectionID {
case gettingStarted
case workflow
case tasks
case about
}

struct ItemMetadata: WebsiteItemMetadata {}

var url = URL(string: "https://pumaswift.github.io")!
var name = "Puma Swift"
var description = "Build utilities in pure Swift"
var language: Language { .english }
var imagePath: Path? { "https://avatars2.githubusercontent.com/u/54233247?s=200&v=4" }
}

try PumaSwiftWeb()
.publish(withTheme: .foundation)

Step 5: Create Content folder
Step 6: swift run
Step 7: Copy Output to root and push to GitHub

How to send message from bot to Slack in Swift

Issue #560

Create a bot

Post message

After adding bot to workspace, we’ll get OAuth Access Token and Bot User OAuth Access Token. Use Bot User OAuth Access Token to test drive bot message sending

https://api.slack.com/methods/chat.postMessage/test

The request url is like

1
https://slack.com/api/chat.postMessage?token=xoxb-7212342835698-890815481123-abcdGgDEFfm2joQs1Vj5mABC&channel=random&text=hello&pretty=1

Code from Puma

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import Foundation

public class Slack {
public var name: String = "Send message to Slack"
public var isEnabled = true

private var message: Message?

public init(_ closure: (Slack) -> Void = { _ in }) {
closure(self)
}
}

public extension Slack {
struct Message {
let token: String
let channel: String
let text: String
let username: String?

public init(
token: String,
channel: String,
text: String,
username: String
) {
self.token = token
self.channel = channel
self.text = text
self.username = username
}
}

func post(message: Message) {
self.message = message
}
}

extension Slack: Task {
public func run(workflow: Workflow, completion: @escaping TaskCompletion) {
guard let message = message else {
completion(.failure(PumaError.invalid))
return
}

let sender = MessageSender()
sender.send(message: message, completion: { result in
switch result {
case .success:
Deps.console.success("Message posted successfully")
case .failure(let error):
Deps.console.error("Failed: \(error.localizedDescription)")
}
completion(result)
})
}
}

private class MessageSender {
struct Response: Decodable {
let ok: Bool
let error: String?
}

func send(message: Slack.Message, completion: @escaping (Result<(), Error>) -> Void) {
guard let baseUrl = URL(string: "https://slack.com/api/chat.postMessage") else {
completion(.failure(PumaError.invalid))
return
}

var components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: false)
components?.queryItems = [
URLQueryItem(name: "token", value: message.token),
URLQueryItem(name: "channel", value: message.channel),
URLQueryItem(name: "text", value: message.text),
URLQueryItem(name: "pretty", value: "1")
]

if let username = message.username {
components?.queryItems?.append(
URLQueryItem(name: "username", value: username)
)
}

guard let requestUrl = components?.url else {
completion(.failure(PumaError.invalid))
return
}

var request = URLRequest(url: requestUrl)
request.allHTTPHeaderFields = [
"Accept": "application/json"
]

let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
guard let data = data else {
completion(.failure(error ?? PumaError.invalid))
return
}

do {
let response = try JSONDecoder().decode(Response.self, from: data)
if response.ok {
completion(.success(()))
} else {
completion(.failure(PumaError.from(string: response.error)))
}
} catch {
completion(.failure(error))
}
})

task.resume()
}
}

Read more

How to parse xcrun simctl devices

Issue #559

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public class GetDestinations {
public init() {}

public func getAvailable(workflow: Workflow) throws -> [Destination] {
let processHandler = DefaultProcessHandler(filter: { $0.starts(with: "name=") })
let string = try CommandLine().runBash(
workflow: workflow,
program: "xcrun simctl",
arguments: [
"list",
"devices",
"-j"
],
processHandler: processHandler
)

guard let data = string.data(using: .utf8) else {
throw PumaError.invalid
}

let response: Response = try JSONDecoder().decode(Response.self, from: data)
let devicesWithOS: [DeviceWithOS] = response.devices.flatMap({ key, value in
return value.map({ DeviceWithOS(device: $0, os: key) })
})

let destinations: [Destination] = try devicesWithOS
.filter({ withOS in
return withOS.device.isAvailable
})
.compactMap({ withOS in
guard
let platform = self.platform(withOS: withOS),
let os = try self.os(withOS: withOS)
else {
return nil
}

var destination = Destination(
name: withOS.device.name,
platform: platform,
os: os
)
destination.id = withOS.device.udid
return destination
})

return destinations
}

func findId(workflow: Workflow, destination: Destination) throws -> String? {
let availableDestinations = try self.getAvailable(workflow: workflow)
return availableDestinations.first(where: { $0 == destination })?.id
}

// MARK: - Private

private func platform(withOS: DeviceWithOS) -> String? {
let list: [String] = [
Destination.Platform.iOS,
Destination.Platform.watchOS,
Destination.Platform.macOS,
Destination.Platform.tvOS,
]

return list.first(where: { withOS.os.contains($0) })
}

// com.apple.CoreSimulator.SimRuntime.iOS-13-2
private func os(withOS: DeviceWithOS) throws -> String? {
guard let string = try withOS.os.matches(pattern: #"(-\d+)+"#).first else {
return nil
}

return string.dropFirst().replacingOccurrences(of: "-", with: ".")
}
}

private struct Response: Decodable {
let devices: [String: [Device]]
}

private struct Device: Decodable {
let state: String
let name: String
let udid: String
let isAvailable: Bool
}

private struct DeviceWithOS {
let device: Device
let os: String
}

How to parse xcrun instruments devices

Issue #558

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class GetDestinations {
public init() {}

public func getAvailable(workflow: Workflow) throws -> [Destination] {
let processHandler = DefaultProcessHandler(filter: { $0.starts(with: "name=") })
let string = try CommandLine().runBash(
workflow: workflow,
program: "xcrun instruments",
arguments: [
"-s",
"devices"
],
processHandler: processHandler
)

// Ex: iPad Air (11.0.1) [7A5EAD29-D870-49FB-9A9B-C81079620AC9] (Simulator)
let destinations: [Destination] = try string
.split(separator: "\n")
.map({ String($0) })
.filter({ try $0.hasPattern(pattern: #"\[.+\]"#) })
.compactMap({ (line) -> Destination? in
parse(line)
})

return destinations
}

func parse(_ line: String) -> Destination? {
guard var id = try? line.matches(pattern: #"\[.+\]"#).first else {
return nil
}

var line = line
line = line.replacingOccurrences(of: id, with: "")
id = id
.replacingOccurrences(of: "[", with: "")
.replacingOccurrences(of: "]", with: "")

let isSimulator = line.contains("(Simulator)")
line = line.replacingOccurrences(of: "(Simulator)", with: "")

var os = (try? line.matches(pattern: #"\((\d+\.)?(\d+\.)?(\*|\d+)\)"#).first) ?? ""
let name = line
.replacingOccurrences(of: os, with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)

os = os.replacingOccurrences(of: "(", with: "")
.replacingOccurrences(of: ")", with: "")

let device = self.device(name: name)

if os.isEmpty {
return Destination(name: name, id: id)
} else {
let platform = isSimulator ? "\(device) Simulator" : device
return Destination(name: name, platform: platform, os: os)
}
}

// MARK: - Private

private func device(name: String) -> String {
if name.starts(with: "iPad") || name.starts(with: "iPhone") {
return Destination.Platform.iOS
} else if name.starts(with: "Apple Watch") {
return Destination.Platform.watchOS
} else if name.starts(with: "Apple TV") {
return Destination.Platform.tvOS
} else if name.containsIgnoringCase("mac") {
return Destination.Platform.macOS
} else {
return Destination.Platform.iOS
}
}
}

How to generate xml in Swift

Issue #556

Instead of learning XMLParser, we can make a lightweight version

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import Foundation

public protocol XmlItem {
func toLines() -> [String]
}

public struct XmlString: XmlItem {
public let key: String
public let value: String

public init(key: String, value: String) {
self.key = key
self.value = value
}

public func toLines() -> [String] {
return [
"<key>\(key)</key>",
"<string>\(value)</string>"
] as [String]
}
}

public struct XmlBool: XmlItem {
public let key: String
public let value: Bool

public init(key: String, value: Bool) {
self.key = key
self.value = value
}

public func toLines() -> [String] {
let string = value ? "<true/>" : "<false/>"
return [
"<key>\(key)</key>",
"\(string)"
] as [String]
}
}

public struct XmlDict: XmlItem {
public let key: String
public let items: [XmlItem]

public init(key: String, items: [XmlItem]) {
self.key = key
self.items = items
}

public func toLines() -> [String] {
var lines = [String]()
lines.append("<dict>")
lines.append(contentsOf: items.flatMap({ $0.toLines() }))
lines.append("</dict>")

return lines
}
}

public class XmlGenerator {
public init() {}
public func generateXml(_ items: [XmlItem]) -> String {
let content = items.flatMap({ $0.toLines() }).joined(separator: "\n")
let xml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\(content)
</dict>
</plist>
"""
return xml
}

public func xmlItems(dictionary: [String: Any]) -> [XmlItem] {
return dictionary.flatMap({ (key, value) -> [XmlItem] in
switch value {
case let string as String:
return [XmlString(key: key, value: string)]
case let bool as Bool:
return [XmlBool(key: key, value: bool)]
case let nestedDictionary as [String: Any]:
return xmlItems(dictionary: nestedDictionary)
default:
return []
}
})
}
}

How to use KVO in Swift

Issue #554

A class must inherit from NSObject, and we have 3 ways to trigger property change

Use setValue(value: AnyObject?, forKey key: String) from NSKeyValueCoding

class MyObjectToObserve: NSObject {
    var myDate = NSDate()
    func updateDate() {
        setValue(NSDate(), forKey: "myDate")
    }
}

Use willChangeValueForKey and didChangeValueForKey from NSKeyValueObserving

class MyObjectToObserve: NSObject {
    var myDate = NSDate()
    func updateDate() {
        willChangeValueForKey("myDate")
        myDate = NSDate()
        didChangeValueForKey("myDate")
    }
}

Use dynamic. See Swift Type Compatibility

You can also use the dynamic modifier to require that access to members be dynamically dispatched through the Objective-C runtime if you’re using APIs like key–value observing that dynamically replace the implementation of a method.

class MyObjectToObserve: NSObject {
    dynamic var myDate = NSDate()
    func updateDate() {
        myDate = NSDate()
    }
}

And property getter and setter is called when used. You can verify when working with KVO. This is an example of computed property

class MyObjectToObserve: NSObject {
    var backing: NSDate = NSDate()
    dynamic var myDate: NSDate {
        set {
            print("setter is called")
            backing = newValue
        }
        get {
            print("getter is called")
            return backing
        }
    }
}

Read more

How to use precondition and assert in Swift

Issue #553

Read Swift asserts - the missing manual

1
2
3
4
5
6
7
                        debug	release	  release
function -Onone -O -Ounchecked
assert() YES NO NO
assertionFailure() YES NO NO**
precondition() YES YES NO
preconditionFailure() YES YES YES**
fatalError()* YES YES YES

And from Interesting discussions on Swift Evolution

– assert: checking your own code for internal errors

– precondition: for checking that your clients have given you valid arguments.

Read more

How to use regular expression in Swift

Issue #551

Find matches

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import Foundation

public extension String {
func matches(pattern: String) throws -> [String] {
let regex = try NSRegularExpression(pattern: pattern)
let results = regex.matches(in: self, options: [], range: NSRange(self.startIndex..., in: self))
return results.compactMap({ result in
if let range = Range(result.range, in: self) {
return String(self[range])
} else {
return nil
}
})
}

func hasPattern(pattern: String) throws -> Bool {
return try !matches(pattern: pattern).isEmpty
}
}

func testRegex() throws {
let string = "iPad Air (11.0.1) [7A5EAD29-D870-49FB-9A9B-C81079620AC9] (Simulator)"
let matches = try string.matches(pattern: #"\[.+\]"#)
XCTAssertEqual(matches.first, "[7A5EAD29-D870-49FB-9A9B-C81079620AC9]")
}

Range and NSRange

1
2
let string: String
let nsRange: NSRange = NSRange(string.startIndex..., in: string)
1
2
3
4
let string: String
let nsRange: NSRange
let range: Range = Range(nsRange, in: string)
let substring: Substring = string[range]

Common expression

Version

1
2
3
#"\((\d+\.)?(\d+\.)?(\*|\d+)\)"#

(11.0.1)

Read more

How to keep command line tool running with async in Swift

Issue #549

Use Semaphore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Sequence: Task {
public func run(workflow: Workflow, completion: @escaping TaskCompletion) {
let semaphore = DispatchSemaphore(value: 0)
runFirst(tasks: tasks, workflow: workflow, completion: { result in
completion(result)
semaphore.signal()
})

semaphore.wait()
}
}

public class Concurrent: Task {
public func run(workflow: Workflow, completion: @escaping (Result<(), Error>) -> Void) {
var runTaskCount = 0
let taskCount = tasks.count
let semaphore = DispatchSemaphore(value: 0)

tasks.forEach { task in
task.run(workflow: workflow, completion: { _ in
self.serialQueue.async {
runTaskCount += 1
if runTaskCount == taskCount {
completion(.success(()))
semaphore.signal()
}
}
})
}

semaphore.wait()
}
}

Read more

How to sync an async function in Swift

Issue #547

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func sync<T>(_ work: (@escaping ([T]) -> Void) -> Void) -> [T] {
let semaphore = DispatchSemaphore(value: 1)
var results = [T]()
work { values in
results = values
semaphore.signal()
}

return results
}

sync({ completion in
service.load(completion)
})

How to not use protocol extension in Swift

Issue #542

With protocol extension

See code Puma

1
Build is UsesXcodeBuild is UsesCommandLine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/// Any task that uses command line
public protocol UsesCommandLine: AnyObject {}

public extension UsesCommandLine {
func runBash(
workflow: Workflow,
program: String,
arguments: [String],
processHandler: ProcessHandler = DefaultProcessHandler()
) throws {
// Code
}

func runProcess(
_ process: Process,
workflow: Workflow,
processHandler: ProcessHandler = DefaultProcessHandler()
) throws {
// Code
}
}

/// Any task that uses xcodebuild
public protocol UsesXcodeBuild: UsesCommandLine {
var xcodebuild: Xcodebuild { get set }
}

public extension UsesXcodeBuild {
func runXcodeBuild(workflow: Workflow) throws {
try runBash(
workflow: workflow,
program: "xcodebuild",
arguments: xcodebuild.arguments,
processHandler: XcodeBuildProcessHandler()
)
}
}

public class Build: UsesXcodeBuild {
public var isEnabled = true
public var xcodebuild = Xcodebuild()
}

Without protocol extension

1
Build has Xcodebuild has CommandLine
1
2
3
4
5
6
7
8
9
10
11
12
13
public struct Xcodebuild {
var arguments: [String] = []

@discardableResult
func run(workflow: Workflow) throws -> String {
return try CommandLine().runBash(
workflow: workflow,
program: "xcodebuild",
arguments: arguments,
processHandler: XcodeBuildProcessHandler()
)
}
}

How to show localized text in SwiftUI

Issue #533

1
2
3
4
5
6
7
8
9
10
11
12
struct ContentView: View {
@Environment(\.locale) var locale: Locale

var body: some View {
VStack {
Text(LocalizedStringKey("hello"))
.font(.largeTitle)
Text(flag(from: locale.regionCode!))
.font(.largeTitle)
}
}
}

How to do lense in Swift

Issue #528

What is lense

https://www.schoolofhaskell.com/school/to-infinity-and-beyond/pick-of-the-week/a-little-lens-starter-tutorial

A lens is a first-class reference to a subpart of some data type. For instance, we have _1 which is the lens that “focuses on” the first element of a pair. Given a lens there are essentially three things you might want to do

View the subpart
Modify the whole by changing the subpart
Combine this lens with another lens to look even deeper

Before, use functional approach

http://chris.eidhof.nl/post/lenses-in-swift/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Person {
let name_ : String
let address_ : Address
}

struct Address {
let street_ : String
let city_ : String
}

struct Lens<A,B> {
let from : A -> B
let to : (B, A) -> A
}

let address : Lens<Person,Address> = Lens(from: { $0.address_ }, to: {
Person(name_: $1.name_, address_: $0)
})

let street : Lens<Address,String> = Lens(from: { $0.street_ }, to: {
Address(street_: $0, city_: $1.city_)
})

let newAddress = street.to("My new street name", existingAddress)

Now, with Keypath

https://iankeen.tech/2018/06/05/type-safe-temporary-models/
https://swiftbysundell.com/articles/defining-testing-data-in-swift/

Use KeyPath to modify struct data

1
2
3
4
5
6
7
8
9
10
11
12
protocol Stubbable: Identifiable {
static func stub(withID id: Identifier<Self>) -> Self
}

extension Stubbable {
func setting<T>(_ keyPath: WritableKeyPath<Self, T>,
to value: T) -> Self {
var stub = self
stub[keyPath: keyPath] = value
return stub
}
}

How to convert from callback to Future Publisher in Combine

Issue #527

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import Foundation
import Combine

public typealias TaskCompletion = (Result<(), Error>) -> Void

public protocol Task: AnyObject {
var name: String { get }
func run(workflow: Workflow, completion: TaskCompletion)
}

public extension Task {
func asPublisher(workflow: Workflow) -> AnyPublisher<(), Error> {
return Future({ completion in
self.run(workflow: workflow, completion: completion)
}).eraseToAnyPublisher()
}
}

let sequence = Publishers.Sequence<[AnyPublisher<(), Error>], Error>(
sequence: tasks.map({ $0.asPublisher(workflow: self) })
)

How to make init with closure in Swift

Issue #526

1
2
3
4
5
6
7
public class Build: UsesXcodeBuild {
public var arguments = [String]()

public init(_ closure: (Build) -> Void = { _ in }) {
closure(self)
}
}

Use function builder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Workflow {
public var workingDirectory: String = "."
public let tasks: [Task]

public init(@TaskBuilder builder: () -> [Task]) {
self.tasks = builder()
self.tasks.forEach { task in
task.workflow = self
}
}

public init(@TaskBuilder builder: () -> Task) {
self.tasks = [builder()]
self.tasks.forEach { task in
task.workflow = self
}
}
}

public func run(@TaskBuilder builder: () -> [Task]) {
let workflow = Workflow(builder: builder)
workflow.run()
}

public func run(@TaskBuilder builder: () -> Task) {
let workflow = Workflow(builder: builder)
workflow.run()
}

How to test a developing package with Swift Package Manager

Issue #525

Use macOS Command Line project

Example Puma

  • Create a new macOS project, select Command Line ToolScreenshot 2019-11-30 at 22 40 35
  • Drag Puma.xcodeproj as a sub project of our test project
  • Go to our TestPuma target, under Link Binary with Libraries, select Puma framework
Screenshot 2019-11-30 at 22 41 18
  • Puma has dependencies on PumaCore and PumaiOS, but in Xcode we only need to select Puma framework

  • In code, we need to explicitly import PumaiOS framework if we use any of its classes

1
2
3
4
5
6
7
8
9
10
11
import Foundation
import Puma
import PumaiOS

func testDrive() {
run {
SetVersionNumber {
$0.buildNumberForAllTarget("1.1")
}
}
}
  • As our Puma.xcodeproj is inside this test project, we can drill down into our Puma.xcodeproj and update the code.

Workspace

Instead of dragging Puma as a subproject of TestPuma, we can use workspace, and link Puma frameworks

Screenshot 2019-12-14 at 21 36 32

Troubleshooting

Code signing for frameworks

To avoid signing issue, we need to select a Team for all frameworks

not valid for use in process using Library Validation: mapped file has no Team ID and is not a platform binary (signed with custom identity or adhoc?

Library not loaded

Need to set runpath search path, read https://stackoverflow.com/questions/28577692/macos-command-line-tool-with-swift-cocoa-framework-library-not-loaded

Specify LD_RUNPATH_SEARCH_PATHS = @executable_path in Build Settings

missing required module ‘clibc’

Take a look at Puma -> SPMLibc, there’s header search path

1
$(SRCROOT)/.build/checkouts/swift-package-manager/Sources/clibc/include

which is at the .build folder inside root

Screenshot 2019-12-14 at 21 55 30

So for our TestPuma target, we need to add this header search path with the correct path

1
$(SRCROOT)/../../.build/checkouts/swift-package-manager/Sources/clibc/include
Screenshot 2019-12-14 at 21 55 55

Read more

How to use method from protocol extension in Swift

Issue #524

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// Any task that uses command line
public protocol UsesCommandLine: AnyObject {
var program: String { get }
var arguments: Set<String> { get set }
}

public extension UsesCommandLine {
func run() throws {
let command = "\(program) \(arguments.joined(separator: " "))"
Log.command(command)
_ = try Process().run(command: command)
}
}

class Build: UsesCommandLine {
public func run() throws {
arguments.insert("build")
try (self as UsesCommandLine).run()
}
}

How to organize dependencies in Swift Package Manager

Issue #523

In Puma I want to make build tools for iOS and Android, which should share some common infrastructure. So we can organize dependencies like.

Puma -> PumaAndroid, PumaiOS -> PumaCore -> xcbeautify, Files, Colorizer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription

let package = Package(
name: "Puma",
platforms: [.macOS("10.15")],
products: [
.library(name: "Puma", targets: ["Puma"])
],
dependencies: [
.package(
url: "https://github.com/thii/xcbeautify.git",
.upToNextMajor(from: "0.4.1")
),
.package(
url: "https://github.com/getGuaka/Colorizer",
.upToNextMajor(from: "0.2.0")
),
.package(
url: "https://github.com/JohnSundell/Files.git",
.upToNextMajor(from : "3.1.0")
)
],
targets: [
.target(
name: "Puma",
dependencies: [
"PumaiOS",
"PumaAndroid",
],
path: "Sources/Puma"
),
.target(
name: "PumaCore",
dependencies: [
"XcbeautifyLib",
"Colorizer",
"Files"
],
path: "Sources/Core"
),
.target(
name: "PumaiOS",
dependencies: [
"PumaCore"
],
path: "Sources/iOS"
),
.target(
name: "PumaAndroid",
dependencies: [
"PumaCore"
],
path: "Sources/Android"
),
.testTarget(
name: "PumaTests",
dependencies: ["Puma"
]
)
]
)

How to provide configurations in Swift

Issue #522

Sometimes ago I created Puma, which is a thin wrapper around Xcode commandline tools, for example xcodebuild

There’s lots of arguments to pass in xcodebuild, and there are many tasks like build, test and archive that all uses this command.

Use Options struct to encapsulate parameters

To avoid passing many parameters into a class, I tend to make an Options struct to encapsulate all passing parameters. I also use composition, where Build.Options and Test.Options contains Xcodebuild.Options

This ensures that the caller must provide all needed parameters, when you can compile you are ensured that all required parameters are provided.

This is OK, but a bit rigid in a way that there are many more parameters we can pass into xcodebuild command, so we must provide a way for user to alter or add more parameters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
let xcodebuildOptions = Xcodebuild.Options(
workspace: nil,
project: "TestApp",
scheme: "TestApp",
configuration: Configuration.release,
sdk: Sdk.iPhone,
signing: .auto(automaticSigning),
usesModernBuildSystem: true
)

run {
SetVersionNumber(options: .init(buildNumber: "1.1"))
SetBuildNumber(options: .init(buildNumber: "2"))
Build(options: .init(
buildOptions: xcodebuildOptions,
buildsForTesting: true
))

Test(options: .init(
buildOptions: xcodebuildOptions,
destination: Destination(
platform: Destination.Platform.iOSSimulator,
name: Destination.Name.iPhoneXr,
os: Destination.OS.os12_2
)
))
}

Here is how to convert from Options to arguments to pass to our command. Because each parameter has different specifiers, like with double hyphens --flag=true, single hyphen -flag=true or just hyphen with a space between parameter key and value -flag true, we need to manually specify that, and concat them with string. Luckily, the order of parameters is not important

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public struct Xcodebuild {
public struct Options {
/// build the workspace NAME
public let workspace: String?
/// build the project NAME
public let project: String
/// build the scheme NAME
public let scheme: String
/// use the build configuration NAME for building each target
public let configuration: String
/// use SDK as the name or path of the base SDK when building the project
public let sdk: String?
public let signing: Signing?
public let usesModernBuildSystem: Bool

public init(
workspace: String? = nil,
project: String,
scheme: String,
configuration: String = Configuration.debug,
sdk: String? = Sdk.iPhoneSimulator,
signing: Signing? = nil,
usesModernBuildSystem: Bool = true) {

self.workspace = workspace
self.project = project
self.scheme = scheme
self.configuration = configuration
self.sdk = sdk
self.signing = signing
self.usesModernBuildSystem = usesModernBuildSystem
}
}
}

extension Xcodebuild.Options {
func toArguments() -> [String?] {
return [
workspace.map{ "-workspace \($0.addingFileExtension("xcworkspace"))" },
"-project \(project.addingFileExtension("xcodeproj"))",
"-scheme \(scheme)",
"-configuration \(configuration)",
sdk.map { "-sdk \($0)" },
"-UseModernBuildSystem=\(usesModernBuildSystem ? "YES": "NO")"

]
}
}

Use convenient methods

Another way is to have a Set<String> as a container of parameters, and provide common method via protocol extension

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/// Any task that uses command line
public protocol UsesCommandLine: AnyObject {
var program: String { get }
var arguments: Set<String> { get set }
}

public extension UsesCommandLine {
func run() throws {
let command = "\(program) \(arguments.joined(separator: " "))"
Log.command(command)
_ = try Process().run(command: command)
}
}

/// Any task that uses xcodebuild
public protocol UsesXcodeBuild: UsesCommandLine {}

public extension UsesXcodeBuild {
var program: String { "xcodebuild" }

func `default`(project: String, scheme: String) {
self.project(project)
self.scheme(scheme)
self.configuration(Configuration.debug)
self.sdk(Sdk.iPhoneSimulator)
self.usesModernBuildSystem(enabled: true)
}

func project(_ name: String) {
arguments.insert("-project \(name.addingFileExtension("xcodeproj"))")
}

func workspace(_ name: String) {
arguments.insert("-workspace \(name.addingFileExtension("xcworkspace"))")
}

func scheme(_ name: String) {
arguments.insert("-scheme \(name)")
}

func configuration(_ configuration: String) {
arguments.insert("-configuration \(configuration)")
}

func sdk(_ sdk: String) {
arguments.insert("-sdk \(sdk)")
}

func usesModernBuildSystem(enabled: Bool) {
arguments.insert("-UseModernBuildSystem=\(enabled ? "YES": "NO")")
}
}

class Build: Task, UsesXcodeBuild {}
class Test: Task, UsesXcodeBuild {}

Now the call site looks like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
run {
SetVersionNumber {
$0.versionNumberForAllTargets("1.1")
}

SetBuildNumber {
$0.buildNumberForAllTargets("2")
}

Build {
$0.default(project: "TestApp", scheme: "TestApp")
$0.buildsForTesting(enabled: true)
}

Test {
$0.default(project: "TestApp", scheme: "TestApp")
$0.testsWithoutBuilding(enabled: true)
$0.destination(Destination(
platform: Destination.Platform.iOSSimulator,
name: Destination.Name.iPhoneXr,
os: Destination.OS.os12_2
))
}
}

How to test UserDefaults in iOS

Issue #518

1
2
let userDefaults = UserDefaults(suiteName: suiteName)
userDefaults.removePersistentDomain(forName: suiteName)

https://developer.apple.com/documentation/foundation/userdefaults/1417339-removepersistentdomain

Calling this method is equivalent to initializing a user defaults object with init(suiteName:) passing domainName, and calling the removeObject(forKey:) method on each of its keys.

Read more

How to group array by property in Swift

Issue #510

Use Dictionary(grouping:by:)

1
2
3
4
5
6
7
8
9
10
func groups(countries: [Country]) -> [Group] {
let dictionary = Dictionary(grouping: countries, by: { String($0.name.prefix(1)) })
let groups = dictionary
.map({ (key: String, value: [Country]) -> Group in
return Group(initial: key, countries: value)
})
.sorted(by: { $0.initial < $1.initial })

return groups
}

How to map error in Combine

Issue #506

When a function expects AnyPublisher<[Book], Error> but in mock, we have Just

1
2
3
4
5
6
7
func getBooks() -> AnyPublisher<[Book], Error> {
return Just([
Book(id: "1", name: "Book 1"),
Book(id: "2", name: "Book 2"),
])
.eraseToAnyPublisher()
}

There will be a mismatch, hence compile error

Cannot convert return expression of type ‘AnyPublisher<[Book], Just.Failure>’ (aka ‘AnyPublisher<Array, Never>’) to return type ‘AnyPublisher<[Book], Error>’

The reason is because Just produces Never, not Error. The workaround is to introduce Error

1
2
3
enum AppError: Error {
case impossible
}
1
2
3
4
5
6
7
8
func getBooks() -> AnyPublisher<[Book], Error> {
return Just([
Book(id: "1", name: "Book 1"),
Book(id: "2", name: "Book 2"),
])
.mapError({ _ in AppError.impossible })
.eraseToAnyPublisher()
}

Updated at 2020-11-07 20:30:01

How to make Swift Package Manager package for multiple platforms

Issue #504

https://twitter.com/NeoNacho/status/1181245484867801088?s=20

There’s no way to have platform specific sources or targets today, so you’ll have to take a different approach. I would recommend wrapping all OS specific files in #if os and just having one target. For tests, you could do something similar, one test target, but conditional tests

Every files are in Sources folder, so we can use platform and version checks. For example Omnia is a Swift Package Manager that supports iOS, tvOS, watchOS, macOS and Catalyst.

For macOS only code, need to check for AppKit and Catalyst

https://github.com/onmyway133/Omnia/blob/master/Sources/macOS/ClickedCollectionView.swift

1
#if canImport(AppKit) && !targetEnvironment(macCatalyst)

For SwiftUI feature, need to check for iOS 13 and macOS 10.15

https://github.com/onmyway133/Omnia/blob/master/Sources/SwiftUI/Utils/ImageLoader.swift

1
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)