How to handle UICollectionView reloadData with selected index path

Issue #434

When calling collectionView.reloadData(), selected indexpath stays the same, but be aware that order of data may have changed

1
2
3
4
5
6
7
8
let selectedData = ...
let indexPathForSelectedData = ...

collectionView.scrollToItem(
at: indexPathForSelectedData,
at: .centeredHorizontally,
animated: false
)

How to use Firebase ID token

Issue #424

One confusing point here that people often do not realize is that even though the custom token itself expires after one hour, a modern client SDK authenticated with that custom token will stay authenticated beyond that hour! What happens under the hood is that the custom token is sent to the Firebase Auth service in exchange for an ID token and refresh token pair which are used to keep the client SDK authenticated

As with custom tokens, ID tokens are short-lived JWTs, lasting for just one hour. In order to allow end users to stay logged in for more than one hour, the modern SDKs transparently refresh a user’s ID token on your behalf using a refresh token

If your app includes a custom backend server, ID tokens can and should be used to communicate securely with it. Instead of sending requests with a user’s raw uid which can be easily spoofed by a malicious client, send the user’s ID token which can be verified via a Firebase Admin SDK

When a user or device successfully signs in, Firebase creates a corresponding ID token that uniquely identifies them and grants them access to several resources, such as Firebase Realtime Database and Cloud Storage. You can re-use that ID token to identify the user or device on your custom backend server. To retrieve the ID token from the client, make sure the user is signed in and then get the ID token from the signed-in user:

How to constrain to views inside UICollectionViewCell in iOS

Issue #422

To constrain views outside to elements inside UICollectionViewCell, we can use UILayoutGuide.

Need to make layout guide the same constraints as the real elements

1
2
3
4
5
6
let imageViewGuide = UILayoutGuide()
collectionView.addLayoutGuide(imageViewGuide)
NSLayoutConstraint.on([
imageViewGuide.topAnchor.constraint(equalTo: collectionView.topAnchor, constant: 16),
imageViewGuide.heightAnchor.constraint(equalTo: collectionView.heightAnchor, multiplier: 0.5)
])
1
2
3
4
NSLayoutConstraint.on([
loadingIndicator.centerXAnchor.constraint(equalTo: collectionView.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: imageViewGuide.centerYAnchor)
])

How to secure CVC in STPPaymentCardTextField in Stripe for iOS

Issue #421

1
2
3
4
5
6
7
8
9
10
11
12
private func maskCvcIfAny() {
guard
let view = paymentTextField.subviews.first(where: { !($0 is UIImageView) }),
let cvcField = view.subviews
.compactMap({ $0 as? UITextField })
.first(where: { $0.tag == 2 && ($0.accessibilityLabel ?? "").lowercased().contains("cvc") })
else {
return
}

cvcField.isSecureTextEntry = true
}

where tag is in STPPaymentCardTextFieldViewModel.h

1
2
3
4
5
6
typedef NS_ENUM(NSInteger, STPCardFieldType) {
STPCardFieldTypeNumber,
STPCardFieldTypeExpiration,
STPCardFieldTypeCVC,
STPCardFieldTypePostalCode,
};

Also, need to check accessibilityLabel in STPPaymentCardTextField.m

1
2
3
4
5
6
7
- (NSString *)defaultCVCPlaceholder {
if (self.viewModel.brand == STPCardBrandAmex) {
return STPLocalizedString(@"CVV", @"Label for entering CVV in text field");
} else {
return STPLocalizedString(@"CVC", @"Label for entering CVC in text field");
}
}

How to easily parse deep json in Swift

Issue #414

Codable is awesome, but sometimes we just need to quickly get value in a deepy nested JSON. In the same way I did for Dart How to resolve deep json object in Dart, let’s make that in Swift.

See https://github.com/onmyway133/Omnia/blob/master/Sources/Shared/JSON.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
public func resolve<T>(_ jsonDictionary: [String: Any], keyPath: String) -> T? {
var current: Any? = jsonDictionary

keyPath.split(separator: ".").forEach { component in
if let maybeInt = Int(component), let array = current as? Array<Any> {
current = array[maybeInt]
} else if let dictionary = current as? JSONDictionary {
current = dictionary[String(component)]
}
}

return current as? T
}

So we can just resolve via key path

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
class JSONTests: XCTestCase {
func test() {
let json: [String: Any] = [
"outside": [
"object": [
"number": 1,
"text": "hello"
],
"arrayOfObjects": [
[
"number": 2,
"text": "two"
],
[
"number": 3,
"text": "three"
]
],
"arrayOfArrays": [
[
"one", "two", "three", "four"
],
[
"five", "six", "seven"
]
]
]
]

XCTAssertEqual(resolve(json, keyPath: "outside.object.number"), 1)
XCTAssertEqual(resolve(json, keyPath: "outside.object.text"), "hello")
XCTAssertEqual(resolve(json, keyPath: "outside.arrayOfObjects.1.number"), 3)
XCTAssertEqual(resolve(json, keyPath: "outside.arrayOfArrays.1.1"), "six")
}
}

How to speed up GMSMarker in Google Maps for iOS

Issue #412

  • Google Maps with a lot of pin, and no clustering can have bad performance if there are complex view in the marker.
  • The workaround is to use manual layout and rasterization

shouldRasterize

When the value of this property is true, the layer is rendered as a bitmap in its local coordinate space and then composited to the destination with any other content. Shadow effects and any filters in the filters property are rasterized and included in the bitmap. However, the current opacity of the layer is not rasterized. If the rasterized bitmap requires scaling during compositing, the filters in the minificationFilter and magnificationFilter properties are applied as needed.

In the class PinView: UIView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
isOpaque = true
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale

final class StopMarker: GMSMarker {
let stop: Stop
private let pinView = PinView()

init(stop: Stop) {
self.stop = stop
super.init()
self.position = stop.toCoordinate()
self.iconView = pinView
}
}

Read more

When your app needs to draw something on the screen, the GPU takes your layer hierarchy (UIView is just a wrapper on top of CALayer, which in the end are OpenGL textures) and applies one by one on top of each other based on their x,y,z position. In regular rendering, the whole operation happens in special frame buffers that the display will directly read for rendering on the screen, repeating the process at a rate around 60 times per second.

Of course the process have some drawbacks as well. The main one is that offscreen rendering requires a context switch (GPU has to change to a different memory area to perform the drawing) and then copying the resulting composited layer into the frame buffer. Every time any of the composited layers change, the cache needs to be redrawn again. This is why in many circumstances offscreen rendering is not a good idea, as it requires additional computation when need to be rerendered. Besides, the layer requires extra video memory which of course is limited, so use it with caution.

How to support drag and drop in UICollectionView iOS

Issue #411

