How to use Core Data

Issue #785

Core Data

  • Responding to changes in a managed object context

    Calling mergeChanges on a managed object context will automatically refresh any managed objects that have changed. This ensures that your context always contains all the latest information. Note that you don’t have to call mergeChanges on a viewContext when you set its automaticallyMergesChangesFromParent property to true. In that case, Core Data will handle the merge on your behalf.

  • Using Core Data in the Background

    Managed objects retrieved from a context are bound to the same queue that the context is bound to.

  • The Laws of Core Data

    The ability to have a child MOC is neat in some corner cases. Let’s review the core functionality of MOCs in order to understand those cases:

  • MOCs load objects from its source (a PSC or another MOC)
  • MOCs save objects to its source (a PSC or another MOC)
  • MOCs enforce graph integrity when they save

Use Core Data as a local cache

When you save changes in a context, the changes are only committed “one store up.” If you save a child context, changes are pushed to its parent. Changes are not saved to the persistent store until the root context is saved. (A root managed object context is one whose parent context is nil.)

  • NSManagedObjectContext fetch

    An object that meets the criteria specified by request (it is an instance of the entity specified by the request, and it matches the request’s predicate if there is one) and that has been inserted into a context but which is not yet saved to a persistent store, is retrieved if the fetch request is executed on that context.

  • NSBatchDeleteRequest mergeChanges

    Changes are not reflected in the context”. So what you’re seeing is normal. Batch updates work directly on the persistent store file instead of going through the managed object context, so the context doesn’t know about them. When you delete the objects by fetching and then deleting, you’re working through the context, so it knows about the changes you’re making (in fact it’s performing those changes for you).

  • Does modifying a managed object in a parent context propagate down to child contexts when performing a child fetch in Core Data?

mainContext would fetch all the way to the persistent store. Fetches and objectWithID: only go as many levels as they need to.
It’s important to remember that when a context is created it’s a “snapshot” of the state of it’s parent. Subsequent changes to the parent will not be visible in the child unless the child is somehow invalidated

CloudKit

NSFetchedResultsController

How to fetch with background context

TLDR: Use NSFetchedResultsController in backgroundContext

  • If use Core Data as cache: convert to struct
  • If use NSManagedObject: use FetchRequest on viewContext

Approach 1: Treat Core Data as cache

Pros

  • backgroundContext.automaticallyMergesChangesFromParent
  • Use 1 shared background context to load and updates changes
  • Convert NSManagedObject to struct
  • Our struct can have additional logic and properties
  • Does not need to care about faulting

Cons

  • Need to update objectId When context.save, NSManagedObject ‘s objecteId is updated
  • Need to reload when underlying NSManagedObject changes
  • Need to handle relationship

Instead of plain NSFetchRequest, can use NSFetchedResultsController on background context. In its controllerDidChangeContent convert to struct

Approach 2: viewContext to persistent coordinator

1
viewContext -> persistent coordinator <- backgroundContext

Pros

  • viewContext.automaticallyMergesChangesFromParent
  • viewContext and background Context stems from persistent container
  • background context changes object
  • viewContext read only

Cons

  • viewContext blocks main thread

Approach 3: viewContext to background context

1
viewContext -> backgroundContext -> persistent coordinator

Pros

  • viewContext.automaticallyMergesChangesFromParent
  • viewContext reads from backgroundContext, no main queue block
  • backgroundContext changes

Cons

  • ???

Read more

Approach 4: background context -> mainContext -> backgroundContext -> persistent coordinator

https://stackoverflow.com/questions/35961936/child-nsmanagedobjectcontext-update-from-parent

1
2
3
RootContext (private queue) - saves to persistent store
MainContext (main queue) child of RootContext - use for UI (FRC)
WorkerContext (private queue) - child of MainContext - use for updates & inserts

Updated at 2021-02-28 07:58:23

How to make simple search bar in SwiftUI

Issue #776

We need to use a custom Binding to trigger onChange as onEditingChanged is only called when the user selects the textField, and onCommit is only called when return or done button on keyboard is tapped.

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
import SwiftUI
import EasySwiftUI

struct SearchBar: View {
@Binding
var searchText: String
let onChange: () -> Void
@State
private var showsCancelButton: Bool = false

var body: some View {
return HStack {
textField
cancelButton
}
}

private var searchTextBinding: Binding<String> {
Binding<String>(get: {
searchText
}, set: { newValue in
DispatchQueue.main.async {
searchText = newValue
onChange()
}
})
}

private var textField: some View {
HStack {
Image(systemName: SFSymbol.magnifyingglass.rawValue)

TextField("Search", text: searchTextBinding, onEditingChanged: { isEditing in
withAnimation {
self.showsCancelButton = true
}
onChange()
}, onCommit: {
// No op
})
.foregroundColor(.primary)

Button(action: {
self.searchText = ""
}) {
Image(systemName: SFSymbol.xmarkCircleFill.rawValue)
.opacity(searchText == "" ? 0 : 1)
}
}
.padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6))
.foregroundColor(.secondary)
.background(Color(.systemBackground))
.cornerRadius(10.0)
}

@ViewBuilder
private var cancelButton: some View {
if showsCancelButton {
Button("Cancel") {
UIApplication.shared.endEditing(true)
withAnimation {
self.searchText = ""
self.showsCancelButton = false
}
onChange()
}
.foregroundColor(Color(.systemBlue))
}
}
}

extension UIApplication {
func endEditing(_ force: Bool) {
self.windows
.filter{$0.isKeyWindow}
.first?
.endEditing(force)
}
}

How to fix share and action extension not showing up in iOS 14

Issue #775

My share sheet and action extension not showing up in iOS 14, built-in Xcode 12.3. The solution is to restart test device, and it shows up again.

Also make sure your extension targets have the same version and build number, and same deployment target

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationDictionaryVersion</key>
<integer>2</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>MyPreprocessor</string>
<key>NSExtensionActivationUsesStrictMatching</key>
<integer>2</integer>
</dict>

Updated at 2021-02-17 20:49:49

How to add alternative app icons for iOS

Issue #749

Some apps want to support alternative app icons in Settings where user can choose to update app icon.
Here’s some must do to make it work, as of Xcode 12.2

  • In Info.plist, must declare CFBundleIcons with both CFBundlePrimaryIcon and CFBundleAlternateIcons
  • Icons must be in project folder, not Asset Catalog

Here’s how it is done on my app PastePal

Screenshot 2021-01-14 at 06 46 40

Add app icons to project folder

Prepare icons, like Icon1, Icon2 with 2 variants for 2x and 3x. These need to be added to project folder. When I add these to Asset Catalog it does not update app icon. I usually add a new folder in project like Resource/AlternateAppIcons

The default 1x size for iPhone icon size from iOS 7 to iOS 14 is 60px.

Declare in Info.plist

Although declaring CFBundlePrimaryIcon seems unnecessary, I find that without this it does not work. I use AppIcon60x60 as the compiled one from Asset Catalog for our main AppIcon

For UIPrerenderedIcon, a boolean value indicating whether the app’s icon already contains a shine effect. We set to false to allow iOS to apply round and glossy effect to our icon.

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
<key>CFBundleIcons</key>
<dict>
<key>CFBundlePrimaryIcon</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon60x60</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>AppIconPride1</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIconPride1</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
<key>AppIconPride2</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIconPride2</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
</dict>
</dict>

Asset Catalog

Since we’re using Asset Catalog, if we simply use UIImage with named, it will load by default in Asset Catalog, unless we manually specify bundle path. For simple demo, we can just copy our images again to Asset Catalog so we can show in the app

