How to set text color for UIDatePicker

Issue #660

Apply tintColor does not seem to have effect.

1
2
datePicker.setValue(UIColor.label, forKeyPath: "textColor")
datePicker.setValue(false, forKey: "highlightsToday")

How to use background in iOS

Issue #631

beginBackgroundTask

https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/extending_your_app_s_background_execution_time

When your app moves to the background, the system calls your app delegate’s applicationDidEnterBackground(_:) method. That method has five seconds to perform any tasks and return. Shortly after that method returns, the system puts your app into the suspended state. For most apps, five seconds is enough to perform any crucial tasks, but if you need more time, you can ask UIKit to extend your app’s runtime.

You extend your app’s runtime by calling the beginBackgroundTask(withName:expirationHandler:) method. Calling this method gives you extra time to perform important tasks.

BackgroundTasks

Use the BackgroundTasks framework to keep your app content up to date and run tasks requiring minutes to complete while your app is in the background. Longer tasks can optionally require a powered device and network connectivity.

Register launch handlers for tasks when the app launches and schedule them as required. The system will launch your app in the background and execute the tasks.

The main API for using this framework is the BGTaskScheduler . This API constantly monitors the system state such as battery level, background usage, and more, so it chooses the optimal time to run your tasks.

To use this API, you begin working when your app is on the foreground. You need to create Background task request. The framework provides an abstract class BGTask, you never use this task directly. Instead, the framework provides two concrete subclasses you can interact with: BGProcessingTask, for long running and maintenance tasks such backup and cleanup, and BGAppRefreshTask to keep your app up-to-date throughout the day.

URLSession background

When you create your background download or upload tasks with URLSession, you’re actually scheduling a download (or upload) with the ‘nsurlsessiond’ which is a daemon service that runs as a separate process.

How to mask with UILabel

Issue #603

Need to set correct frame for mask layer or UILabel, as it is relative to the coordinate of the view to be masked

1
2
3
4
5
6
7
8
9
let aView = UIView(frame: .init(x: 100, y: 110, width: 200, height: 100))

let textLayer = CATextLayer()
textLayer.foregroundColor = UIColor.white.cgColor
textLayer.string = "Hello world"
textLayer.font = UIFont.preferredFont(forTextStyle: .largeTitle)
textLayer.frame = aView.bounds

aView.layer.mask = textLayer

Use sizeToFit to ensure frame for UILabel

1
2
3
4
5
6
7
8
9
let label = UILabel()
label.frame.origin = CGPoint(x: 80, y: 80)

label.textColor = UIColor.black
label.font = UIFont.preferredFont(forTextStyle: .largeTitle)
label.text = "ABC"
label.sizeToFit()

aView.mask = label

Change bounds.origin

1
2
label.frame.origin = CGPoint(x: 50, y: 50)
aView.bounds.origin = label.frame.origin

Adding label to view hierarchy seems to remove masking effect. Need to set mask later

1
2
3
view.addSubview(aView)
view.addSubview(label)
aView.mask = label

Can’t add overlayView to UILabel and use UILabel as mask, cause cycler CALayer

After using UILabel as mask, its superview is nil

1
2
aView.mask = label
label.superview == nil

Mask with snapshot from UILabel. Need to set correct frame for aView and maskLayer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let maskLayer = CALayer()
maskLayer.contents = label.makeScreenshot().cgImage
maskLayer.contentsGravity = .resizeAspect

aView.frame = label.bounds
maskLayer.frame = aView.bounds
aView.layer.mask = maskLayer
label.addSubview(aView)

extension UIView {
func makeScreenshot() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: self.bounds)
return renderer.image { (context) in
self.layer.render(in: context.cgContext)
}
}
}

How to sync multiple CAAnimation

Issue #600

Use same CACurrentMediaTime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final class AnimationSyncer {
static let now = CACurrentMediaTime()

func makeAnimation() -> CABasicAnimation {
let animation = CABasicAnimation(keyPath: "opacity")
animation.fillMode = .forwards
animation.fromValue = 0
animation.toValue = 1
animation.repeatCount = .infinity
animation.duration = 2
animation.beginTime = Self.now
animation.autoreverses = true
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
return animation
}
}