See DragAndDrop example

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
class ViewController: UIViewController, UICollectionViewDropDelegate, UICollectionViewDragDelegate {

// MARK: - UICollectionViewDragDelegate

func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let controller = leftController

let provider = NSItemProvider(
object: controller.imageForCell(indexPath: indexPath)
)

let dragItem = UIDragItem(itemProvider: provider)
dragItem.localObject = indexPath
return [dragItem]
}

// MARK: - UICollectionViewDropDelegate

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {

let destinationIndexPath: IndexPath
if let indexPath = coordinator.destinationIndexPath {
destinationIndexPath = indexPath
} else {
destinationIndexPath = IndexPath(row: 0, section: 0)
}

let controller = rightController

let dragItemIndexPath = coordinator.items.last?.dragItem.localObject as! IndexPath
let draggedItem = leftController.items[dragItemIndexPath.item]

// remove
leftController.items.remove(at: dragItemIndexPath.item)
leftController.collectionView.deleteItems(at: [dragItemIndexPath])

// insert
controller.items.insert(draggedItem, at: destinationIndexPath.item)
controller.collectionView.insertItems(at: [destinationIndexPath])
}
}

How to test Date with timezone aware in Swift

Issue #402

I want to test if a date has passed another date

1
2
let base =  Date(timeIntervalSince1970: 1567756697)
XCTAssertEqual(validator.hasPassed(event: event, date: base), true)

My hasPassed is using Calendar.current

1
2
3
4
5
func minuteSinceMidnight(date: Date) -> MinuteSinceMidnight {
let calendar = Calendar.current
let start = calendar.startOfDay(for: date)
return Int(date.timeIntervalSince(start)) / 60
}

But the minute is always having timezone applied. Even if I try with DateComponents

1
2
3
4
5
6
7
8
func minuteSinceMidnight(date: Date) -> MinuteSinceMidnight {
let components = calendar.dateComponents([.hour, .minute], from: date)
guard let hour = components.hour, let minute = components.minute else {
return 0
}

return hour * 60 + minute
}

As long as I use Calendar, it always has timezone applied.

Checking this time interval 1567756697 on https://www.epochconverter.com/

Assuming that this timestamp is in seconds:
GMT: Friday, September 6, 2019 7:58:17 PM
Your time zone: Friday, September 6, 2019 9:58:17 PM GMT+02:00 DST

Because I have GMT+2, there will always be 2 hours offset. This works in app, but not in test because of the way I construct Date with time interval.

One way is to have test data using string construction, and provide timezone to DateFormatter

1
2
3
let formatter = ISO8601DateFormatter()
let date = formatter.date(from: "2019-07-58T12:39:00Z")
let string = formatter.string(from: Date())

Another way is to have a fixed timezone for Calendar

1
2
var calendar = Calendar.current
calendar.timeZone = TimeZone(secondsFromGMT: 0)!

Another way is to adjust existing date

1
calendar.date(bySettingHour: 20, minute: 02, second: 00, of: Date()

How to do simple analytics in iOS

Issue #395

Prefer static enum to avoid repetition and error. The Log should have methods with all required fields so the call site is as simple as possible. How to format and assign parameters is encapsulated in this Analytics.

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
import Foundation
import Firebase
import FirebaseAnalytics

struct Analytics {
enum Parameter: String {
case studentId = "student_id"
case classId = "class_id"
case url = "url"
}

enum Property: String {
case grantLocation = "grant_location"
}

enum Name: String {
case login
case logOut = "log_out"
case enroll
}

struct Log {
private func log(_ name: Name, parameters: [Parameter: String] = [:]) {
let mapped: [String: String] = Dictionary(uniqueKeysWithValues: parameters.map({ key, value in
return (key.rawValue, value)
}))

FirebaseAnalytics.Analytics.logEvent(name.rawValue, parameters: mapped)
}

private func set(userId: String?) {
FirebaseAnalytics.Analytics.setUserID(userId)
}

private func setProperty(_ property: Property, value: String) {
FirebaseAnalytics.Analytics.setUserProperty(value, forName: property.rawValue)
}
}

let log = Log()
}

extension Analytics.Log {
func grantLocation(hasGranted: Bool) {
setProperty(.grantLocation, value: hasGranted.toString())
}

func login(userId: String) {
log(.login)
set(userId: userId)
}

func logOut() {
log(.logOut)
set(userId: nil)
}

func enroll(classId: String) {
log(.enroll, parameters: [
.classId: classId
])
}
}

private extension Bool {
func toString() -> String {
return self ? "yes": "no"
}
}

How to choose Firebase vs Google Analytics

Issue #387

Google Analytics is shutting down. From Firebase Analytics console, we can choose to upgrade to Google Analytics, no code change is needed.

https://support.google.com/firebase/answer/9167112?hl=en

In October 2019, we will start to sunset Google Analytics mobile-apps reporting based on the Google Analytics Services SDKs for Android and iOS.

https://firebase.googleblog.com/2019/07/firebase-google-analytics-upgrade.html

Thanks to our continued partnership with Google Analytics, you can now upgrade your Firebase projects to the next generation of app analytics!

https://www.e-nor.com/blog/google-analytics/google-analytics-unifies-app-and-website-measurement

Google Analytics team has officially launched a new type of GA properties called “App + Web” to open public beta

It is a new GA property type that allows you to combine app and web data for unified reporting and analysis

Over the coming weeks, those who have existing Firebase projects will be able to upgrade your projects to the next generation Google Analytics experience as follows:

How to check app running on jailbreak iOS device

Issue #385

From https://github.com/OneSignal/OneSignal-iOS-SDK/blob/master/iOS_SDK/OneSignalSDK/Source/OneSignalJailbreakDetection.m

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
+ (BOOL)isJailbroken {

#if !(TARGET_IPHONE_SIMULATOR)

FILE *file = fopen("/Applications/Cydia.app", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/Library/MobileSubstrate/MobileSubstrate.dylib", "r");
if (file) {
fclose(file);
return YES;
}

file = fopen("/bin/bash", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/usr/sbin/sshd", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/etc/apt", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/usr/bin/ssh", "r");
if (file) {
fclose(file);
return YES;
}

NSFileManager *fileManager = [NSFileManager defaultManager];

if ([fileManager fileExistsAtPath:@"/Applications/Cydia.app"])
return YES;
else if ([fileManager fileExistsAtPath:@"/Library/MobileSubstrate/MobileSubstrate.dylib"])
return YES;
else if ([fileManager fileExistsAtPath:@"/bin/bash"])
return YES;
else if ([fileManager fileExistsAtPath:@"/usr/sbin/sshd"])
return YES;
else if ([fileManager fileExistsAtPath:@"/etc/apt"])
return YES;
else if ([fileManager fileExistsAtPath:@"/usr/bin/ssh"])
return YES;

// Omit logic below since they show warnings in the device log on iOS 9 devices.
if (NSFoundationVersionNumber > 1144.17) // NSFoundationVersionNumber_iOS_8_4
return NO;

// Check if the app can access outside of its sandbox
NSError *error = nil;
NSString *string = @".";
[string writeToFile:@"/private/jailbreak.txt" atomically:YES encoding:NSUTF8StringEncoding error:&error];
if (!error)
return YES;
else
[fileManager removeItemAtPath:@"/private/jailbreak.txt" error:nil];

// Check if the app can open a Cydia's URL scheme
if ([[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:@"cydia://package/com.example.package"]])
return YES;

#endif

return NO;
}

How to manage OneSignal push notification in iOS

Issue #377

OneSignal is an alternative for Parse for push notifications but the sdk has many extra stuff and assumptions and lots of swizzling.

We can just use Rest to make API calls. From https://github.com/onmyway133/Dust

Every official push notification SDK can do many things

  • Register device token. This is crucial for the notification to get from your backend -> APNS -> device
  • Manage player id, user id, arn, …This is used to associate with device token
  • Manager tag, topic, subscription, segments, …This is used to group a set of device tokens
  • Do swizzling, update your application badge number, change your user notification settings, … without your knowing about that
  • Some other fancy stuffs
  • Dust does only one thing, which is push notification handling. The rest is under your control

OneSignal

1
2
3
4
5
6
7
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject : AnyObject]?) -> Bool {
OneSignal.appID = ""
}

func application(application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: NSData) {
OneSignal.handleDeviceToken(deviceToken)
}

Here is the implementation

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

struct Utils {

static func parse(deviceToken data: NSData) -> String {
let buffer = UnsafePointer<CChar>(data.bytes)
var string = ""

for i in 0..<data.length {
string += String(format: "%02.2hhx", arguments: [buffer[i]])
}

return string
}

static func deviceModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)
var v = systemInfo.machine

var deviceModel = ""
let _ = withUnsafePointer(&v) {
deviceModel = String(UTF8String: UnsafePointer($0)) ?? ""
}

return deviceModel
}