I usually have an enum of AlternateAppIcon so I can display in a grid and let user choose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum AlternateAppIcon: String, CaseIterable, Identifiable {
var id: AlternateAppIcon { self }

case main = "Main"
case pride1 = "Pride1"
case pride2 = "Pride2"
}

func onChange(_ icon: AlternateAppIcon) {
guard UIApplication.shared.supportsAlternateIcons else { return }
switch icon {
case .main:
UIApplication.shared.setAlternateIconName(nil)
default:
UIApplication.shared.setAlternateIconName(icon.name)
}
}

Change app icon for iPad

So far we’re only talk about iPhone. To support iPad, we need to specify iPad versions in Info.plist with CFBundleIcons~ipad

1
2
3
4
5
6
7
8
- CFBundleIcons
- CFBundleAlternateIcons
- Icon Name
- CFBundleIconFiles
- CFBundleIcons~ipad
- CFBundleAlternateIcons
- Icon Name
- CFBundleIconFiles

Also notice the size for icons on iPad

Screenshot 2021-01-14 at 07 01 23

Updated at 2021-01-14 06:02:28

How to use UITextView in SwiftUI

Issue #747

Need to use Coordinator conforming to UITextViewDelegate to apply changes back to Binding

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
import SwiftUI
import UIKit

struct MyTextView: UIViewRepresentable {
@Binding
var text: String

final class Coordinator: NSObject, UITextViewDelegate {
let parent: MyTextView

init(parent: MyTextView) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
if textView.text != parent.text {
parent.text = textView.text
}
}
}

func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}

func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isScrollEnabled = true
view.isEditable = true
view.isUserInteractionEnabled = true
view.font = UIFont.preferredFont(forTextStyle: .body)
view.delegate = context.coordinator
return view
}

func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
}

How to check app going to background in SwiftUI

Issue #746

From iOS 13, the default is to support multiple scene, so the the old UIApplicationDelegate lifecycle does not work. Double check your Info.plist for UIApplicationSceneManifest key

1
2
3
4
5
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>

One way to be notified about application life cycle is to use UIApplicationDelegateAdaptor and via NotificationCenter

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
import SwiftUI
import UIKit
import FontAwesomeSwiftUI

final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FontAwesome.register()
PreferenceManager.shared.load()
return true
}
}

@main
struct MyAwesomeApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self)
var appDelegate

var body: some Scene {
WindowGroup {
MainView(store: Store.shared)
.onReceive(
NotificationCenter.default.publisher(
for: UIApplication.didEnterBackgroundNotification)) { _ in
PreferenceManager.shared.save()
}
}
}
}

How to make Auto Layout more convenient in iOS

Issue #742

Auto Layout has been around since macOS 10.7 and iOS 6.0 as a nicer way to do layouts over the old resizing masks. Besides some rare cases when we need to manually specify origins and sizes, Auto Layout is the preferred way to do declarative layouts whether we choose to do UI in code or Storyboard. The Auto Layout APIs have some improvements over the years, there are also some sugar libraries that add easy syntaxes, so more choices for developers.

In this article, let’s take a review of how layout has improved over the years, from manual layout, autoresizing masks and finally to Auto Layout. I will mention a few takes on some libraries and how to abstract Auto Layout using the builder pattern. There are also some notes about the view life cycle and APIs improvements that might be overlooked. These are of course based on my experience and varies because of personal tastes but I hope you‘ll find something useful.

Although the article mention iOS, the same learning applies to watchOS, tvOS and macOS too.

Positioning a view before Auto Layout

When I first started iOS programming in early 2014, I read a book about Auto Layout and that book detailed lots of scenarios that completely confused me. It didn’t take long until I tried Auto Layout in an app and I realized it w as so simple. In its simplest sense, a view needs a position and a size to be correctly shown on the screen, everything else is just extra. In Auto Layout’s term, we need to specify enough constraints to position and size the view.

Manual layout using CGRect

If we take a look back at the way we do a manual layout with the frame, there are origins and sizes. For example, here is how to position a red box that stretches accordingly with the width of the view controller.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ViewController: UIViewController {

let box = UIView()
override func viewDidLoad() {
super.viewDidLoad()

box.backgroundColor = UIColor.red
view.addSubview(box)
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()

box.frame = CGRect(x: 20, y: 50, width: view.frame.size.width - 40, height: 100)
}
}

viewDidLoad is called when the view property of view controller is loaded, we need to wait until viewDidLayoutSubviews so that we have access to the final bounds. When the bounds change for a view controller’s view, the view adjusts the positions of its subviews and then the system calls this method.

Why is view size correct in viewDidLoad

viewDidLoad is definitely not the recommended way to do manual layout but there are times we still see that its view is correctly sized and fills the screen. This is when we need to read View Management in UIViewController more thoroughly:

Each view controller manages a view hierarchy, the root view of which is stored in the *view*property of this class. The root view acts primarily as a container for the rest of the view hierarchy. The size and position of the root view is determined by the object that owns it, which is either a parent view controller or the app’s window. The view controller that is owned by the window is the app’s root view controller and its view is sized to fill the window.
A view controller’s root view is always sized to fit its assigned space.

It can also be that the view has fixed size in xib or storyboard but we should control the size explicitly and do that in the right view controller method to avoid unexpected behaviors.

Autoresizing masks

Autoresizing mask was the old way to make layout a bit more declarative, also called springs and struts layout system. It is integer bit mask that determines how the receiver resizes itself when its superview’s bounds change. Combining these constants lets you specify which dimensions of the view should grow or shrink relative to the superview. The default value of this property is none, which indicates that the view should not be resized at all.

While we can specify which edges we want to fix and which should be flexible, it is confusing in the ways we do in xib and in code.

In the above screenshot, we pin the top of the red box to the top of the screen, and that is fixed distance. When the view changes size, the width and height of the red box changes proportionally, but the top spacing remains fixed.

In code, instead of specifying autoresizing in terms of fixed distance, we use flexible terms to specify which edges should be flexible.

To achieve the same red box on the top of the screen, we need to specify a flexible width and a flexible bottom margin. This means the left, right and top edges are fixed.

box.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin]

Here are a few more scenarios

  • Horizontally fixed distance from the left: [.flexibleRightMargin]

  • Center horizontally [.flexibleLeftMargin, .flexibleRightMargin]

  • Vertically fixed distance from the top: [.flexibleBottomMargin]

  • Center vertically [.flexibleTopMargin, .flexibleBottomMargin]

These are not very intuitive and the way these are scaled proportionally may not fit our expectation. Also, note that multiple options can be done on the same axis.

When more than one option along the same axis is set, the default behavior is to distribute the size difference proportionally among the flexible portions. The larger the flexible portion, relative to the other flexible portions, the more it is likely to grow. For example, suppose this property includes the flexibleWidth and flexibleRightMarginconstants but does not include the flexibleLeftMargin constant, thus indicating that the width of the view’s left margin is fixed but that the view’s width and right margin may change.

Understanding of autoresizing masks won’t waste your time, we will come back to it in a few minutes 😉

Auto Layout to the rescue

Auto Layout, together with dynamic text and size classes, are recommended ways to build adaptive user interfaces as there the number of iOS devices with different screen sizes grows.

A constraint-based layout system

Auto Layout is described via NSLayoutConstraint by defining constraints between 2 objects. Here is the simple formula to remember:

item1.attribute1 = multiplier × item2.attribute2 + constant