How to build SwiftUI style UICollectionView data source in Swift

Issue #598

It’s hard to see any iOS app which don’t use UITableView or UICollectionView, as they are the basic and important foundation to represent data. UICollectionView is very basic to use, yet a bit tedious for common use cases, but if we abstract over it, then it becomes super hard to customize. Every app is unique, and any attempt to wrap around UICollectionView will fail horribly. A sensable approach for a good abstraction is to make it super easy for normal cases, and easy to customize for advanced scenarios.

I’m always interested in how to make UICollectionView easier and fun to write and have curated many open sources here data source. Many of these data source libraries try to come up with totally different namings and complex paradigm which makes it hard to onboard, and many are hard to customize.

In its simplest form, what we want in a UICollectionView data source is cell = f(state), which means our cell representation is just a function of the state. We just want to set model to the cell, the correct cell, in a type safe manner.

Generic data source

The basic is to make a generic data source that sticks with a particular cell

1
2
3
4
5
class DataSource<T>: NSObject {
let items: [T]
let configure: (T, UICollectionViewCell) -> Void
let select: (UICollectionViewCell, IndexPath) -> Void
}

This works for basic usage, and we can create multiple DataSource for each kind of model. The problem is it’s hard to subclass DataSource as generic in Swift and inheritance for ObjcC NSObject don’t work well.

Check for the types

Seeing the problem with generic data source, I’ve tried another approach with Upstream where it’s easier to declare sections and models.

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)

This uses the Adapter pattern and we need to handle AdapterDelegate. To avoid the generic problem, this Adapter store items as Any, so we need to type cast all the time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
}
}
}

The benefit is that we can easily subclass this Adapter manager to customize the behaviour, here is how to make accordion

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)
}
}

SwiftUI

SwiftUI comes in iOS 13 with a very concise and easy to use syntax. SwiftUI has good diffing so we just need to update our models so the whole content will be diffed and rendered again.

1
2
3
4
5
6
7
8
9
10
11
12
var body: some View {
List {
ForEach(blogs) { blog in
VStack {
Text(blog.name)
}
.onTap {
print("cell was tapped")
}
}
}
}

SwiftUI style with diffing

I built DeepDiff before and it was used by many people. Now I’m pleased to introduce Micro which is a SwiftU style with DeepDiff powered so it performs fast diffing whenever state changes.

With Micro we can just use the familiar forEach to declare Cell, and the returned State will tell DataSource to update the UICollectionView.

Every time state is assigned, UICollectionView will be fast diffed and reloaded. The only requirement is that your model should conform to DiffAware with diffId so DeepDiff knows how to diff for changes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let dataSource = DataSource(collectionView: collectionView)
dataSource.state = State {
ForEach(blogs) { blog in
Cell<BlogCell>() { context, cell in
cell.nameLabel.text = blog.name
}
.onSelect { context in
print("cell at index \(context.indexPath.item) is selected")
}
.onSize { context in
CGSize(
width: context.collectionView.frame.size.width,
height: 40
)
}
}
}

DataSource is completely overridable, if you want to customize any methods, just subclass DataSource, override methods and access its state.models

1
2
3
4
5
6
class CustomDataSource: DataSource {
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let blog = state.models[indexPath.item] as? Blog
print(blog)
}
}

Diffable data source in iOS 13

In iOS 13, Apple adds Using Collection View Compositional Layouts and Diffable Data Sources which is very handy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func makeDataSource() -> UITableViewDiffableDataSource<Section, Contact> {
let reuseIdentifier = cellReuseIdentifier

return UICollectionViewDiffableDataSource(
collectionView: collectionView,
cellProvider: { collectionView, indexPath, blog in
let cell = tableView.dequeueReusableCell(
withIdentifier: reuseIdentifier,
for: indexPath
)

cell.textLabel?.text = blog.name
cell.detailTextLabel?.text = blog.email
return cell
}
)
}