static func systemVersion() -> String {
let version = NSProcessInfo.processInfo().operatingSystemVersion

return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
}

static func language() -> String {
return NSLocale.preferredLanguages().first!
}

static func timezone() -> Int {
return NSTimeZone.localTimeZone().secondsFromGMT
}

static func soundFiles() -> [String] {
guard let resourcePath = NSBundle.mainBundle().resourcePath
else { return [] }

let files = try? NSFileManager.defaultManager()
.contentsOfDirectoryAtPath(resourcePath)
.filter {
return $0.hasSuffix(".wav") || $0.hasSuffix(".mp3")
}

return files ?? []
}

static func versionNumber() -> String? {
return NSBundle.mainBundle().infoDictionary?["CFBundleShortVersionString"] as? String
}

static func buildNumber() -> String? {
return NSBundle.mainBundle().infoDictionary?["CFBundleVersionString"] as? String
}

static func netType() -> Int {
// Reachability
return 0
}
}
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
import Foundation

public struct UserDefaults {

struct Key {
static let playerID: String = "Dust-OneSignal-Player-ID-Key"
static let deviceToken: String = "Dust-OneSignal-Device-Token-Key"
static let subscribed: String = "Dust-OneSignal-Disable-Subscribed-Key"
}

public static var playerID: String? {
get {
return NSUserDefaults.standardUserDefaults().stringForKey(Key.playerID)
}

set {
NSUserDefaults.standardUserDefaults().setObject(newValue, forKey: Key.playerID)
NSUserDefaults.standardUserDefaults().synchronize()
}
}

public static var deviceToken: String? {
get {
return NSUserDefaults.standardUserDefaults().stringForKey(Key.deviceToken)
}

set {
NSUserDefaults.standardUserDefaults().setObject(newValue, forKey: Key.deviceToken)
NSUserDefaults.standardUserDefaults().synchronize()
}
}

public static var subscribed: Bool {
get {
return NSUserDefaults.standardUserDefaults().boolForKey(Key.subscribed)
}

set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: Key.subscribed)
NSUserDefaults.standardUserDefaults().synchronize()
}
}
}
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
import Foundation
import Alamofire

public struct OneSignal {

static var appID: String = ""
static let version = "020115"
static let baseURL = NSURL(string: "https://onesignal.com/api/v1")!

enum NotificationType: Int {
case subscribed = 7
case unsubscribed = -2

static func value() -> Int {
return UserDefaults.subscribed
? NotificationType.subscribed.rawValue : NotificationType.unsubscribed.rawValue
}
}

enum Provisioning: Int {
case development = 1
}

public static func setup(appID appID: String) {
NSUserDefaults.standardUserDefaults().registerDefaults([
UserDefaults.Key.subscribed: true
])

OneSignal.appID = appID
}

public static func registerOrUpdateSession(completion: ((String?) -> Void)? = nil) {
guard let bundleID = NSBundle.mainBundle().bundleIdentifier,
let deviceToken = UserDefaults.deviceToken
else {
return
}

var params: [String: AnyObject] = [
"app_id" : appID,
"device_model" : Utils.deviceModel(),
"device_os" : Utils.systemVersion(),
"language" : Utils.language(),
"timezone" : NSNumber(integer: Utils.timezone()),
"device_type" : NSNumber(integer : 0),
"sounds" : Utils.soundFiles(),
"sdk" : version,
"identifier" : deviceToken,
"net_type" : NSNumber(integer: Utils.netType()),
"rooted": NSNumber(bool: false),
"as_id": "OptedOut",
"sdk_type": "native",
"ios_bundle": bundleID,
"game_version": Utils.versionNumber() ?? "",
"notification_types": NotificationType.value(),
]

#if DEBUG
params["test_type"] = Provisioning.development.rawValue
#endif

let url: NSURL

if let playerID = UserDefaults.playerID {
url = baseURL.URLByAppendingPathComponent("players/\(playerID)/on_session")
} else {
url = baseURL.URLByAppendingPathComponent("players")
}

Alamofire
.request(.POST, url, parameters: params)
.responseJSON { response in
guard let json = response.result.value as? [String: AnyObject]
else {
completion?(nil)
return
}

if let id = json["id"] as? String {
UserDefaults.playerID = id
completion?(id)
} else if let value = json["success"] as? Int,
playerID = UserDefaults.playerID where value == 1 {
completion?(playerID)
} else {
completion?(nil)
}
}
}

public static func handle(deviceToken data: NSData) {
UserDefaults.deviceToken = Utils.parse(deviceToken: data)
registerOrUpdateSession()
}

public static func update(subscription subscribed: Bool) {
guard let playerID = UserDefaults.playerID else { return }
UserDefaults.subscribed = subscribed

let url = baseURL.URLByAppendingPathComponent("players/\(playerID)")
let params: [String: AnyObject] = [
"app_id": appID,
"notification_types": NotificationType.value()
]

Alamofire
.request(.PUT, url, parameters: params)
.responseJSON { response in
print(response)
}
}

public static func update(badge count: Int) {
guard let playerID = UserDefaults.playerID else { return }

let url = baseURL.URLByAppendingPathComponent("players/\(playerID)")
let params: [String: AnyObject] = [
"app_id": appID,
"badge_count": count
]

Alamofire
.request(.PUT, url, parameters: params)
.responseJSON { response in

}
}

public static func getPlayerID(completion: String -> Void) {
if let playerID = UserDefaults.playerID {
completion(playerID)
return
}

registerOrUpdateSession { playerID in
if let playerID = playerID {
completion(playerID)
}
}
}
}