Here is how to replicate that red box with NSLayoutConstraint. We need to specify which property of which view should be connected to another property of another view. Auto Layout supports many attributes such ascenterX, centerY and topMargin.

1
2
3
4
5
let marginTop = NSLayoutConstraint(item: box, attribute: .top, relatedBy: .equal, toItem: view, attribute: .top, multiplier: 1.0, constant: 50)
let marginLeft = NSLayoutConstraint(item: box, attribute: .left, relatedBy: .equal, toItem: view, attribute: .left, multiplier: 1.0, constant: 20)
let marginRight = NSLayoutConstraint(item: box, attribute: .right, relatedBy: .equal, toItem: view, attribute: .right, multiplier: 1.0, constant: -20)
let height = NSLayoutConstraint(item: box, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 100)
NSLayoutConstraint.activate([marginTop, marginLeft, marginRight, height])

translatesAutoresizingMaskIntoConstraints

If we run the above code, we will get into the popular warning message regarding translatesAutoresizingMaskIntoConstraints

[LayoutConstraints] Unable to simultaneously satisfy constraints.
 Probably at least one of the constraints in the following list is one you don't want. 
 Try this: 
  (1) look at each constraint and try to figure out which you don't expect; 
  (2) find the code that added the unwanted constraint or constraints and fix it. 
 (Note: If you're seeing NSAutoresizingMaskLayoutConstraints that you don't understand, refer to the documentation for the UIView property translatesAutoresizingMaskIntoConstraints) 
(
    "<NSAutoresizingMaskLayoutConstraint:0x600003ef2300 h=--& v=--& UIView:0x7fb66c5059f0.midX == 0   (active)>",
    "<NSLayoutConstraint:0x600003e94b90 H:|-(20)-[UIView:0x7fb66c5059f0](LTR)   (active, names: '|':UIView:0x7fb66c50bce0 )>"
)

If you need some help deciphering this, there is wtfautolayout that does a good job on explaining what’s really happening.

It is said that resizing masks has been reimplemented using Auto Layout under the hood, and there is always NSAutoresizingMaskLayoutConstraint added to the view, hence the midX constraint.

We should never mix resizing masks and Auto Layout to avoid unwanted behavior, the fix is simply to disable translatesAutoresizingMaskIntoConstraints

box.translatesAutoresizingMaskIntoConstraints = false

This property is false by default for views from xib or storyboard but holds true if we declare layout in code. The intention is for the system to create a set of constraints that duplicate the behavior specified by the view’s autoresizing mask. This also lets you modify the view’s size and location using the view’s frame, bounds, or center properties, allowing you to create a static, frame-based layout within Auto Layout.

Visual Format Language

The Visual Format Language lets you use ASCII-art like strings to define your constraints. I see it is used in some code bases so it’s good to know it.

Here is how to recreate the red box using VFL. We need to specify constraints for both horizontal and vertical direction. Note that the same format string may result in multiple constraints:

1
2
3
4
5
6
let views = ["box" : box]
let horizontal = "H:|-20-[box]-20-|"
let vertical = "V:|-50-[box(100)]"
let horizontalConstraints = NSLayoutConstraint.constraints(withVisualFormat: horioptions: [], metrics: nil, views: views)
let verticalConstraints = NSLayoutConstraint.constraints(withVisualFormat: veoptions: [], metrics: nil, views: views)
NSLayoutConstraint.activate([horizontalConstraints, verticalConstraints].flatMap({ $0 }))

Visual Format Language is a bit nicer than the verbose NSLayoutConstraint initializer, but it encourages string format, which is error-prone.

addConstraint and activate

This may seem trivial but I see in modern code bases, there is still usage of addConstraint . This was old and hard to use, as we must find the nearest common ancestor view of the 2 views that envolve in Auto Layout.

Starting from iOS 8, there is isActive and the static activate functions that ease this adding constraints process a lot. Basically what it does is to activate or deactivate the constraint with calls toaddConstraint(_:) and removeConstraint(_:) on the view that is the closest common ancestor of the items managed by this constraint.

NSLayoutAnchor

Starting with macOS 10.11 and iOS 9, there was NSLayoutAnchor that simplifies Auto Layout a lot. Auto Layout was declarative, but a bit verbose, now it is simpler than ever with an anchoring system.

Here is how to achieve that same red box

NSLayoutConstraint.activate([
    box.topAnchor.constraint(equalTo: view.topAnchor, constant: 50),
    box.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 20),
    box.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -20),
    box.heightAnchor.constraint(equalToConstant: 100)
])

The cool thing about NSLayoutAnchor is its generic constrained API design. Constraints are divided into X-axis, Y-axis and dimension anchor type that makes it hard to make mistakes.

open class NSLayoutXAxisAnchor : NSLayoutAnchor<NSLayoutXAxisAnchor>
open class NSLayoutDimension : NSLayoutAnchor<NSLayoutDimension>

For example, we can’t pin the top anchor to the left anchor as they are in different axis, and it makes no sense. Attempt to do the following results in compilation issue as Swift strong type system ensures correctness.

box.topAnchor.constraint(equalTo: view.leftAnchor, constant: 50),

Ambiguous error message with NSLayoutAnchor

I’ve gone into a few cases where the error messages from NSLayoutAnchor don’t help. If we mistakenly connect topAnchor with centerXAnchor, which are not possible as they are from different axes.

NSLayoutConstraint.activate([
    box.topAnchor.constraint(equalTo: view.centerXAnchor, constant: 50)
])

Xcode complains of unwrapped UIView problem which may confuse us even more.

Value of optional type 'UIView?' must be unwrapped to refer to member 'centerXAnchor' of wrapped base type 'UIView'

Another puzzle, regarding this code

NSLayoutConstraint.activate([
    imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    imageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 8),
    imageView.heightAnchor.constraint(equalToConstant: view.heightAnchor, mult0.7),
    imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: 1.0)
])

Xcode is complaining about ‘Int’ is not convertible to ‘CGFloat’ which is very misleading. Can you spot the error?

The problem is that we are using equalToConstant , not equalTo . The generic constraints of NSLayoutAnchor is giving us misleading errors and can waste lots of time of us trying to figure out the subtle typos.

Abstractions over Auto Layout

NSLayoutAnchor is getting more popular now but it is not without any flaws. Depending on personal taste, there might be some other forms of abstractions over Auto Layout, namely Cartography and SnapKit, which I ‘ve used and loved. Here are a few of my takes on those.

Cartography

Cartography is one of the most popular ways to do Auto Layout in iOS. It uses operators which makes constraints very clear:

constrain(button1, button2) { button1, button2 in
    button1.right == button2.left - 12
}

What I don’t like about Cartography is that we have to repeat parameter names and that the parameters inside closure are just proxies, not real views and there is a limit on the number of constrain items.

Another huge con was that long compilation time issue due to excessive use of operators Very long compile time in large functions. Although Swift compilation time is getting better, this was a big problem. I even had to write a script to remove Cartography to use simple NSLayoutAnchor, so take a look at AutoLayoutConverter, which converts Cartography code from

constrain(logoImnageView, view1, view2) { logoImageView, view1, view2 in
    logoImageView.with == 74
    view1.left == view2.left + 20
}

to simple NSLayoutAnchor

Constraint.on(
  logoImageView.widthAnchor.constraint(equalToConstant: 74),
  view1.leftAnchor.constraint(equalTo: view2.leftAnchor, constant: 20),
)

There are always tradeoffs but to reduce compilation time at the time was a top priority.

SnapKit