This is iOS 13+ only, and the main components are the cellProvider acting as cellForItemAtIndexPath, and the snapshot for diffing. It also supports section.

1
2
let snapshot = NSDiffableDataSourceSnapshot<Section, Blog>()
dataSource.apply(snapshot, animatingDifferences: animate)

How to test drag and drop in UITests

Issue #583

In UITests, we can use press from XCUIElement to test drag and drop

1
2
3
4
5
let fromCat = app.buttons["cat1"].firstMatch
let toCat = app.buttons["cat2"]
let fromCoordinate = fromCat.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
let toCoordinate = toCat.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: -0.5))
fromCoordinate.press(forDuration: 1, thenDragTo: toCoordinate)

and then take screenshot

1
2
3
4
5
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways
attachment.name = name
add(attachment)

Screenshot capturing happens after the action, so it may be too late. One way is to inject launch arguments, like app.launchArguments.append("--dragdrop") to alter some code in the app.

We can also swizzle gesture recognizer to alter behavior

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
extension UILongPressGestureRecognizer {
@objc var uiTests_state: UIGestureRecognizer.State {
let state = self.uiTests_state
if state == .ended {
return .changed
} else {
return state
}
}
}

let originalSelector = #selector(getter: UILongPressGestureRecognizer.state)
let swizzledSelector = #selector(getter: UILongPressGestureRecognizer.uiTests_state)

let originalMethod = class_getInstanceMethod(UILongPressGestureRecognizer.self, originalSelector)!
let swizzledMethod = class_getInstanceMethod(UILongPressGestureRecognizer.self, swizzledSelector)!

let didAddMethod = class_addMethod(UILongPressGestureRecognizer.self, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod))

if didAddMethod {
class_replaceMethod(UILongPressGestureRecognizer.self, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod))
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}

How to set corner radius in iOS

Issue #582

Use View Debugging

Run on device, Xcode -> Debug -> View debugging -> Rendering -> Color blended layer
On Simulator -> Debug -> Color Blended Layer

Corner radius

Okay. Talked to a Core Animation engineer again:

  • cornerRadius was deliberately improved in Metal so it could be used everywhere.
  • Using a bitmap is WAY heavier in terms of memory and performance.
  • CALayer maskLayer is still heavy.

https://developer.apple.com/documentation/quartzcore/calayer/1410818-cornerradius

Setting the radius to a value greater than 0.0 causes the layer to begin drawing rounded corners on its background. By default, the corner radius does not apply to the image in the layer’s contents property; it applies only to the background color and border of the layer. However, setting the masksToBounds property to true causes the content to be clipped to the rounded corners.

https://developer.apple.com/documentation/quartzcore/calayer/1410896-maskstobounds

When the value of this property is true, Core Animation creates an implicit clipping mask that matches the bounds of the layer and includes any corner radius effects. If a value for the mask property is also specified, the two masks are multiplied to get the final mask value.

Mask layer

layer.cornerRadius, with or without layer.maskedCorners causes blending
Use mask layer instead of layer.cornerRadius to avoid blending, but mask causes offscreen rendering

1
2
3
4
5
6
7
8
let mask = CAShapeLayer()
let path = UIBezierPath(
roundedRect: bounds,
byRoundingCorners: [.topLeft, .topRight, .bottomLeft, .bottomRight],
cornerRadii: CGSize(width: 20, height: 20)
)
mask.path = path.cgPath
layer.mask = mask

Offscreen rendering

Instruments’ Core Animation Tool has an option called Color Offscreen-Rendered Yellow that will color regions yellow that have been rendered with an offscreen buffer (this option is also available in the Simulator’s Debug menu). Be sure to also check Color Hits Green and Misses Red. Green is for whenever an offscreen buffer is reused, while red is for when it had to be re-created.

Offscreen drawing on the other hand refers to the process of generating bitmap graphics in the background using the CPU before handing them off to the GPU for onscreen rendering. In iOS, offscreen drawing occurs automatically in any of the following cases:

Core Graphics (any class prefixed with CG)
The drawRect() method, even with an empty implementation.
CALayers with a shouldRasterize property set to YES.
CALayers using masks (setMasksToBounds) and dynamic shadows (setShadow
).
Any text displayed on screen, including Core Text.
Group opacity (UIViewGroupOpacity).

Instruments

Read more

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@abstract Sets the corner rounding method to use on the ASDisplayNode.
There are three types of corner rounding provided by Texture: CALayer, Precomposited, and Clipping.

- ASCornerRoundingTypeDefaultSlowCALayer: uses CALayer's inefficient .cornerRadius property. Use
this type of corner in situations in which there is both movement through and movement underneath
the corner (very rare). This uses only .cornerRadius.

- ASCornerRoundingTypePrecomposited: corners are drawn using bezier paths to clip the content in a
CGContext / UIGraphicsContext. This requires .backgroundColor and .cornerRadius to be set. Use opaque
background colors when possible for optimal efficiency, but transparent colors are supported and much
more efficient than CALayer. The only limitation of this approach is that it cannot clip children, and
thus works best for ASImageNodes or containers showing a background around their children.

- ASCornerRoundingTypeClipping: overlays 4 separate opaque corners on top of the content that needs
corner rounding. Requires .backgroundColor and .cornerRadius to be set. Use clip corners in situations
in which is movement through the corner, with an opaque background (no movement underneath the corner).
Clipped corners are ideal for animating / resizing views, and still outperform CALayer.

Generally, on iOS, pixel effects and Quartz / Core Graphics drawing are not hardware accelerated, and most other things are.
The following things are not hardware accelerated, which means that they need to be done in software (offscreen):
Anything done in a drawRect. If your view has a drawRect, even an empty one, the drawing is not done in hardware, and there is a performance penalty.
Any layer with the shouldRasterize property set to YES.
Any layer with a mask or drop shadow.
Text (any kind, including UILabels, CATextLayers, Core Text, etc).
Any drawing you do yourself (either onscreen or offscreen) using a CGContext.

For example, writing your own draw method with Core Graphics means your rendering will technically be done in software (offscreen) as opposed to being hardware accelerated like it is when you use a normal CALayer. This is why manually rendering a UIImage with a CGContext is slower than just assigning the image to a UIImageView.

if layer’s contents is nil or this contents has a transparent background, you just need to set cornerRadius. For UILabel, UITextView and UIButton, you can just set layer’s backgroundColor and cornerRadius to get a rounded corner. Note: UILabel’s backgroundColor is not its layer’s backgroundColor.

How to work with SceneDelegate in iOS 12

Issue #580

Events

open url

Implement scene(_:openURLContexts:) in your scene delegate.

If the URL launches your app, you will get scene(_:willConnectTo:options:) instead and it’s in the options.

life cycle

Here’s how it works: If you have an “Application Scene Manifest” in your Info.plist and your app delegate has a configurationForConnectingSceneSession method, the UIApplication won’t send background and foreground lifecycle messages to your app delegate. That means the code in these methods won’t run:

applicationDidBecomeActive
applicationWillResignActive
applicationDidEnterBackground
applicationWillEnterForeground
The app delegate will still receive the willFinishLaunchingWithOptions: and didFinishLaunchingWithOptions: method calls so any code in those methods will work as before.

UIApplication notifications

Notifications still trigger in iOS 13 if adopting SceneDelegate

1
2
UIApplication.didBecomeActiveNotification
UIApplication.willResignActiveNotification

https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_foreground

Use foreground transitions to prepare your app’s UI to appear onscreen. An app’s transition to the foreground is usually in response to a user action. For example, when the user taps the app’s icon, the system launches the app and brings it to the foreground. Use a foreground transition to update your app’s UI, acquire resources, and start the services you need to handle user requests.

All state transitions result in UIKit sending notifications to the appropriate delegate object:

In iOS 13 and later—A UISceneDelegate object.
In iOS 12 and earlier—The UIApplicationDelegate object.

You can support both types of delegate objects, but UIKit always uses scene delegate objects when they are available. UIKit notifies only the scene delegate associated with the specific scene that is entering the foreground. For information about how to configure scene support, see Specifying the Scenes Your App Supports.