How to do throttle and debounce using DispatchWorkItem in Swift

Issue #376

https://github.com/onmyway133/Omnia/blob/master/Sources/Shared/Debouncer.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Foundation

public class Debouncer {
private let delay: TimeInterval
private var workItem: DispatchWorkItem?

public init(delay: TimeInterval) {
self.delay = delay
}

/// Trigger the action after some delay
public func run(action: @escaping () -> Void) {
workItem?.cancel()
workItem = DispatchWorkItem(block: action)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem!)
}
}
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
import XCTest

class DebouncerTests: XCTestCase {

func testDebounce() {
let expectation = self.expectation(description: #function)
let debouncer = Debouncer(delay: 0.5)
var value = 0

debouncer.run(action: {
value = 1
})

debouncer.run(action: {
value = 2
})

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: {
debouncer.run(action: {
value = 3
})
})

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.71, execute: {
XCTAssertEqual(value, 3)
expectation.fulfill()
})

wait(for: [expectation], timeout: 1.2)
}
}

How to simplify UIApplication life cycle observation in iOS

Issue #375

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final class LifecyclerHandler {
private var observer: AnyObject!
var action: (() -> Void)?
private let debouncer = Debouncer(delay: 1.0)

func setup() {
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main,
using: { [weak self] _ in
self?.debouncer.run {
self?.action?()
}
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
private let lifecycleHandler = LifecyclerHandler()

override func viewDidLoad() {
super.viewDidLoad()


lifecycleHandler.action = {
Deps.userHandler.refreshToken()
}

lifecycleHandler.setup()
}

How to do UITests with Google Maps on iOS

Issue #374

Interact with GMSMapView

Add accessibilityIdentifier to the parent view of GMSMapView. Setting directly onto GMSMapView has no effect

1
accessibilityIdentifier = "MapView"
1
2
3
4
5
6
7
let map = app.otherElements.matching(identifier: "MapView").element(boundBy: 0)
map.pinch(withScale: 2, velocity: 1)
map.rotate(CGFloat.pi/3, withVelocity: 1.0)
map.swipeLeft()
map.swipeRight()
map.swipeDown()
map.swipeDown()

Interact with GMSMarker (1st try)

Need to enable accessibility

1
mapView.accessibilityElementsHidden = false

Can’t use pinch to zoom out with UITests, so need to mock location !!!

1
map().pinch(withScale: 0.05, velocity: -1)

Need to use gpx to mock to preferred location

1
2
3
4
let map = app.otherElements[Constant.AccessibilityId.mapView.rawValue]
let pin = app.otherElements
.matching(identifier: Constant.AccessibilityId.pin.rawValue)
.element(boundBy: 0)

Try isAccessibilityElement = true for PinView, can’t touch!!
Use coordinate, can’t touch !!

1
2
let coordinate = pin.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()

Try traversing all the pins, can’t touch

1
2
3
4
5
6
Array(0..<pins.count).forEach {
let pin = pins.element(boundBy: $0)
if pin.isHittable {
pin.tap()
}
}

When po app.otherElements, coordinates are outside screen

1
Other, {{1624.0, 1624.0}, {30.0, 30.0}}, identifier: 'pin', label: 'Hello world'

Interact with GMSMarker (works)

My PinView has isHittable being false, no matter how I use coordinate or enable it. It can’t be touched.

Go to Xcode -> Open Developer Tool -> Accessibility Inspector to inspect our app in iOS simulator

inspector

It turns out that if I do

1
po app.buttons

It shows all the GMSMarker, but with identifier having class name MyApp.MyStopMarker, so just need to use buttons

1
2
3
4
5
6
7
8
9
10
11
extension NSPredicate {
static func contains(identifier: String) -> NSPredicate {
return NSPredicate(format: "identifier CONTAINS[c] '\(text)'")
}
}

let pin = map()
.buttons.matching(NSPredicate.contains("MyStopMarker"))
.element(boundBy: 0)

pin.tap()

Updated at 2021-01-26 09:47:41

Make to make rounded background UIButton in iOS

Issue #373

UIButton.contentEdgeInsets does not play well with Auto Layout, we need to use intrinsicContentSize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
final class InsetButton: UIButton {
required init(text: String) {
super.init(frame: .zero)

titleLabel?.textColor = .white
setTitle(text, for: .normal)

layer.cornerRadius = 15
layer.masksToBounds = true
backgroundColor = .black
isUserInteractionEnabled = false
}

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

override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + 24, height: size.height)
}
}

How to make scrolling UIScrollView with Auto Layout in iOS

Issue #371

Scrolling UIScrollView is used in common scenarios like steps, onboarding.
From iOS 11, UIScrollView has contentLayoutGuide and frameLayoutGuide

Docs

https://developer.apple.com/documentation/uikit/uiscrollview/2865870-contentlayoutguide

Use this layout guide when you want to create Auto Layout constraints related to the content area of a scroll view.

https://developer.apple.com/documentation/uikit/uiscrollview/2865772-framelayoutguide

Use this layout guide when you want to create Auto Layout constraints that explicitly involve the frame rectangle of the scroll view itself, as opposed to its content rectangle.

Code

I found out that using contentLayoutGuide and frameLayoutGuide does not work in iOS 11, when swiping to the next page, it breaks the constraints. iOS 12 works well, so we have to check iOS version

Let the contentView drives the contentSize of scrollView

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

final class PagerView: UIView {
let scrollView = UIScrollView()

private(set) var pages: [UIView] = []
private let contentView = UIView()

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

setup()
}

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

private func setup() {
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false

addSubview(scrollView)
scrollView.addSubview(contentView)

if #available(iOS 12.0, *) {
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.on([
scrollView.frameLayoutGuide.pinEdges(view: self)
])

NSLayoutConstraint.on([
scrollView.contentLayoutGuide.pinEdges(view: contentView),
[scrollView.contentLayoutGuide.heightAnchor.constraint(
equalTo: scrollView.frameLayoutGuide.heightAnchor
)]
])
} else {
NSLayoutConstraint.on([
scrollView.pinEdges(view: self),
scrollView.pinEdges(view: contentView)
])

NSLayoutConstraint.on([
contentView.heightAnchor.constraint(equalTo: heightAnchor)
])
}
}