SnapKit, originally Masonry, is perhaps the most popular Auto Layout wrapper

box.snp.makeConstraints { (make) -> Void in
   make.width.height.equalTo(50)
   make.center.equalTo(self.view)
}

The syntax is nice and with snp namespace to avoid extension name clashes, which I love.

The thing I don’t like about SnapKit is that limited closure. We can only work on 1 view at a time, and the make inside closure is just a proxy, which does not seem intuitive.

Imagine if we’re gonna make paging views or piano, where each view is stacked side by side. We need a lot of SnapKit calls as we can only work on 1 view at a time. Also, there is no clear relationship where we connect with the other view.

[https://github.com/onmyway133/blog/issues/22](https://github.com/onmyway133/blog/issues/22)https://github.com/onmyway133/blog/issues/22

keyB.snp.makeConstraints { (make) -> Void in
   make.left.equalTo(self.keyA.right)
}

keyC.snp.makeConstraints { (make) -> Void in
   make.left.equalTo(self.keyB.right)
}

keyD.snp.makeConstraints { (make) -> Void in
   make.left.equalTo(self.keyC.right)
}

The many overloading functions

There are also attempts to build simple Auto Layout wrapper functions but that escalates very quickly.

We might begin with an extension that pins edge constraints to superview.

box.pinEdgesToSuperview()

But a view does not always pin to its superview, it can be to another view, then we add another function

box.pinEdgesToView(_ view: UIView)

It would be nice if there is some padding, isn’t it? Let’s add insets options

box.pinEdgesToView(_ view: UIView, insets: UIEdgeInsets)

There might be cases where we only want to pin top, left, and right and not the bottom, let’s add another parameter

box.pinEdgesToView(_ view: UIView, insets: UIEdgeInsets, exclude: NSLayoutConstraint.Attribute)

Constraints are not always 1000 priorities, it can be lower. We need to support that

box.pinEdgesToView(_ view: UIView, insets: UIEdgeInsets, exclude: NSLayoutConstraint.Attribute, priority: NSLayoutConstraint.Priority)

We might exclude more than one properties or set different priority levels for each constraint. The simple wrapper with overloading functions and default parameters are just like building rigid abstraction based on premature assumptions. This just limits us in the long run and not scalable 😢

Embracing Auto Layout

All the Auto Layout frameworks out there are just convenient ways to build NSLayoutConstraint, in fact, these are what you normally need

  • Call addSubview so that view is in the hierarchy

  • Set translatesAutoresizingMaskIntoConstraints = false

  • Set isActive = true to enable constraints

Here is how to make an extension on NSLayoutConstraint that disables translatesAutoresizingMaskIntoConstraints for the involved views. Code is from Omnia

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public extension NSLayoutConstraint {

/// Disable auto resizing mask and activate constraints
///
/// - Parameter constraints: constraints to activate
static func on(_ constraints: [NSLayoutConstraint]) {
constraints.forEach {
($0.firstItem as? UIView)?.translatesAutoresizingMaskIntoConstraints = false
$0.isActive = true
}
}

static func on(_ constraintsArray: [[NSLayoutConstraint]]) {
let constraints = constraintsArray.flatMap({ $0 })
NSLayoutConstraint.on(constraints)
}
}

Here before we activate constraints, we find the firstItem then disables translatesAutoresizingMaskIntoConstraints. From Swift 4.2 there is a separation between compactMap and flatMap so we can safely use flatMap to flatten an array of arrays. This is useful when we have an array of arrays of constraints.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public extension UIView {
func pinCenter(view: UIView) -> [NSLayoutConstraint] {
return [
centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerYAnchor.constraint(equalTo: view.centerYAnchor)
]
}

func pin(size: CGSize) -> [NSLayoutConstraint] {
return [
widthAnchor.constraint(equalToConstant: size.width),
heightAnchor.constraint(equalToConstant: size.height)
]
}
}

With that, we can pin the red box with a certain size and to the center of the screen:

NSLayoutConstraint.on([
    box.pinCenter(view: view),
    box.pin(size: CGSize(width: 100, height: 50))
])

This is a very thin but powerful wrapper over NSLayoutAnchor and we can expand it the way we need. It sadly has some problems, like we can’t easily change the priority, as we have to reference the constraint 😢

let topConstraint = box.topAnchor.constraint(equalTo: view.topAnchor, constant: 50)
topConstraint.priority = UILayoutPriority.defaultLow

NSLayoutConstraint.on([
    topConstraint
])

Making Auto Layout more convenient with the builder pattern

The above extension on NSLayoutConstraint works well. However, if you’re like me who wants even more declarative and fast Auto Layout code, we can use the builder pattern to make Auto Layout even nicer. The builder pattern can be applied to many parts of the code but I find it very well suited for Auto Layout. The final code is Anchors on GitHub, and I will detail how to make it.

Here is what we want to achieve to quickly position 4 views:

activate(
  boxA.anchor.top.left,
  boxB.anchor.top.right,
  boxC.anchor.bottom.left,
  boxD.anchor.bottom.right
)

Most of the times, we want to anchor to parent view, so that should be implicitly done for us. I like to have anchor namespace to avoid extension naming clashes and to make it the starting point for all our convenient Auto Layout code. Let’s identify a few core concepts

Which objects can interact with Auto Layout?

Currently, there are 3 types of objects that can interact withAuto Layout

  • UIView

  • UILayoutSupport, from iOS 7, for UIViewController to get bottomLayoutGuide and topLayoutGuide . In iOS 11, we should use safeAreaLayoutGuide from UIView instead

  • UILayoutGuide: using invisible UIView to do Auto Layout is expensive, that’s why Apple introduced layout guides in iOS 9 to help.

So to support these 3 with anchor namespace, we can make Anchor object that holds AnyObject as behind the scene, NSLayoutConstraint works with AnyObject:

public class Anchor: ConstraintProducer {
  let item: AnyObject

/// Init with View
  convenience init(view: View) {
    self.init(item: view)
  }

/// Init with Layout Guide
  convenience init(layoutGuide: LayoutGuide) {
    self.init(item: layoutGuide)
  }

// Init with Item
  public init(item: AnyObject) {
    self.item = item
  }
}

Now we can define anchor property

public extension View {
  var anchor: Anchor {
    return Anchor(view: self)
  }
}

public extension LayoutGuide {
  var anchor: Anchor {
    return Anchor(layoutGuide: self)
  }
}

Which properties are needed in a layout constraint?

The builder patterns make building things declaratively by holding temporary values. Besides from, to, priority, identifier, we need an array of pins to handle cases where there are multiple created constraints. A center constraint results in both centerX and centerY constraints, and an edge constraint results in top, left, bottom and right constraints.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum To {
case anchor(Anchor)
case size
case none
}
class Pin {
let attribute: Attribute
var constant: CGFloa
init(_ attribute: Attribute, constant: CGFloat = 0) {
self.attribute = attribute
self.constant = constant
}
let item: AnyObject
// key: attribute
// value: constant
var pins: [Pin] = [
var multiplierValue: CGFloat = 1
var priorityValue: Float?
var identifierValue: String?
var referenceBlock: (([NSLayoutConstraint]) -> Void)?
var relationValue: Relation = .equal
var toValue: To = .none

With this, we can also expand our convenient anchor to support more constraints, like spacing horizontally, which adds left and right constraints with correct constants. Because as you know, in Auto Layout, for right and bottom direction, we need to use negative values:

func paddingHorizontally(_ value: CGFloat) -> Anchor {
  removeIfAny(.leading)
  removeIfAny(.trailing
  pins.append(Pin(.leading, constant: value))
  pins.append(Pin(.trailing, constant: -value)
  return self
}

Inferring constraints

There are times we want to infer constraints, like if we want a view’s height to double its width. Since we already have width, declaring ratio should pair the height to the width.

box.anchor.width.constant(10)
box.anchor.height.ratio(2) // height==width*2

This is easily achieved by checking our pins array

if sourceAnchor.exists(.width) {
  return Anchor(item: sourceAnchor.item).width
    .equal
    .to(Anchor(item: sourceAnchor.item).height)
    .multiplier(ratio).constraints()
} else if sourceAnchor.exists(.height) {
  return Anchor(item: sourceAnchor.item).height
    .equal
    .to(Anchor(item: sourceAnchor.item).width)
    .multiplier(ratio).constraints()
} else {
  return []
}

Retrieving a constraint

I see we’re used to storing constraint property in order to change its constant later. The constraints property in UIView has enough info and it is the source of truth, so retrieving constraint from that is more preferable.

Here‘s how we find constraint and update that

boxA.anchor.find(.height)?.constant = 100

// later
boxB.anchor.find(.height)?.constant = 100

// later
boxC.anchor.find(.height)?.constant = 100

The code to find constraint is very straightforward.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public extension Anchor {
/// Find a constraint based on an attribute
func find(_ attribute: Attribute) -> NSLayoutConstraint? {
guard let view = item as? View else {
return nil
}
var constraints = view.superview?.constraints
if attribute == .width || attribute == .height {
constraints?.append(contentsOf: view.constraints)
}
return constraints?.filter({
guard $0.firstAttribute == attribute else {
return false
}
guard $0.firstItem as? NSObject == view else {
return false
}
return true
}).first
}
}

How to reset constraints

One of the patterns I see all over is resetting constraints in UITableViewCell or UICollectionViewCell. Depending on the state, the cell removes certain constraints and add new constraints. Cartography does this well by using group.

constrain(view, replace: group) { view in
    view.top  == view.superview!.top
    view.left == view.superview!.left
}

If we think about it, NSLayoutConstraint is just layout instructions. It can be activated or deactivated . So if we can group constraints, we can activate or deactivate them as a whole.

Here is how to declare 4 groups of constraints, the syntax is from Anchors but this applies to NSLayoutAnchor as well since those generateNSLayoutConstraint under the hood.

let g1 = group(box.anchor.top.left)
let g2 = group(box.anchor.top.right)
let g3 = group(box.anchor.bottom.right)
let g4 = group(box.anchor.bottom.left)

Where to go from here

In this article, we step a bit in time into manual layout, autoresizing masks and then to the modern Auto Layout. The Auto Layout APIs have improvements over the years and are recommended way to do layout. Learning declarative layout also helps me a lot when I learn Constraint Layout in Android, flexbox in React Native or the widget layout in Flutter.

The post goes through the detailed implementation of how we can build more convenient Auto Layout like Anchors with the builder pattern. In the next article, let’s explore the many ways to debug Auto Layout and how to correctly do Auto Layout for different screen sizes.

In the meantime, let’s play Tetris in Auto Layout, because why not 😉

[https://github.com/onmyway133/Anchors](https://github.com/onmyway133/Anchors)https://github.com/onmyway133/Anchors

activate(
  lineBlock.anchor.left.bottom
)

// later
activate(
  firstSquareBlock.anchor.left.equal.to(lineBlock.anchor.right),
  firstSquareBlock.anchor.bottom
)

// later
activate(
  secondSquareBlock.anchor.right.bottom
)

Read more


Updated at 2021-01-05 07:22:31

How to convert from paid to free with IAP

Issue #703

What is receipt

Read When to refresh a receipt vs restore purchases in iOS?

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.

Note

  • Receipt is generated and bundled with your app when user download the app, whether it is free or paid
  • When user makes IAP, receipt is updated with IAP information
  • When user downloads an app (download free, or purchase paid app), they get future updates (whether free or paid) forever.
  • Call SKReceiptRefreshRequest or SKPaymentQueue.restoreCompletedTransactions asks for Appstore credential
  • When we build the app from Xcode or download from Testflight, receipt is not bundled within the app since the app is not downloaded from AppStore. We can use SKReceiptRefreshRequest to download receipt from sandbox Appstore
  • restoreCompletedTransactions updates app receipt
  • Receipt is stored locally on device, so when user uninstalls and reinstalls your app, there’s no in app purchases information, this is when you should refresh receipt or restoreCompletedTransactions

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/

receipt

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

receipt tips

Restoring Purchased Products

Read Restoring Purchased Products

Users sometimes need to restore purchased content, such as when they upgrade to a new phone.

Don’t automatically restore purchases, especially when your app is launched. Restoring purchases prompts for the user’s App Store credentials, which interrupts the flow of your app

In most cases, you only need to refresh the app receipt and deliver the products listed on the receipt. The refreshed receipt contains a record of the user’s purchases in this app, from any device the user’s App Store account is logged into

Refreshing a receipt doesn’t create new transactions; it requests the latest copy of the receipt from the App Store

Restoring completed transactions creates a new transaction for every transaction previously completed, essentially replaying history for your transaction queue observer. Your app maintains its own state to keep track of why it’s restoring completed transactions and how to handle them.

What are the different IAP types

From AppStore

Consumable (pay everytime)

A consumable In-App Purchase must be purchased every time the user downloads it. One-time services, such as fish food in a fishing app, are usually implemented as consumables.

Non-Consumable (one time payment)

Non-consumable In-App Purchases only need to be purchased once by users. Services that do not expire or decrease with use are usually implemented as non-consumables, such as new race tracks for a game app.

Auto-Renewable Subscriptions (will deduct money from your credit card on a cycle complete)

Auto-renewable Subscriptions allow the user to purchase updating and dynamic content for a set duration of time. Subscriptions renew automatically unless the user opts out, such as magazine subscriptions.

Free Subscription (no payment and is still visible even you did not submitted your account detail to itunes connect)

Free subscriptions are a way for developers to put free subscription content in Newsstand. Once a user signs up for a free subscription, it will be available on all devices associated with the user’s Apple ID. Note that free subscriptions do not expire and can only be offered in Newsstand-enabled apps.

Non-Renewing (need to renew manually)

Subscription Non-Renewing Subscriptions allow the sale of services with a limited duration. Non-Renewing Subscriptions must be used for In-App Purchases that offer time-based access to static content. Examples include a one week subscription to voice guidance feature within a navigation app or an annual subscription to online catalog of archived video or audio.

When is app receipt missing

Read SKReceiptRefreshRequest

Use this API to request a new receipt if the receipt is invalid or missing

Receipt is stored locally on device. It can be missing in case user sync or restore device.

Watch WWDC 2014 - 305 Preventing Unauthorized Purchases with Receipts

How to check receipt existence

1
2
Bundle.main.appStoreReceiptURL
checkResourceIsReachable

How to read receipt

Read In-App Purchases: Receipt Validation Tutorial

The receipt consists of a single file in the app bundle. The file is in a format called PKCS #7. The payload consists of a set of receipt attributes in a cross-platform format called ASN.1

1
2
3
4
5
6
7
8
9
10
case 12: // Receipt Creation Date
var dateStartPtr = ptr
receiptCreationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)

case 17: // IAP Receipt
print("IAP Receipt.")

case 19: // Original App Version
var stringStartPtr = ptr
originalAppVersion = readASN1String(ptr: &stringStartPtr, maxLength: length)

Use TPInAppReceipt which includes certificates.

1
try InAppReceipt.localReceipt()

Check Receipt Fields

1
2
3
4
Original Application Version
The version of the app that was originally purchased.
ASN.1 Field Type 19
ASN.1 Field Value UTF8STRING

Note

This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString (in macOS) in the Info.plist file when the purchase was originally made

CFBundleVersion is build number, and CFBundleShortVersionString is app version

1
2
3
In-App Purchase Receipt
The receipt for an in-app purchase.
ASN.1 Field Type 17

Read Validating Receipts with the App Store

Sample verifyReceipt json

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
{
"receipt": {
"receipt_type": "ProductionSandbox",
"adam_id": 0,
"app_item_id": 0,
"bundle_id": "com.example.app.ios",
"application_version": "3",
"download_id": 0,
"version_external_identifier": 0,
"receipt_creation_date": "2018-11-13 16:46:31 Etc/GMT",
"receipt_creation_date_ms": "1542127591000",
"receipt_creation_date_pst": "2018-11-13 08:46:31 America/Los_Angeles",
"request_date": "2018-11-13 17:10:31 Etc/GMT",
"request_date_ms": "1542129031280",
"request_date_pst": "2018-11-13 09:10:31 America/Los_Angeles",
"original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
"original_purchase_date_ms": "1375340400000",
"original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
"original_application_version": "1.0",
"in_app": [{
"quantity": "1",
"product_id": "test2",
"transaction_id": "1000000472106082",
"original_transaction_id": "1000000472106082",
"purchase_date": "2018-11-13 16:46:31 Etc/GMT",
"purchase_date_ms": "1542127591000",
"purchase_date_pst": "2018-11-13 08:46:31 America/Los_Angeles",
"original_purchase_date": "2018-11-13 16:46:31 Etc/GMT",
"original_purchase_date_ms": "1542127591000",
"original_purchase_date_pst": "2018-11-13 08:46:31 America/Los_Angeles",
"is_trial_period": "false"
}]
},
"status": 0,
"environment": "Sandbox"
}

Verify your receipt first with the production URL; then verify with the sandbox URL if you receive a 21007 status code. This approach ensures you do not have to switch between URLs while your application is tested, reviewed by App Review, or live in the App Store.

Show me the code

Let’s use enum to represent possible states for each resource. Here’s simple case where we only have 1 non consumable IAP product.

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
enum IAPError: Error {
case failedToRefreshReceipt
case failedToRequestProduct
case failedToPurchase
case receiptNotFound
}

enum IAPResourceState<T> {
case notAsked
case loading
case success(T)
case failure(IAPError)
}

final class PricingPlan: ObservableObject {
static let pro = (Bundle.main.bundleIdentifier ?? "") + ".pro"

@Published
var isPro: Bool = false
@Published
var product: IAPResourceState<SKProduct> = .notAsked
@Published
var purchase: IAPResourceState<SKPayment> = .notAsked
@Published
var receipt: IAPResourceState<InAppReceipt> = .notAsked
}

Let’s have a central place for managing all IAP operations, called IAPManager, it can update our ObservableObject PricingPlan hence triggers update to SwiftUI.

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
import StoreKit
import TPInAppReceipt
import Version

final class IAPManager: NSObject {
private let pricingPlan: PricingPlan
private let paymentQueue: SKPaymentQueue

init(pricingPlan: PricingPlan) {
self.pricingPlan = pricingPlan
self.paymentQueue = SKPaymentQueue.default()

super.init()

self.paymentQueue.add(self)
}

func requestProducts() {
let identifiers = PricingPlan.pro
let request = SKProductsRequest(productIdentifiers: Set(arrayLiteral: identifiers))
request.delegate = self
pricingPlan.product = .loading
request.start()
}

func purchase(product: SKProduct) {
guard SKPaymentQueue.canMakePayments() else {
showAlert(text: "You are not allowed to make payment. Please check device settings.")
return
}

pricingPlan.purchase = .loading
let payment = SKPayment(product: product)
paymentQueue.add(payment)
}

func refreshReceipt() {
let request = SKReceiptRefreshRequest()
request.delegate = self
request.start()
}

func restorePurchase() {
paymentQueue.restoreCompletedTransactions()
}
}

Refresh receipt

You can use restoreCompletedTransactions if you simply finishTransaction and grant user pro feature, like in this simple tutorial In-App Purchase Tutorial: Getting Started, search for SKPaymentTransactionObserver. restoreCompletedTransactions also updates receipt.

Otherwise refreshing receipt is a better idea. It serves both case when receipt is not there locally and when you want to restore transactions. With receipt refreshing, no restored transactions are created and SKPaymentTransactionObserver is not called, so we need to check receipt proactively.

Either restoreCompletedTransactions or SKReceiptRefreshRequest asks for AppStore credential so you should present a button there and ask user.

Check local receipt

Try to locate local receipt and examine it.

  • If it is not there (missing, corrupted), refresh receipt
  • If it’s there, check if it was from a version when the app was still as paid. Notice the difference in meaning of originalAppVersion in macOS and iOS
  • If it is not paid, check if this receipt contains In App Purchase information for our product

In practice, we need to perform some basic checks on receipt, like bundle id, app version, device id. Read In-App Purchases: Receipt Validation Tutorial, search for Validating the Receipt. TPInAppReceipt also has some handy verify functions

Besides verifying receipt locally, it is advisable to call verifyreceipt either on device, or better on serve to let Apple verify receipt and returns you a human readable json for receipt information.

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
func checkReceipt() {
DispatchQueue.main.async {
do {
let receipt = try InAppReceipt.localReceipt()
self.pricingPlan.receipt = .success(receipt)
if self.isPaid(receipt: receipt) {
self.pricingPlan.isPro = true
} else if receipt.containsPurchase(ofProductIdentifier: PricingPlan.pro) {
self.pricingPlan.isPro = true
}
} catch {
self.pricingPlan.receipt = .failure(.receiptNotFound)
}
}
}

private func isPaid(receipt: InAppReceipt) -> Bool {
#if os(macOS)
// originalAppVersion is CFBundleShortVersionString
if let version = Version(receipt.originalAppVersion) {
return version < versionToIAP
}
#else
// originalAppVersion is CFBundleVersion
if let buildNumber = Int(receipt.originalAppVersion) {
return buildNumber < buildNumberToIAP
}
#endif
return false
}

Finally, observe SKProductsRequestDelegate which also conforms to SKRequestDelegate for both product and receipt refresh request

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
extension IAPManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
DispatchQueue.main.async {
guard let product = response.products.first else {
self.pricingPlan.product = .failure(IAPError.failedToRequestProduct)
return
}

self.pricingPlan.product = .success(product)
}
}

func request(_ request: SKRequest, didFailWithError error: Error) {
DispatchQueue.main.async {
switch request {
case is SKProductsRequest:
self.pricingPlan.product = .failure(IAPError.failedToRequestProduct)
case is SKReceiptRefreshRequest:
self.pricingPlan.receipt = .failure(IAPError.failedToRefreshReceipt)
default:
break
}

}
}

func requestDidFinish(_ request: SKRequest) {
switch request {
case is SKReceiptRefreshRequest:
checkReceipt()
default:
break
}
}
}

Updated at 2020-12-04 06:58:17

How to avoid multiple match elements in UITests from iOS 13

Issue #691

Supposed we want to present a ViewController, and there exist both UIToolbar in both the presenting and presented view controllers.

From iOS 13, the model style is not full screen and interactive. From UITests perspective there are 2 UIToolbar, we need to specify the correct one to avoid multiple match errors

1
let editButton = app.toolbars["EditArticle.Toolbar"].buttons["Edit"]

Updated at 2020-11-04 10:02:29

How to test push notifications in simulator and production iOS apps

Issue #682

After has recently reminded about his updating APNs provider API which makes me realised a lot has changed about push notifications, both in terms of client and provider approach.

The HTTP/2-based Apple Push Notification service (APNs) provider API lets you take advantage of great features, such as authentication with a JSON Web Token, improved error messaging, and per-notification feedback. If you send push notifications with the legacy binary protocol, we strongly recommend upgrading to the APNs provider API.

From the the iOS client point of view, you pretty much don’t need to care about provider API, as you only need to register for push notification capability and send the device token to your backend, and it’s the backend’s job to send push request to Apple notifications server.

But it’s good to know the underlying mechanism, especially when you want to troubleshoot push notification. Since a push notification failure can be because of many reasons: it could be due to some failed logic in your backend, or that the push certificate has expired, or that you have sent the wrong device token, or that user has turned off push permission in Settings app, or that the push has been delayed.

Here’s also a common abbreviations you need to learn: APNs it is short for Apple Push Notification service. You can think of it as the server your device always has connection with, so you’re ready for any server push messages from it.

What is binary provider API

Push notification feature was first introduced by Apple in 2009 via a fairly complex binary provider API

binary

In short, binary provider API is just a specification about which address and which format to send push request to Apple push server. The binary interface of the production environment is available through gateway.push.apple.com, port 2195 and development environment gateway.sandbox.push.apple.com, port 2195. The binary interface employs a plain TCP socket for binary content that is streaming in nature.

As you can see in the package instruction above, we need specify frame length and data. Data is a key value pairs of multiple informations like device token, expiration, topic, priority.

Send request to the above addresses via secured TLS or SSL channel. The SSL certificate required for these connections is obtained from your developer account.

The new HTTP/2 provider API

The new HTTP2/ provider API is recommended, it has detailed error reporting and better throughput. If you use URLSession which supports the HTTP/1.1 and HTTP/2 protocols. HTTP/2 support, as described by RFC 7540, you will feel familiar.

In short, when you have a notification to send to a user, your provider must construct a POST request and send it to Apple Push Notification service (APNs). Your request must include the following information:

  • The JSON payload that you want to send
  • The device token for the user’s device
  • Request-header fields specifying how to deliver the notification
  • For token-based authentication, your provider server’s current authentication token

Upon receiving your server’s POST request, APNs validates the request using either the provided authentication token or your server’s certificate. If validation succeeds, APNs uses the provided device token to identify the user’s device. It then tries to send your JSON payload to that device.

So it’s pretty much how you configure URLRequestwith URLSession, specify base url, some request headers including the authorization and the payload body.

Use HTTP/2 and TLS 1.2 or later to establish a connection to the new provider API endpoint. For development serverit is api.sandbox.push.apple.com:443 and for production server it is api.push.apple.com:443. You then send the request as POST and Apple will do its job to verify and send the push notification to the users device.

Certificate vs token based authentication

To send push request to APNs, you need to authenticate to tell that is really from you. APNs support 2 types of authentication, the traditional way with a certificate, and the recently new recommended way with a p8 token.

Certificate based authentication

For certificate based authentication, you will need a p12 certificate which you can obtain and generate from your Developer account.

cer

Because there are sandbox and production push endpoint, few years ago it was required to have separate sandbox and production environment push certificate, which you had to create separately in your Apple developer account, then in testing need to specify the correct push certificate for each environment.

Now around iOS 10 year we can use just 1 single certificate for both sandbox and production, which is a relief for us developers. When we create certificate on Apple developer page, we need to upload a certificate signing request that we can generate from Keychain Access app. After we download the generated push certificate, download and run it in Keychain, there we can generate p12 key file that contains both certificate and private key to sign our push request.

Certificate and provisioning profiles valid for only 1 year. So every year you have to renew push notification certificates, which is also a good and bad thing. Besides that, every app differs from bundle id, so we kinda have to generate certificate for each app.

Token based authentication

Token-based authentication offers a stateless way to communicate with APNs. Stateless communication is faster than certificate-based communication because it doesn’t require APNs to look up the certificate, or other information, related to your provider server. There are other advantages to using token-based authentication:

The cool thing about token is that you can use one token to distribute notifications for all of your company’s apps. You can create in your Apple developer account. Key authentication is the recommended way to authenticate with Apple services, so it is used not only for push services, but also for MusicKit and DeviceCheck.

key

When you request a key, Apple gives you a 10-character string with the Key ID which you must include this string in your JSON tokens and an authentication token signing key, specified as a text file (with a .p8 file extension). The key is allowed to download once and you need to keep it properly.

The token that you include with your notification requests uses the JSON Web Token (JWT) specification. The token itself contains four key-value pairs

Screenshot 2020-10-11 at 06 28 34

After all, the JSON Web Token is encoded in this authorization HTTP headers in your request like this

1
2
3
authorization = bearer eyAia2lkIjogIjhZTDNHM1JSWDciIH0.eyAiaXNzIjogIkM4Nk5WOUpYM0QiLCAiaWF0I
jogIjE0NTkxNDM1ODA2NTAiIH0.MEYCIQDzqyahmH1rz1s-LFNkylXEa2lZ_aOCX4daxxTZkVEGzwIhALvkClnx5m5eAT6
Lxw7LZtEQcH6JENhJTMArwLf3sXwi

For security, APNs requires you to refresh your token regularly. Refresh your token no more than once every 20 minutes and no less than once every 60 minutes.

How to register for push notifications from iOS app

The APIs to register for remote notification has changed over the years.

iOS 7

In iOS 7, we used to use this method registerForRemoteNotificationTypes to register to receive remote notifications of the specified types via Apple Push Notification service.

The types can be UIRemoteNotificationTypeBadge, UIRemoteNotificationTypeAlert, UIRemoteNotificationTypeSound

When you send this message, the device initiates the registration process with Apple Push Notification service. If it succeeds, the app delegate receives a device token in the application:didRegisterForRemoteNotificationsWithDeviceToken: method; if registration doesn’t succeed, the delegate is informed via the application:didFailToRegisterForRemoteNotificationsWithError:method. If the app delegate receives a device token, it should connect with its provider and pass it the token.

iOS 8 with registerUserNotificationSettings

From iOS 8, there’s separation between asking for a remote notification with device token, and with presenting push message to the user. This confused developers as these 2 things are separate now.

First, we use registerForRemoteNotifications to register to receive remote notifications via Apple Push Notification service.

Call this method to initiate the registration process with Apple Push Notification service. If registration succeeds, the app calls your app delegate object’s application(_:didRegisterForRemoteNotificationsWithDeviceToken:) method and passes it a device token. You should pass this token along to the server you use to generate remote notifications for the device. If registration fails, the app calls its app delegate’s application(_:didFailToRegisterForRemoteNotificationsWithError:) method instead.

In short, this is to receive device token from APNs so we can do silent push notification or other things. Note that we need to enable Remote notification capability for Background modes.

Screenshot 2020-10-11 at 06 39 46

To present push message to user via alert, banner, badge or sound, we need to explicitly ask for using this method registerUserNotificationSettings to registers your preferred options for notifying the user.

If your app displays alerts, play sounds, or badges its icon, you must call this method during your launch cycle to request permission to alert the user in these ways. After calling this method, the app calls the application(_ application: UIApplication, didRegister notificationSettings: UIUserNotificationSettings) method of its app delegate to report the results. You can use that method to determine if your request was granted or denied by the user.

iOS 10 with UserNotifications framework

In iOS 10, Apple introduced UserNotifications and UserNotificationsUI framework and lots of new features to push notifications like actions and attachments.

To ask for permission to present push message from iOS 10, use the new UNUserNotificationCenter which accepts options and block callback with grant status.

1
UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert])

abc

There ‘s also UNNotificationAction and UNNotificationAttachment to specify additional actions and attachment to go along with the push message, this is very handy for visual purpose and convenient actions user can perform right away from the push message.

There’s also a convenient UserNotificationsUI that was shipped with iOS 10 that allows us to embed custom view controller from our push message

ui

When an iOS device receives a notification containing an alert, the system displays the contents of the alert in two stages. Initially, it displays an abbreviated banner with the title, subtitle, and two to four lines of body text from the notification. If the user presses the abbreviated banner, iOS displays the full notification interface, including any notification-related actions. The system provides the interface for the abbreviated banner, but you can customize the full interface using a notification content app extension.

Also, there is this callback userNotificationCenter _:willPresent that asks the delegate how to handle a notification that arrived while the app was running in the foreground.

If your app is in the foreground when a notification arrives, the shared user notification center calls this method to deliver the notification directly to your app. If you implement this method, you can take whatever actions are necessary to process the notification or show it when your app is running.

iOS 12 with provisional push

New in iOS 12 is the UNAuthorizationStatus.provisional, which are notifications that appear silently in the user’s notification center without appearing on the user’s home screen. We can start sending them as soon as a user has downloaded and run your app for the first time. You can send provisional push notifications unlimited times unless the user explicitly turns them off.

Screenshot 2020-10-11 at 06 58 34

This is good to send unobtrusive push to users in their Notification Center where they can pick up at a later time.

iOS 13 with apns-push-type

Starting with iOS 13 and watchOS 6, there is apns-push-type which must accurately reflect the contents of your notification’s payload. If there is a mismatch, or if the header is missing on required systems, APNs may return an error.

The apns-push-type header field has six valid values. The descriptions below describe when and how to use these values. For example alert for notifications that trigger a user interaction and background for notifications that deliver content in the background.

In a WWDC 2019 session Advances in App Background Execution, apns-priority must be set to 5 for content available notifications.

iOS 14 with ephemeral authorization status and AppClip

From Enable your App Clip to schedule and receive notifications for a short or extended time period.

Some App Clips may need to schedule or receive notifications to provide value. Consider an App Clip that allows users to order food for delivery: By sending notifications, the App Clip informs the user about an upcoming delivery. If notifications are important for your App Clip’s functionality, enable it to schedule or receive notifications for up to 8 hours after each launch.

Remote notification only with content-available

Besides user interface notification, there is content-available notification that delivers notifications that wake your app and update it in the background. If your app’s server-based content changes infrequently or at irregular intervals, you can use background notifications to notify your app when new content becomes available. Read Pushing Background Updates to Your App

Testing push notification in simulator

We have been able to drag images to the Photos app in simulator for years, but new in Xcode 11.4 is the ability to drag push payload to simulator to simulate remote push notification.

All we have to do is create an apns file with Simulator Target Bundle key to specify our app, then drag to the simulator

1
2
3
4
5
6
7
8
{
"Simulator Target Bundle": "com.onmyway133.PushHero",
"aps": {
"alert": "Welcome to Push Hero",
"sound": "chime",
"badge": 2
}
}

Many of the simulator features can be controlled via xcrun simctl command line tool where you can change status bar time, battery info, start and stop certain simulators and send push with xcrun simctl push. This is very handy in case you want to automate things.

Test push notification easily with Push Hero

As iOS developers who need to test push notification a lot, I face this challenge. That’s why I made Push Hero as a native macOS application that allows us to reliably test push notification. It is written in pure Swift with all the new APNs specification in mind.

Screenshot 2020-10-08 at 06 17 12

With Push Hero, we can setup multiple test scenario for different app. Each we can specify the authentication method we want, either with p8 token or p12 certificate based. There’s also input validation and hint helper that explains which field is needed and in which format, so you save time to work on your push testing instead.

New in latest version of Push Hero is the ability to send multiple pushes to multiple device tokens, which is the most frequent request. In the right history pane, there’s information about each request and response content, together with apns id information.

Also in Push Hero is the popup to show explanation for each field, and you need to consult Sending Notification Requests to APNs documentation as there are some specifications there. For example with VoIP push, the apns-topic header field must use your app’s bundle ID with .voip appended to the end. If you’re using certificate-based authentication, you must also register the certificate for VoIP services

Conclusion

Push notification continues to be important for iOS apps, and Apple has over the years improved and changed it for the better. This also means lots of knowledge to keep up with. Understanding provider APNs, setting up certificate and JSON Web Token key can be intimidating and take time.

Hopefully the above summary gets you more understanding into push notification, not only the history of changes in both client and provider API, but also some ways to test it easily.


Updated at 2021-01-01 23:26:17

How to draw arc corner using Bezier Path

Issue #673

To draw rounded 2 corners at top left and top right, let’s start from bottom left

1
2
3
4
5
6
7
8
9
10
let path = UIBezierPath()
// bottom left
path.move(to: CGPoint(x: 0, y: bounds.height))
// top left corner
path.addArc(withCenter: CGPoint(x: radius, y: radius), radius: radius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 3 / 2, clockwise: true)
// top right corner
path.addArc(withCenter: CGPoint(x: bounds.width - radius, y: radius), radius: radius, startAngle: CGFloat.pi * 3 / 2, endAngle: 0, clockwise: true)
// bottom right
path.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
path.close()
Screenshot 2020-09-15 at 14 16 01

cc

Read more


Updated at 2020-09-15 12:18:11

How to make dynamic font size for UIButton

Issue #671

Use adjustsFontForContentSizeCategory

A Boolean that indicates whether the object automatically updates its font when the device’s content size category changes.

If you set this property to YES, the element adjusts for a new content size category on a UIContentSizeCategoryDidChangeNotification.

1
2
3
4
5
6
7
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.backgroundColor = UIColor.green
button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .title1)

label.adjustsFontForContentSizeCategory = true
label.backgroundColor = UIColor.yellow
label.font = UIFont.preferredFont(forTextStyle: .title1)

However it seems view (UIButton or UILabel) size is the same, just the inner text increases in size. A workaround is to put view inside UIStackView so UIButton or UILabel can automatically changes size.

How to test for view disappear in navigation controller

Issue #670

To test for viewWillDisappear during UINavigationController popViewController in unit test, we need to simulate UIWindow so view appearance works.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
final class PopTests: XCTestCase {
func testPop() {
let window = UIWindow(frame: UIScreen.main.bounds)
let navigationController = UINavigationController()
window.rootViewController = navigationController
let viewController = DetailViewController()

navigationController.viewControllers = [
UIViewController(),
viewController
]

window.makeKeyAndVisible()
let expectation = XCTestExpectation()
navigationController.popViewController(animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
XCTAssertTrue(viewController.wasDismissed)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
}
}
1
2
3
4
5
6
7
8
class DetailViewController: UIViewController {
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent {
wasDismissed = true
}
}
}

Updated at 2020-08-14 07:30:07

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

Updated at 2020-06-10 19:15:21

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