keyWindow

Show most recent activeUIWindow

1
UIApplication.shared.keyWindow

This property holds the UIWindow object in the windows array that is most recently sent the makeKeyAndVisible() message.

AppDelegate vs SceneDelegate

Get sceneDelegate from AppDelegate

1
UIApplication.shared.openSessions.first?.scene?.delegate

order

1
2
3
4
SceneDelegate.sceneDidBecomeActive
UIApplication.didBecomeActiveNotification
SceneDelegate.sceneWillResignActive
UIApplication.willResignActiveNotification

Read more

How to get updated safeAreaInsets in iOS

Issue #570

Use viewSafeArea

1
2
3
4
5
6
@available(iOS 11.0, *)
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()

self.collectionView.reloadData()
}

Use https://developer.apple.com/documentation/uikit/uiview/2891102-safearealayoutguide

1
view.safeAreaLayoutGuide.layoutFrame

https://developer.apple.com/documentation/uikit/uiview/2891103-safeareainsets

For the view controller’s root view, the insets account for the status bar, other visible bars, and any additional insets that you specified using the additionalSafeAreaInsets property of your view controller. For other views in the view hierarchy, the insets reflect only the portion of the view that is covered. For example, if a view is entirely within the safe area of its superview, the edge insets in this property are 0.

Use UICollectionView.contentInsetAdjustmentBehavior

Nested view

For UICollectionView inside Cell inside UICollectionView, its insets is 0, but its parent parent is correct, which is the original cell

UICollectionView -> Cell -> ContentView -> UICollectionView

1
collectionView.superview?.superview?.safeAreaInsets

viewWillAppear: safeAreaInsets is not set to collectionView
viewDidAppear: safeAreaInsets is set to collectionView and cells, but not to nested collectionView

In viewSafeAreaInsetsDidChange, invalidate outer and nested collectionViewLayout

Use extendedLayoutIncludesOpaqueBars

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621404-extendedlayoutincludesopaquebars

A Boolean value indicating whether or not the extended layout includes opaque bars.

Seems to affect left and right insets

But it’s not, it is because of when safeAreaInsets is available, and how it is passed to nested view

When invalidating collection view layout with custom UIPresentationController, alongsideTransition is called twice, the first time with old safeAreaInsets, and the second time with latest safeAreaInsets

And the layout invalidation uses the old insets.

1
2
3
4
5
6
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.collectionViewLayout.invalidateLayout()
})
}

Dispatch

1
2
3
4
5
6
7
8
9
// UIViewController subclass
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
DispatchQueue.main.async {
self.collectionViewLayout.invalidateLayout()
}
})
}

Call layoutIfNeeded

1
2
3
4
5
6
7
8
// UIPresentationController subclass
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView = containerView else { return .zero }

presentedView?.setNeedsLayout()
presentedView?.layoutIfNeeded()

return ...

Check

Check that UICollectionView or the view you’re working on is in view hierarchy

Check that you’re using code in viewDidLayoutSubviews when safeAreaInsets is known

Read more

How to disable implicit decoration view animation in UICollectionView

Issue #569

From documentation https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617726-initiallayoutattributesforappear

This method is called after the prepare(forCollectionViewUpdates:) method and before the finalizeCollectionViewUpdates() method for any decoration views that are about to be inserted. Your implementation should return the layout information that describes the initial position and state of the view. The collection view uses this information as the starting point for any animations. (The end point of the animation is the view’s new location in the collection view.) If you return nil, the layout object uses the item’s final attributes for both the start and end points of the animation.

The default implementation of this method returns nil.

Although the doc says “The default implementation of this method returns nil”, calling super.initialLayoutAttributesForAppearingDecorationElement gives somehow implicit animation. The workaround is to explicitly return nil

1
2
3
4
5
6
7
func initialLayoutAttributesForAppearingDecorationElement(ofKind elementKind: String, at decorationIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return nil
}

func finalLayoutAttributesForDisappearingDecorationElement(ofKind elementKind: String, at decorationIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return nil
}