func update(pages: [UIView]) {
clearExistingViews()

self.pages = pages
setupConstraints()
}

private func setupConstraints() {
pages.enumerated().forEach { tuple in
let index = tuple.offset
let page = tuple.element

contentView.addSubview(page)

NSLayoutConstraint.on([
page.topAnchor.constraint(equalTo: scrollView.topAnchor),
page.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
page.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
])

if index == 0 {
NSLayoutConstraint.on([
page.leftAnchor.constraint(equalTo: contentView.leftAnchor)
])
} else {
NSLayoutConstraint.on([
page.leftAnchor.constraint(equalTo: pages[index - 1].rightAnchor)
])
}

if index == pages.count - 1 {
NSLayoutConstraint.on([
page.rightAnchor.constraint(equalTo: contentView.rightAnchor)
])
}
}
}

private func clearExistingViews() {
pages.forEach {
$0.removeFromSuperview()
}
}
}
1
2
3
4
5
6
7
8
9
10
extension UILayoutGuide {
func pinEdges(view: UIView, inset: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: inset.left),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: inset.right),
topAnchor.constraint(equalTo: view.topAnchor, constant: inset.top),
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: inset.bottom)
]
}
}

How to fix Auto Layout issues in iOS

Issue #369

UITemporaryLayoutHeight and UITemporaryLayoutWidth

  • Demystify warnings with https://www.wtfautolayout.com/
  • Reduce priority
  • Use Auto Layout directly instead of using manual frame layout, specially for scrolling pager

NSAutoresizingMaskLayoutConstraint

  • Check that a view ACTUALLY has translatesAutoresizingMaskIntoConstraints set to false

UISV-spacing, UISV-distributing

  • Check UIStackView
  • Set stackview.alignment = .center if you see UIStackView trying to set same trailing or leading edges for its subviews
  • Reduce priority if there’s edge constraints break from subviews to UIStackView

Intrinsic size between UIImageView and UILabel

  • When constraint to each other, can cause UILabel to disappear
  • Reduce compression resistance
1
imageView.setContentCompressionResistancePriority(UILayoutPriority.defaultLow, for: .vertical)

UIAlertController sheet width == - 16

https://stackoverflow.com/a/58666480/1418457

Read more

How to simplify anchor with NSLayoutConstraint in iOS

Issue #368

See https://github.com/onmyway133/Omnia/blob/master/Sources/iOS/NSLayoutConstraint.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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)
}

func priority(_ value: Float) -> NSLayoutConstraint {
priority = UILayoutPriority(value)
return self
}
}

extension Array where Element == NSLayoutConstraint {
func priority(_ value: Float) -> [NSLayoutConstraint] {
forEach {
$0.priority = UILayoutPriority(value)
}

return self
}
}
1
2
3
4
5
6
7
8
9
10
extension UILayoutGuide {
func pinEdges(view: UIView, inset: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: inset.left),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: inset.right),
topAnchor.constraint(equalTo: view.topAnchor, constant: inset.top),
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: inset.bottom)
]
}
}
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
extension UIView {
func pinEdges(view: UIView, inset: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: inset.left),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: inset.right),
topAnchor.constraint(equalTo: view.topAnchor, constant: inset.top),
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: inset.bottom)
]
}

func pinCenter(view: UIView, offset: CGPoint = .zero) -> [NSLayoutConstraint] {
return [
centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: offset.x),
centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: offset.y)
]
}

func padding(view: UIView, _ constant: CGFloat = 0) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: constant),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -constant)
]
}

func size(_ constant: CGFloat) -> [NSLayoutConstraint] {
return [
widthAnchor.constraint(equalToConstant: constant),
heightAnchor.constraint(equalToConstant: constant)
]
}

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

func addSubviews(_ views: [UIView]) {
views.forEach {
addSubview($0)
}
}
}

How to handle link clicked in WKWebView in iOS

Issue #365

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

final class WebViewHandler: NSObject, WKNavigationDelegate {
var show: ((UIViewController) -> Void)?
let supportedSchemes = ["http", "https"]

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
defer {
decisionHandler(.allow)
}

guard
navigationAction.navigationType == .linkActivated,
let url = navigationAction.request.url,
let scheme = url.scheme,
supportedSchemes.contains(scheme)
else {
return
}

let controller = SFSafariViewController(url: url)
show?(controller)
}
}

How to use AppFlowController in iOS

Issue #364

AppFlowController.swift

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

final class AppFlowController: UIViewController {
private lazy var window = UIWindow(frame: UIScreen.main.bounds)

func configure() {
GMSServices.provideAPIKey(Constant.googleMapsApiKey)
STPPaymentConfiguration.shared().publishableKey = Constant.stripeKey
}

func start() {
if Deps.onboardingHandler.hasOnboarded {
startMain()
} else {
startOnboarding()
}

window.makeKeyAndVisible()
}

func startOnboarding() {
let controller = OnboardingController()
controller.delegate = self
window.rootViewController = controller
}

func startMain() {
let controller = MainFlowController()
window.rootViewController = controller
controller.start()
}
}

extension AppFlowController: OnboardingControllerDelegate {
func onboardingControllerDidFinish(_ controller: OnboardingController) {
Deps.onboardingHandler.hasOnboarded = true
startMain()
}
}

AppDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

private let appFlowController = AppFlowController()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

appFlowController.configure()
appFlowController.start()

UIApplication.shared.registerForRemoteNotifications()
FirebaseApp.configure()
return true
}
}

How to declare UIGestureRecognizer in iOS

Issue #362

1
2
3
4
5
let tapGR = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))

@objc private func handleTap(_ gr: UITapGestureRecognizer) {
didTouch?()
}

We need to use lazy instead of let for gesture to work

1
lazy var tapGR = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))

How to use Payment Intent and Setup Intents with Stripe in iOS

Issue #356

StripeHandler.swift

From Stripe 16.0.0 https://github.com/stripe/stripe-ios/blob/master/CHANGELOG.md#1600-2019-07-18

Migrates STPPaymentCardTextField.cardParams property type from STPCardParams to STPPaymentMethodCardParams

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
final class StripeHandler {
func createPaymentMethod(
textField: STPPaymentCardTextField,
completion: @escaping (Result<STPPaymentMethod, Error>) -> Void) {

let paymentMethodParams = STPPaymentMethodParams(
card: textField.cardParams,
billingDetails: nil,
metadata: nil
)

STPAPIClient.shared().createPaymentMethod(
with: paymentMethodParams,
completion: { paymentMethod, error in
DispatchQueue.main.async {
if let paymentMethod = paymentMethod {
completion(.success(paymentMethod))
} else {
completion(.failure(error ?? AppError.request))
}
}
})

STPAPIClient.shared().createPaymentMethod(
with: paymentMethodParams,
completion: { paymentMethod, error in
DispatchQueue.main.async {
if let paymentMethod = paymentMethod {
completion(.success(paymentMethod))
} else {
completion(.failure(error ?? AppError.request))
}
}
})
}

func confirmSetupIntents(
clientSecret: String,
paymentMethodId: String,
context: STPAuthenticationContext?,
completion: @escaping (Result<STPSetupIntent, Error>) -> Void) {

guard let context = context else {
completion(.failure(AppError.invalid))
return
}

let setupIntentParams = STPSetupIntentConfirmParams(clientSecret: clientSecret)
setupIntentParams.paymentMethodID = paymentMethodId

let paymentHandler = STPPaymentHandler.shared()
paymentHandler.confirmSetupIntent(
setupIntentParams,
with: context,
completion: { status, intent, error in
DispatchQueue.main.async {
if case .succeeded = status, let intent = intent {
completion(.success(intent))
} else {
completion(.failure(error ?? AppError.invalid))
}
}
})
}

func confirmPaymentIntents(
clientSecret: String,
context: STPAuthenticationContext?,
completion: @escaping (Result<STPPaymentIntent, Error>) -> Void) {

guard let context = context else {
completion(.failure(AppError.invalid))
return
}

STPPaymentHandler.shared().handleNextAction(
forPayment: clientSecret,
with: context,
returnURL: nil,
completion: { status, paymentIntent, error in
if case .succeeded = status, let paymentIntent = paymentIntent {
completion(.success(paymentIntent))
} else {
completion(.failure(error ?? AppError.invalid))
}
})
}

func toCard(paymentMethod: STPPaymentMethod) -> MyCard? {
guard
let card = paymentMethod.card,
let last4 = card.last4
else {
return nil
}

return withValue(MyCard()) {
$0.expiryYear = UInt32(card.expYear)
$0.expiryMonth = UInt32(card.expMonth)
$0.lastFourDigits = last4
$0.brand = STPCard.string(from: card.brand)
}
}
}

Payment intents

https://stripe.com/docs/payments/payment-intents/creating-payment-intents

When using automatic confirmation, create the PaymentIntent at the beginning of the checkout process. When using manual confirmation, create the PaymentIntent after collecting payment information from the customer using Elements or our iOS and Android SDKs. For a detailed comparison on the automatic and manual confirmation flows, see accepting one-time payments.

Step 3: Authenticate the payment if necessary

Pass the confirmed Payment Intent client secret from the previous step to STPPaymentHandler handleNextActionForPayment. If the customer must perform 3D Secure authentication to complete the payment, STPPaymentHandler presents view controllers using the STPAuthenticationContext passed in and walks them through that process. See Supporting 3D Secure Authentication on iOS to learn more.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MyAPIClient.createAndConfirmPaymentIntent(paymentMethodId: paymentMethodId) { result in
guard case .success(let paymentIntentClientSecret) = result else {
// Handle error
return
}
STPPaymentHandler.shared().handleNextAction(forPayment: paymentIntentClientSecret, with: self, returnURL: nil) { (status, paymentIntent, error) in
switch (status) {
case .succeeded:
// ...Continued in Step 4
case .canceled:
// Handle cancel
case .failed:
// Handle error
}
}
}

Setup intents

There is Setup intents https://stripe.com/docs/payments/cards/reusing-cards#saving-cards-without-payment for saving cards

Use the Setup Intents API to authenticate a customer’s card without making an initial payment. This flow works best for businesses that want to onboard customers without charging them right away:

Step 4: Submit the card details to Stripe from the client

Pass the STPSetupIntentParams object to the confirmSetupIntent method on a STPPaymentHandler sharedManager. If the customer must perform additional steps to complete the payment, such as authentication, STPPaymentHandler presents view controllers using the STPAuthenticationContext passed in and walks them through that process. See Supporting 3D Secure Authentication on iOS to learn more.

1
2
3
4
5
6
7
8
9
10
11
12
13
let setupIntentParams = STPSetupIntentParams(clientSecret: clientSecret)
setupIntentParams.paymentMethodId = paymentMethodId
let paymentManager = STPPaymentHandler.shared()
paymentManager.confirmSetupIntent(setupIntentParams, authenticationContext: self, completion { (status, setupIntent, error) in
switch (status) {
case .succeeded:
// Setup succeeded
case .canceled:
// Handle cancel
case .failed:
// Handle error
}
})

Authentication context

In STPPaymentHandler.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (BOOL)_canPresentWithAuthenticationContext:(id<STPAuthenticationContext>)authenticationContext {
UIViewController *presentingViewController = authenticationContext.authenticationPresentingViewController;
// Is presentingViewController non-nil and in the window?
if (presentingViewController == nil || presentingViewController.view.window == nil) {
return NO;
}

// Is it the Apple Pay VC?
if ([presentingViewController isKindOfClass:[PKPaymentAuthorizationViewController class]]) {
// We can't present over Apple Pay, user must implement prepareAuthenticationContextForPresentation: to dismiss it.
return [authenticationContext respondsToSelector:@selector(prepareAuthenticationContextForPresentation:)];
}

// Is it already presenting something?
if (presentingViewController.presentedViewController == nil) {
return YES;
} else {
// Hopefully the user implemented prepareAuthenticationContextForPresentation: to dismiss it.
return [authenticationContext respondsToSelector:@selector(prepareAuthenticationContextForPresentation:)];
}
}

Use stripe SDK

STPSetupIntentConfirmParams.useStripeSDK

A boolean number to indicate whether you intend to use the Stripe SDK’s functionality to handle any SetupIntent next actions.
If set to false, STPSetupIntent.nextAction will only ever contain a redirect url that can be opened in a webview or mobile browser.
When set to true, the nextAction may contain information that the Stripe SDK can use to perform native authentication within your app.

1
2
let setupIntentParams = STPSetupIntentConfirmParams(clientSecret: clientSecret)
setupIntentParams.useStripeSDK = NSNumber(booleanLiteral: true)

Read more

How to not resign first responder for UITextField in iOS

Issue #353

When using STPPaymentCardTextField from stripe-ios, the default behavior is when we touch outside to dismiss keyboard, it checks and focus on number text field is it is invalid