Decoration seems to be removed when all items are removed. Workaround is to check and only add decoration when there is preferred data or cell

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 take screenshots for UITest in Xcodee

Issue #539

XCUIScreenshot

1
2
3
4
5
6
7
8
extension XCTestCase {
func takeScreenshot(name: String) {
let screenshot = XCUIScreen.main.screenshot()
let attach = XCTAttachment(screenshot: screenshot)
attach.lifetime = .keepAlways
add(attach)
}
}
Screenshot 2019-12-12 at 23 02 21

Gather screenshot for localization

xcresult from Xcode 11

xcresulttool

Test plan

How to fix UIToolbar Auto Layout issues

Issue #538

Hierarchy

1
UIToolbar -> _UIToolbarContentView -> _UIButtonBarStackVie

UIToolbarContentView

1
2
_UIToolbarContentView's width should equal 0
_UIToolbarContentView's height should equal 0

Workaround that fixes 1 warning

1
2
toolbar.setItems(items, animated: false)
toolbar.updateConstraintsIfNeeded()

###

Set frame explicitly

Use a non .zero frame that is close to the view bounds width

1
2
3
4
let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 375, height: 30))
DispatchQueue.main.async {
self.toolbar.updateConstraintsIfNeeded()
}

How to use passed launch arguments in UITests

Issue #537

Specify launch arguments

In xcodebuild, specify launch arguments.

You can specify this under Launch Arguments in Run action of the app scheme or UITest scheme

Screenshot 2019-12-10 at 23 27 02
1
-AppleLanguages (jp) -AppleLocale (jp_JP)
1
2
3
4
5
6
7
8
9
10
11
12
13
(lldb) po ProcessInfo().arguments
11 elements
- 0 : "/Users/khoa/Library/Developer/CoreSimulator/Devices/561F2B45-26B2-4897-98C4-8A917AEB48D2/data/Containers/Bundle/Application/436E0A43-8323-4F53-BBE0-6F75F674916F/TestAppUITests-Runner.app/TestAppUITests-Runner"
- 1 : "-AppleLanguages"
- 2 : "(ja)"
- 3 : "-AppleTextDirection"
- 4 : "NO"
- 5 : "-AppleLocale"
- 6 : "ja_JP"
- 7 : "-NSTreatUnknownArgumentsAsOpen"
- 8 : "NO"
- 9 : "-ApplePersistenceIgnoreState"
- 10 : "YES"

In UITests, pass launch arguments from UITest scheme to UITest application

1
app.launchArguments += ProcessInfo().arguments

Environments

1
ProcessInfo().environment // [String: String]

How to add padding to left right view in UITextField

Issue #536

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
extension UITextField {
func setLeftView(_ view: UIView, padding: CGFloat) {
view.translatesAutoresizingMaskIntoConstraints = true

let outerView = UIView()
outerView.translatesAutoresizingMaskIntoConstraints = false
outerView.addSubview(view)

outerView.frame = CGRect(
origin: .zero,
size: CGSize(
width: view.frame.size.width + padding,
height: view.frame.size.height + padding
)
)

view.center = CGPoint(
x: outerView.bounds.size.width / 2,
y: outerView.bounds.size.height / 2
)

leftView = outerView
}
}

How to use decoration view in UICollectionView

Issue #529

indexPath

https://developer.apple.com/documentation/uikit/uicollectionviewlayoutattributes/1617786-layoutattributesfordecorationvie

It is up to you to decide how to use the indexPath parameter to identify a given decoration view. Typically, you use the decorationViewKind parameter to identify the type of the decoration view and the indexPath information to distinguish between different instances of that view.

Posts

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 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, *)

How to refresh receipt and restore in app purchase in iOS

Issue #496

Read this Restoring Purchased Products to understand the purposes between the 2.

From iOS 7, every app downloaded from the store has a receipt (for downloading/buying the app) at appStoreReceiptURL. When users purchases something via In App Purchase, the content at appStoreReceiptURL is updated with purchases information. Most of the cases, you just need to refresh the receipt (at appStoreReceiptURL) so that you know which transactions users have made.