STPPaymentCardTextField.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (STPFormTextField *)currentFirstResponderField {
for (STPFormTextField *textField in [self allFields]) {
if ([textField isFirstResponder]) {
return textField;
}
}
return nil;
}

- (BOOL)canResignFirstResponder {
return [self.currentFirstResponderField canResignFirstResponder];
}

- (BOOL)resignFirstResponder {
[super resignFirstResponder];
BOOL success = [self.currentFirstResponderField resignFirstResponder];
[self layoutViewsToFocusField:nil
animated:YES
completion:nil];
[self updateImageForFieldType:STPCardFieldTypeNumber];
return success;
}

Then it calls [self.numberField becomeFirstResponder]; is validation on number fails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef void (^STPLayoutAnimationCompletionBlock)(BOOL completed);
- (void)layoutViewsToFocusField:(NSNumber *)focusedField
animated:(BOOL)animated
completion:(STPLayoutAnimationCompletionBlock)completion {

NSNumber *fieldtoFocus = focusedField;

if (fieldtoFocus == nil
&& ![self.focusedTextFieldForLayout isEqualToNumber:@(STPCardFieldTypeNumber)]
&& ([self.viewModel validationStateForField:STPCardFieldTypeNumber] != STPCardValidationStateValid)) {
fieldtoFocus = @(STPCardFieldTypeNumber);
[self.numberField becomeFirstResponder];
}
}

isUserInteractionEnabled

Be aware to use isUserInteractionEnabled on STPPaymentCardTextField as that can resign first responder when set to true and become first responder when set to false

How to use Firebase PhoneAuth in iOS

Issue #350

Read Authenticate with Firebase on iOS using a Phone Number

Disable swizzling

Info.plist

1
2
<key>FirebaseAppDelegateProxyEnabled</key>
<string>NO</string>

Enable remote notification

Enable Capability -> Background mode -> Remote notification

AppDelegate.swift

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

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

private let appFlowController = AppFlowController()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

UIApplication.shared.registerForRemoteNotifications()
FirebaseApp.configure()
return true
}

// MARK: - Remote Notification

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Auth.auth().setAPNSToken(deviceToken, type: .unknown)
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print(error)
}

func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
completionHandler(.noData)
}

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
if Auth.auth().canHandle(url) {
return true
} else {
return false
}
}
}

Firebase push message looks like

1
2
3
4
5
6
7
8
▿ 1 element
▿ 0 : 2 elements
▿ key : AnyHashable("com.google.firebase.auth")
- value : "com.google.firebase.auth"
▿ value : 1 element
▿ 0 : 2 elements
- key : warning
- value : This fake notification should be forwarded to Firebase Auth.

Captcha

To disable captcha during testing

1
Auth.auth().settings?.isAppVerificationDisabledForTesting = true

How to make digit passcode input in Swift

Issue #347

Add a hidden UITextField to view hierarchy, and add UITapGestureRecognizer to activate that textField.

Use padding string with limit to the number of labels, and prefix to get exactly n characters.

code

DigitView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
import UIKit

final class DigitView: UIView {
lazy var stackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.distribution = .equalSpacing
return view
}()

private(set) var boxes: [UIView] = []
private(set) var labels: [UILabel] = []

lazy var hiddenTextField: UITextField = {
let textField = UITextField()
textField.alpha = 0
textField.keyboardType = .numbersAndPunctuation
return textField
}()

lazy var tapGR = UITapGestureRecognizer(target: self, action: #selector(handle(_:)))

override init(frame: CGRect) {
super.init(frame: frame)
setup()

addGestureRecognizer(tapGR)
}

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

override func layoutSubviews() {
super.layoutSubviews()

boxes.forEach {
$0.layer.borderWidth = 1
$0.layer.borderColor = R.color.primary.cgColor
$0.layoutIfNeeded()
$0.layer.cornerRadius = $0.bounds.height / 2
}
}

@objc private func handle(_ tapGR: UITapGestureRecognizer) {
hiddenTextField.becomeFirstResponder()
}

private func setup() {
addSubviews([hiddenTextField, stackView])
boxes = Array(0..<6).map { _ in
return UIView()
}

labels = boxes.map { box in
let label = UILabel()
label.font = R.customFont.semibold(16)
label.textAlignment = .center
label.textColor = R.color.primary
box.addSubview(label)

NSLayoutConstraint.on([
label.centerXAnchor.constraint(equalTo: box.centerXAnchor),
label.centerYAnchor.constraint(equalTo: box.centerYAnchor)
])

return label
}

boxes.forEach {
stackView.addArrangedSubview($0)

NSLayoutConstraint.on([
$0.heightAnchor.constraint(equalTo: stackView.heightAnchor, multiplier: 0.9),
$0.widthAnchor.constraint(equalTo: $0.heightAnchor, multiplier: 1.0)
])
}

NSLayoutConstraint.on([
stackView.pinEdges(view: self, inset: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: -16))
])
}
}

DigitHandler.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
final class DigitHandler: NSObject {
let digitView: DigitView

init(digitView: DigitView) {
self.digitView = digitView
super.init()

digitView.hiddenTextField.delegate = self
digitView.hiddenTextField.addTarget(self, action: #selector(handle(_:)), for: .editingChanged)
}

@objc private func handle(_ textField: UITextField) {
guard let text = textField.text else {
return
}

let count = digitView.labels.count
let paddedText = String(text.padding(toLength: count, withPad: "-", startingAt: 0).prefix(count))
zip(digitView.labels, paddedText).forEach { tuple in
tuple.0.text = String(tuple.1)
}
}
}

extension DigitHandler: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let text = textField.text ?? ""
return text.count < digitView.labels.count
}
}

How to make credit card input UI in Swift

Issue #346

We have FrontCard that contains number and expiration date, BackCard that contains CVC. CardView is used to contain front and back sides for flipping transition.

We leverage STPPaymentCardTextField from Stripe for working input fields, then CardHandler is used to parse STPPaymentCardTextField content and update our UI.

For masked credit card numbers, we pad string to fit 16 characters with symbol, then chunk into 4 parts and zip with labels to update.

For flipping animation, we use UIView.transition with showHideTransitionViews

a1 a2

BackCard.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
import UIKit

final class BackCard: UIView {
lazy var rectangle: UIView = {
let view = UIView()
view.backgroundColor = R.color.darkText
return view
}()

lazy var cvcLabel: UILabel = {
let label = UILabel()
label.font = R.customFont.medium(14)
label.textColor = R.color.darkText
label.textAlignment = .center
return label
}()

lazy var cvcBox: UIView = {
let view = UIView()
view.backgroundColor = R.color.lightText
return view
}()

override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

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

private func setup() {
addSubviews([rectangle, cvcBox, cvcLabel])
NSLayoutConstraint.on([
rectangle.leftAnchor.constraint(equalTo: leftAnchor),
rectangle.rightAnchor.constraint(equalTo: rightAnchor),
rectangle.heightAnchor.constraint(equalToConstant: 52),
rectangle.topAnchor.constraint(equalTo: topAnchor, constant: 30),

cvcBox.rightAnchor.constraint(equalTo: rightAnchor, constant: -16),
cvcBox.topAnchor.constraint(equalTo: rectangle.bottomAnchor, constant: 16),
cvcBox.widthAnchor.constraint(equalToConstant: 66),
cvcBox.heightAnchor.constraint(equalToConstant: 30),

cvcLabel.centerXAnchor.constraint(equalTo: cvcBox.centerXAnchor),
cvcLabel.centerYAnchor.constraint(equalTo: cvcBox.centerYAnchor)
])
}
}

FrontCard.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
import UIKit

final class FrontCard: UIView {
lazy var stackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .equalSpacing

return stackView
}()

lazy var numberLabels: [UILabel] = Array(0..<4).map({ _ in return UILabel() })
lazy var expirationStaticLabel: UILabel = {
let label = UILabel()
label.font = R.customFont.regular(10)
label.textColor = R.color.darkText
return label
}()

lazy var expirationLabel: UILabel = {
let label = UILabel()
label.font = R.customFont.medium(14)
label.textColor = R.color.darkText
return label
}()

override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

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

private func setup() {
addSubview(stackView)
numberLabels.forEach {
stackView.addArrangedSubview($0)
}

addSubviews([expirationStaticLabel, expirationLabel])

numberLabels.forEach {
$0.font = R.customFont.medium(16)
$0.textColor = R.color.darkText
$0.textAlignment = .center
}

NSLayoutConstraint.on([
stackView.heightAnchor.constraint(equalToConstant: 50),
stackView.leftAnchor.constraint(equalTo: leftAnchor, constant: 24),
stackView.rightAnchor.constraint(equalTo: rightAnchor, constant: -24),
stackView.topAnchor.constraint(equalTo: centerYAnchor),

expirationStaticLabel.topAnchor.constraint(equalTo: stackView.bottomAnchor),
expirationStaticLabel.leftAnchor.constraint(equalTo: rightAnchor, constant: -70),

expirationLabel.leftAnchor.constraint(equalTo: expirationStaticLabel.leftAnchor),
expirationLabel.topAnchor.constraint(equalTo: expirationStaticLabel.bottomAnchor)
])
}
}

CardView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import UIKit

final class CardView: UIView {
let backCard = BackCard()
let frontCard = FrontCard()

override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

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

private func setup() {
addSubview(backCard)
addSubview(frontCard)

[backCard, frontCard].forEach {
NSLayoutConstraint.on([
$0.pinEdges(view: self)
])

$0.clipsToBounds = true
$0.layer.cornerRadius = 10
$0.backgroundColor = R.color.card.background
}
}
}

CardHandler.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
import Foundation
import Stripe

final class CardHandler {
let cardView: CardView

init(cardView: CardView) {
self.cardView = cardView
}

func reset() {
cardView.frontCard.expirationStaticLabel.text = R.string.localizable.cardExpiration()
cardView.frontCard.expirationLabel.text = R.string.localizable.cardExpirationPlaceholder()
cardView.backCard.cvcLabel.text = R.string.localizable.cardCvcPlaceholder()
}

func showFront() {
flip(
from: cardView.backCard,
to: cardView.frontCard,
options: .transitionFlipFromLeft
)
}

func showBack() {
flip(
from: cardView.frontCard,
to: cardView.backCard,
options: .transitionFlipFromRight
)
}

func handle(_ textField: STPPaymentCardTextField) {
handle(number: textField.cardNumber ?? "")
handle(month: textField.formattedExpirationMonth, year: textField.formattedExpirationYear)
handle(cvc: textField.cvc)
}

private func handle(number: String) {
let paddedNumber = number.padding(
toLength: 16,
withPad: R.string.localizable.cardNumberPlaceholder(),
startingAt: 0
)

let chunkedNumbers = paddedNumber.chunk(by: 4)
zip(cardView.frontCard.numberLabels, chunkedNumbers).forEach { tuple in
tuple.0.text = tuple.1
}
}

private func handle(cvc: String?) {
if let cvc = cvc, !cvc.isEmpty {
cardView.backCard.cvcLabel.text = cvc
} else {
cardView.backCard.cvcLabel.text = R.string.localizable.cardCvcPlaceholder()
}
}

private func handle(month: String?, year: String?) {
guard
let month = month, let year = year,
!month.isEmpty
else {
cardView.frontCard.expirationLabel.text = R.string.localizable.cardExpirationPlaceholder()
return
}

let formattedYear = year.ifEmpty(replaceWith: "00")
cardView.frontCard.expirationLabel.text = "\(month)/\(formattedYear)"
}

private func flip(from: UIView, to: UIView, options: UIView.AnimationOptions) {
UIView.transition(
from: from,
to: to,
duration: 0.25,
options: [options, .showHideTransitionViews],
completion: nil
)
}
}

String+Extension.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
extension String {
func ifEmpty(replaceWith: String) -> String {
return isEmpty ? replaceWith : self
}

func chunk(by length: Int) -> [String] {
return stride(from: 0, to: count, by: length).map {
let start = index(startIndex, offsetBy: $0)
let end = index(start, offsetBy: length, limitedBy: endIndex) ?? endIndex
return String(self[start..<end])
}
}
}

Updated at 2020-07-12 08:43:21

How to stop implicit animation when title change on UIButton

Issue #345

UIButton with system type has implicit animation for setTitle(_:for:)

Use this method to set the title for the button. The title you specify derives its formatting from the button’s associated label object. If you set both a title and an attributed title for the button, the button prefers the use of the attributed title over this one.

At a minimum, you should set the value for the normal state. If a title is not specified for a state, the default behavior is to use the title associated with the normal state. If the value for normal is not set, then the property defaults to a system value.

1
2
3
4
UIView.performWithoutAnimation {
button.setTitle(title, for: .normal)
button.layoutIfNeeded()
}

How to use addSubview in iOS

Issue #344

addSubview can trigger viewDidLayoutSubviews, so be careful to just do layout stuff in viewDidLayoutSubviews

This method establishes a strong reference to view and sets its next responder to the receiver, which is its new superview.

Views can have only one superview. If view already has a superview and that view is not the receiver, this method removes the previous superview before making the receiver its new superview.

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. However, this method being called does not indicate that the individual layouts of the view’€™s subviews have been adjusted. Each subview is responsible for adjusting its own layout.

Your view controller can override this method to make changes after the view lays out its subviews. The default implementation of this method does nothing.