Users restore transactions to maintain access to content they’ve already purchased. For example, when they upgrade to a new phone, they don’t lose all of the items they purchased on the old phone. Include some mechanism in your app to let the user restore their purchases, such as a Restore Purchases button. Restoring purchases prompts for the user’s App Store credentials, which interrupts the flow of your app: because of this, don’t automatically restore purchases, especially not every time your app is launched.

In most cases, all your app needs to do is refresh its receipt and deliver the products in its receipt. The refreshed receipt contains a record of the user’s purchases in this app, on this device or any other device. However, some apps need to take an alternate approach for one of the following reasons:

If you use Apple-hosted content, restoring completed transactions gives your app the transaction objects it uses to download the content.
If you need to support versions of iOS earlier than iOS 7, where the app receipt isn’t available, restore completed transactions instead.

Refreshing the receipt asks the App Store for the latest copy of the receipt. Refreshing a receipt does not create any new transactions.

Restoring completed transactions creates a new transaction for every completed transaction the user made, essentially replaying history for your transaction queue observer.

More about receipt, from WWDC 2017, What’s new in StoreKit session https://developer.apple.com/videos/play/wwdc2017/303/

enter image description here

You can also watch WWDC 2017, session Advanced StoreKit for more detail https://developer.apple.com/videos/play/wwdc2017/305/

enter image description here


Original answer https://stackoverflow.com/questions/45615106/when-to-refresh-a-receipt-vs-restore-purchases-in-ios/52162283#52162283

How to add monkey test to iOS apps

Issue #484

Use SwiftMonkey which adds random UITests gestures

Add to UITests target

1
2
3
4
target 'MyAppUITests' do
pod 'R.swift', '~> 5.0'
pod 'SwiftMonkey', '~> 2.1.0'
end

Troubleshooting

Failed to determine hittability of Button

Failed to determine hittability of Button: Unable to fetch parameterized attribute XC_kAXXCParameterizedAttributeConvertHostedViewPositionFromContext, remote interface does not have this capability.

This happens when using SwiftMonkey and somewhere in our code uses isHittable, so best to avoid that by having isolated monkey test only

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import XCTest
import SwiftMonkey

class MonkeyTests: XCTestCase {
var app: XCUIApplication!

override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}

func testMonkey() {
let monkey = Monkey(frame: app.frame)
monkey.addDefaultUIAutomationActions()
monkey.addXCTestTapAlertAction(interval: 100, application: app)
monkey.monkeyAround()
}
}

Another workaround is possibly use addDefaultXCTestPublicActions other than addDefaultUIAutomationActions

UI Test Activity:

Assertion Failure: MonkeyXCTest.swift:33: Failed to get matching snapshots: Timed out while evaluating UI query.

This seems related to SwiftMonkey trying to snapshot. Workaround is to remove

1
monkey.addXCTestTapAlertAction(interval: 100, application: app)

How to use CommonCrypto in iOS

Issue #480

Use modulemap

modulemap approach

I use modulemap in my wrapper around CommonCrypto https://github.com/onmyway133/arcane, https://github.com/onmyway133/Reindeer

For those getting header not found, please take a look https://github.com/onmyway133/Arcane/issues/4 or run xcode-select --install

  • Make a folder CCommonCrypto containing module.modulemap
module CCommonCrypto {
  header "/usr/include/CommonCrypto/CommonCrypto.h"
  export *
}
  • Go to Built Settings -> Import Paths
${SRCROOT}/Sources/CCommonCrypto

Cocoapods with modulemap approach

public header approach

Cocoapods with public header approach

1
2
s.libraries        = "xml2"
s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2', 'OTHER_LDFLAGS' => '-lxml2' }

🐝 Interesting related posts

CommonCrypto from Xcode 10

From Xcode 10, we can just

1
import CommonCrypto

Code

How to configure test target in Xcode

Issue #478

This applies to

  • Main targets
    • App
    • Framework
  • Test targets
    • Unit tests
    • UI tests

Examples

Dependencies used

Examples

  • Cocoapods
  • Carthage

Notes

  • Make sure test target can link to all the frameworks it needs. This includes frameworks that Test targets use, and possibly frameworks that Main target uses !
  • Remember to “Clean Build Folder” and “Clear Derived Data” so that you’re sure it works. Sometimes Xcode caches.

Errors

Errors occur mostly due to linker error

  • Test target X encountered an error (Early unexpected exit, operation never finished bootstrapping - no restart will be attempted
  • Framework not found

Cocoapods

1. Pod

Test targets need to include pods that Main target uses !

or we’ll get “Framework not found”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def app_pods
pod 'Sugar', '~> 1.0'
end

def test_pods
pod 'Nimble', '~> 3.2'
pod 'Quick', '~> 0.9'
end

target 'TeaApp' do
app_pods
end

target 'TeaAppTests' do
app_pods
test_pods
end

target 'TeaAppUITests' do
app_pods
test_pods
end

Cocoapods builds a framework that contains all the frameworks the Test targets need, and configure it for us

3. Runpath Search Paths

  • Go to Test target Build Settings
  • Add $(FRAMEWORK_SEARCH_PATHS)

Carthage

1. Cartfile

We usually have

  • Cartfile for Main target
1
github "hyperoslo/Sugar" ~> 1.0
  • Cartfile.private for Test target
1
2
github "Quick/Nimble"
github "Quick/Quick"
  • Go to Test target build phase
  • Drag built frameworks from Carthage/Build
  • In rare case, we need to drag frameworks that the Main target uses
  • In rare case, we need to drag the Main target framework

3. Framework Search Paths

Configure correct path

  • Go to Test target Built Settings
  • Configure Framework Search Paths

4. Runpath Search Paths

  • Go to Test target Build Settings
  • Add $(FRAMEWORK_SEARCH_PATHS)

5. Copy Files (maybe)

From Adding frameworks to unit tests or a framework

In rare cases, you may want to also copy each dependency into the build product (e.g., to embed dependencies within the outer framework, or make sure dependencies are present in a test bundle). To do this, create a new “Copy Files” build phase with the “Frameworks” destination, then add the framework reference there as well.

Runpath Search Paths and Install name

Question

  • Why preconfigured run path “@executable_path/Frameworks” and “@loader_path/Frameworks” not work?
  • Why configuring runpath to “$(FRAMEWORK_SEARCH_PATHS)” works ?
  • Why framework has install name “@rpath/Sugar.framework/Sugar” ?

Reference

Code

How to use external display in iOS

Issue #473

Before iOS 13

Use UIScreen.didConnectNotification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NotificationCenter.default.addObserver(forName: UIScreen.didConnectNotification,
object: nil, queue: nil) { (notification) in
// Get the new screen information.
let newScreen = notification.object as! UIScreen
let screenDimensions = newScreen.bounds

// Configure a window for the screen.
let newWindow = UIWindow(frame: screenDimensions)
newWindow.screen = newScreen
// Install a custom root view controller in the window.
self.configureAuxilliaryInterface(with: newWindow)

// You must show the window explicitly.
newWindow.isHidden = false
// Save a reference to the window in a local array.
self.additionalWindows.append(newWindow)}

From iOS 13

Handle configurationForConnecting

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
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

var newWindow: UIWindow!

// MARK: UISceneSession Lifecycle

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.


print(UIApplication.shared.connectedScenes)
let scene = UIWindowScene(session: connectingSceneSession, connectionOptions: options)

// Configure a window for the screen.
self.newWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 500))
// self.newWindow.backgroundColor = UIColor.yellow
// Install a custom root view controller in the window.

let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(identifier: "other") as! OtherViewController
self.newWindow.rootViewController = viewController
self.newWindow.windowScene = scene

// You must show the window explicitly.
self.newWindow.isHidden = false

return UISceneConfiguration(name: "External configuration", sessionRole: connectingSceneSession.role)
}
}

Or we can declare in plist

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
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleExternalDisplay</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>External configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).OtherSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Other</string>
</dict>
</array>
</dict>
</dict>

Read more