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 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 function builder in Swift 5.1

Issue #361

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protocol Task {}

struct Build: Task {}
struct Test: Task {}

@_functionBuilder
public struct TaskBuilder {
public static func buildBlock(_ tasks: Task...) -> [Task] {
tasks
}
}

public func run(@TaskBuilder builder: () -> [Task]) {
MyManager.run(tasks: builder())
}

public func run(@TaskBuilder builder: () -> Task) {
MyManager.run(tasks: [builder()])
}
1
2
3
4
run {
Build()
Test()
}

Read more

How to simplify get GRPC streaming in Swift

Issue #360

Given a streaming service

1
2
3
service Server {
rpc GetUsers(GetUsersRequest) returns (stream GetUsersResponse);
}

To get a response list in Swift, we need to do observe stream, which is a subclass of ClientCallServerStreaming

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 getUsers(roomId: String, completion: @escaping (Result<[User], Error>) -> Void) {
let request = withValue(Server_GetUsersRequest()) {
$0.roomId = roomId
}

DispatchQueue.global().async {
var users = [User]()

do {
var streaming = true
let stream = try self.client.getUsers(request, completion: { _ in
streaming = false
})

while streaming {
if let response = try stream.receive() {
users.append(response.user)
}
}

DispatchQueue.main.async {
completion(.success(users))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}

This can get repetitive very fast. To avoid the duplication, we can make a generic function

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

func getStream<Streaming, Response>(
makeStream: @escaping (@escaping () -> Void) throws -> Streaming,
receive: @escaping (Streaming) throws -> Response?,
completion: @escaping (Result<[Response], Error>) -> Void) {

DispatchQueue.global().async {
var responses = [Response]()

do {
var streaming = true

let stream = try makeStream({
streaming = false
})

while streaming {
if let response = try receive(stream) {
responses.append(response)
}
}

DispatchQueue.main.async {
completion(.success(responses))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}

Since swift-grpc generates very concrete structs, we need to use generic. The difference is the Streaming class and Response struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func getUsers(roomId: String, completion: @escaping (Result<[User], Error>) -> Void) {
let request = withValue(Server_GetUsersRequest()) {
$0.roomId = roomId
}

getStream(
makeStream: { completion in
return try self.client.getUsers(request, completion: { _ in
completion()
})
}, receive: { stream in
return try stream.receive()
}, completion: { result in
completion(result.map { $0.map { $0.user }})
})
}

Handle CallResult

1
2
3
4
5
6
7
8
9
10
11
import SwiftGRPC
import SwiftProtobuf

extension CallResult {
func toError() -> NSError {
return NSError(domain: "com.myApp", code: statusCode.rawValue, userInfo: [
"status_code": statusCode,
"status_message": statusMessage ?? ""
])
}
}

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 format currency in Swift

Issue #355

1
2
3
4
5
6
7
8
9
10
11
12
final class CurrencyFormatter {
func format(amount: UInt64, decimalCount: Int) -> String {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = decimalCount
formatter.numberStyle = .decimal

let value = Double(amount) / pow(Double(10), Double(decimalCount))
let fallback = String(format: "%.0f", value)
return formatter.string(from: NSNumber(value: value)) ?? fallback
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
class CurrencyFormatterTests: XCTestCase {
func testFormat() {
let formatter = CurrencyFormatter()
// 120 USD
XCTAssertEqual(formatter.format(amount: 120, decimalCount: 0), "120")

// 12000 cents
XCTAssertEqual(formatter.format(amount: 12000, decimalCount: 2), "120")

// 12520 cents
XCTAssertEqual(formatter.format(amount: 12520, decimalCount: 2), "125.2")
}
}

How to simplify struct mutating in Swift

Issue #354

In Construction, we have a build method to apply closure to inout struct.

We can explicitly define that with withValue

1
2
3
4
5
func withValue<T>(_ value: T, closure: (inout T) -> Void) -> T {
var mutableValue = value
closure(&mutableValue)
return mutableValue
}

So we can modify Protobuf structs easily

1
2
3
4
5
6
user.book = withValue(Book()) {
$0.price = 300
$0.author = withValue(Author()) {
$0.name = "Thor"
}
}

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.

How to format hour minute from time interval in Swift

Issue #340

Use DateComponentsFormatter

https://nshipster.com/formatter/#datecomponentsformatter

Results in no padding 0

1
2
3
4
5
6
7
8
9
10
11
func format(second: TimeInterval) -> String? {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.hour, .minute]
formatter.zeroFormattingBehavior = .pad
return formatter.string(from: second)
}

XCTAssertEqual(format(second: 5400 ), "1:30")
XCTAssertEqual(format(second: 7200), "2:00")
XCTAssertEqual(format(second: 39600 ), "11:00")

Use mod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func format(minute: Int) -> String {
let h = minute / 60
let m = minute % 60
return "\(h.padZero()):\(m.padZero())"
}

private extension Int {
func padZero() -> String {
return String(format: "%02d", self)
}
}

XCTAssertEqual(format(minute: 90 ), "01:30")
XCTAssertEqual(format(minute: 120), "02:00")
XCTAssertEqual(format(minute: 660 ), "11:00")

How to do custom presentation with UIViewPropertyAnimator in iOS

Issue #337

Normally we just present from any UIViewController in any UINavigationController in UITabBarController and it will present over tabbar

1
present(detailViewController, animated: true, completion: nil)

If we have animation with UIViewPropertyAnimator, then we can implement UIViewControllerAnimatedTransitioning and interruptibleAnimator(using:)

The methods in this protocol let you define an animator object, which creates the animations for transitioning a view controller on or off screen in a fixed amount of time. The animations you create using this protocol must not be interactive. To create interactive transitions, you must combine your animator object with another object that controls the timing of your animations.

Implement this method when you want to perform your transitions using an interruptible animator object, such as a UIViewPropertyAnimator object. You must return the same animator object for the duration of the transition.

For more fine-grained control, we can have UIPresentationController

From the time a view controller is presented until the time it is dismissed, UIKit uses a presentation controller to manage various aspects of the presentation process for that view controller. The presentation controller can add its own animations on top of those provided by animator objects, it can respond to size changes, and it can manage other aspects of how the view controller is presented onscreen.

A lazy approach is to present without animation and do animation after

1
2
3
4
present(detailViewController, animated: false, completion: {
let animator = UIViewPropertyAnimator()
animator.startAnimation()
})

If we don’t want to involve UIViewController then we can work on UIView level. This way we can animate hiding tab bar. Any UIViewController within UITabBarController has tabBarController

1
2
3
4
5
let animator = UIViewPropertyAnimator()
animator.addAnimations {
self.tabBarController?.tabBar.transform = CGAffineTransform(translationX: 0, y: tabbar.frame.height)
}
animator.startAnimation()

How to scan up to character in Swift

Issue #335

This is useful when we want to get the first meaningful line in a big paragraph

1
2
3
4
let scanner = Scanner(string: text)
var result: NSString? = ""
scanner.scanUpTo("\n", into: &result)
return result as String?

How to use NSSecureCoding in Swift

Issue #334

NSSecureCoding has been around since iOS 6 and has had some API changes in iOS 12

A protocol that enables encoding and decoding in a manner that is robust against object substitution attacks.

https://developer.apple.com/documentation/foundation/nscoder/2292924-decodeobject

If the coder responds true to requiresSecureCoding, then the coder calls failWithError(_:) in either of the following cases:
The class indicated by cls doesn’t implement NSSecureCoding.
The unarchived class doesn’t match cls, nor do any of its superclasses.

If the coder doesn’t require secure coding, it ignores the cls parameter and does not check the decoded object.

The class must subclass from NSObject and conform to NSSecureCoding

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
class Note: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool = true

func encode(with aCoder: NSCoder) {
aCoder.encode(id, forKey: "id")
aCoder.encode(text, forKey: "text")
aCoder.encode(date, forKey: "date")
}

required init?(coder aDecoder: NSCoder) {
guard
let id = aDecoder.decodeObject(of: [NSString.self], forKey: "id") as? String,
let text = aDecoder.decodeObject(of: [NSString.self], forKey: "text") as? String,
let date = aDecoder.decodeObject(of: [NSDate.self], forKey: "date") as? Date
else {
return nil
}

self.id = id
self.text = text
self.date = date
}

let id: String
var text = "untitled"
var date: Date = Date()

override init() {
id = UUID().uuidString
super.init()
}
}

First, we need to serialize to Data, then use EasyStash for easy persistency

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
do {
let securedItems = items.map({ SecuredClientLoggerItem(item: $0) })
if #available(iOS 11.0, *) {
let data = try NSKeyedArchiver.archivedData(
withRootObject: securedItems,
requiringSecureCoding: true
)

try data.write(to: fileUrl)
} else {
_ = NSKeyedArchiver.archiveRootObject(
securedItems,
toFile: fileUrl.path
)
}
} catch {
print(error)
}

Then we can use unarchiveTopLevelObjectWithData to unarchive array

1
2
3
4
5
6
7
do {
let data = try Data(contentsOf: fileUrl)
let notes = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? [Note]
// notes is of type [Note]?
} catch {
print(error)
}

Note that for UUID, NSCoding seems to convert to UUID instead of String

1
2
3
4
let id = aDecoder.decodeObject(
of: [NSUUID.self],
forKey: "id"
) as? UUID,

How to simplify pager interaction with Rx

Issue #333

In a traditional pager with many pages of content, and a bottom navigation with previous and next button. Each page may have different content, and depending on each state, may block the next button.

The state of next button should state in real time depending on state in each page content, and when user moves back and forth between pages, the state of next button should be reflected as well.

We might have

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extension ViewController: BottomNavigationDelegate {
func bottomNavigationCanGoNext(currentIndex: Int) -> Bool {}
func bottomNavigationDidMoveTo(index: Int) {}
}

extension ViewController: PreferencePageDelegate {
func preferencePageDidSelect(itemCount: Int) {}
}

extension ViewController: FormPageDelegate {
func formPageDidCheck(valid: Bool) {}
}

extension ViewController: ConsentPageDelegate {
func consentPageDidAccept(agree: Bool) {}
}

The indirect communications between each page, bottom navigation and ViewController get complicated and out of hands very quickly.

This is a perfect problem for Rx to solve. If we look closely, the state of next button is a derivative of current index, how many items selected in preferences, valid form and agreement status.

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
class BottomNavigation {
let index = PublishSubject<Int>()
}

class PreferencePage {
let itemCount = PublishSubject<Int>()
}

class FormPage {
let valid = PublishSubject<Bool>()
}

class ConsentPage {
let agree = PublishSubject<Bool>()
}

let canNext = Observable
.combineLatest(bottomNavigation.index, preferencePage.itemCount, formPage.valid, consentPage.agree)
.map({ (index, itemCount, valid, agree) -> Bool in
// Logic goes here to reduce, for example
switch index {
case 0: return true
case 1: return itemCount > 2
case 2: return valid
case 3: Return agree
default: return false
}
})

How to use moveItem in NSCollectionView in AppKit

Issue #332

From moveItem(at:to:)

Moves an item from one location to another in the collection view.

After rearranging items in your data source object, use this method to synchronize those changes with the collection view. Calling this method lets the collection view know that it must update its internal data structures and possibly update its visual appearance. You can move the item to a different section or to a new location in the same section. The collection view updates the layout as needed to account for the move, animating cells into position in response.

When inserting or deleting multiple sections and items, you can animate all of your changes at once using the performBatchUpdates(_:completionHandler:) method.

1
2
3
4
5
6
notes.swapAt(index, 0)

collectionView.animator().moveItem(
at: index.toIndexPath(),
to: 0.toIndexPath()
)

There may be unknown reasons or bug that make other cells stay in incorrect state. The fix is to reload the rest cells

1
2
let set = Set((1..<notes.count).map({ $0.toIndexPath() }))
collectionView.reloadItems(at: set)

How to show dropdown from NSSegmentedControl in AppKit

Issue #331

From NSSegmentedControl

The features of a segmented control include the following:
A segment can have an image, text (label), menu, tooltip, and tag.
A segmented control can contain images or text, but not both.

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
let languageMenu = NSMenu(title: "")
let languages = ["Swift", "Javascript"]
languages.forEach {
let item = NSMenuItem(title: $0, action: #selector(handleSelectLanguage(_:)), keyEquivalent: "")
item.target = self
item.isEnabled = true
languageMenu.addItem(item)
}

let themeMenu = NSMenu(title: "")
let themes = ["one dark", "one light"]
themes.forEach {
let item = NSMenuItem(title: $0, action: #selector(handleSelectLanguage(_:)), keyEquivalent: "")
item.target = self
item.isEnabled = true
themeMenu.addItem(item)
}

segment.segmentCount = 2
segment.selectedSegmentBezelColor = NSColor.red

segment.setLabel("Language", forSegment: 0)
segment.setLabel("Theme", forSegment: 1

segment.setMenu(languageMenu, forSegment: 0)
segment.setMenu(themeMenu, forSegment: 1

segment.showsMenuIndicator(forSegment: 0)
segment.showsMenuIndicator(forSegment: 1)

How to make scrollable NSTextView in AppKit

Issue #330

When adding NSTextView in xib, we see it is embedded under NSClipView. But if we try to use NSClipView to replicate what’s in the xib, it does not scroll.

To make it work, we can follow Putting an NSTextView Object in an NSScrollView and How to make scrollable vertical NSStackView to make our ScrollableInput

For easy Auto Layout, we use Anchors for UIScrollView.

Things worth mentioned for vertical scrolling

1
2
3
textContainer.heightTracksTextView = false
textView.autoresizingMask = [.width]
textView.isVerticallyResizable = true
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
class ScrollableInput: NSView {
let scrollView = NSScrollView()
let textView = NSTextView()

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)

let rect = CGRect(
x: 0, y: 0,
width: 0, height: CGFloat.greatestFiniteMagnitude
)

let layoutManager = NSLayoutManager()

let textContainer = NSTextContainer(size: rect.size)
layoutManager.addTextContainer(textContainer)
textView = NSTextView(frame: rect, textContainer: textContainer)
textView.maxSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)

textContainer.heightTracksTextView = false
textContainer.widthTracksTextView = true

textView.isRichText = false
textView.importsGraphics = false
textView.isEditable = true
textView.isSelectable = true
textView.font = R.font.text
textView.textColor = R.color.text
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false

addSubview(scrollView)
scrollView.hasVerticalScroller = true
scrollView.drawsBackground = false
scrollView.drawsBackground = false
textView.drawsBackground = false

activate(
scrollView.anchor.edges
)

scrollView.documentView = textView
textView.autoresizingMask = [.width]
}

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

From macOS 10.14, we can use NSTextView.scrollableTextView()

Updated at 2020-12-31 05:43:41

How to handle keyboard for UITextField in scrolling UIStackView in iOS

Issue #329

Firstly, to make UIStackView scrollable, embed it inside UIScrollView. Read How to embed UIStackView inside UIScrollView in iOS

It’s best to listen to keyboardWillChangeFrameNotification as it contains frame changes for Keyboard in different situation like custom keyboard, languages.

Posted immediately prior to a change in the keyboard’s frame.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class KeyboardHandler {
let scrollView: UIScrollView
let stackView: UIStackView
var observer: AnyObject?
var keyboardHeightConstraint: NSLayoutConstraint!

struct Info {
let frame: CGRect
let duration: Double
let animationOptions: UIView.AnimationOptions
}

init(scrollView: UIScrollView, stackView: UIStackView) {
self.scrollView = scrollView
self.stackView = stackView
}
}

To make scrollView scroll beyond its contentSize, we can change its contentInset.bottom. Another way is to add a dummy view with certain height to UIStackView and alter its NSLayoutConstraint constant

We can’t access self inside init, so it’s best to have setup function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func setup() {
let space = UIView()
keyboardHeightConstraint = space.heightAnchor.constraint(equalToConstant: 0)
NSLayoutConstraint.on([keyboardHeightConstraint])
stackView.addArrangedSubview(spa
observer = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main,
using: { [weak self] notification in
self?.handle(notification)
}
)
}

Convert Notification to a convenient Info struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func convert(notification: Notification) -> Info? {
guard
let frameValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] NSValue,
let durationotification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
let raw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] NSNumber
else {
return nil

return Info(
frame: frameValue.cgRectValue,
duration: duration.doubleValue,
animationOptions: UIView.AnimationOptions(rawValue: raw.uintValue)
)
}

Then we can compare with UIScreen to check if Keyboard is showing or hiding

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func handle(_ notification: Notification) {
guard let info = convert(notification: notification) else {
return

let isHiding = info.frame.origin.y == UIScreen.main.bounds.height
keyboardHeightConstraint.constant = isHiding ? 0 : info.frame.hei
UIView.animate(
withDuration: info.duration,
delay: 0,
options: info.animationOptions,
animations: {
self.scrollView.layoutIfNeeded()
self.moveTextFieldIfNeeded(info: info)
}, completion: nil)
}

To move UITextField we can use scrollRectToVisible(_:animated:) but we have little control over how much we want to scroll

This method scrolls the content view so that the area defined by rect is just visible inside the scroll view. If the area is already visible, the method does nothing.

Another way is to check if keyboard overlaps UITextField. To do that we use convertRect:toView: with nil target so it uses window coordinates. Since keyboard frame is always relative to window, we have frames in same coordinate space.

Converts a rectangle from the receiver’s coordinate system to that of another view.

rect: A rectangle specified in the local coordinate system (bounds) of the receiver.
view: The view that is the target of the conversion operation. If view is nil, this method instead converts to window base coordinates. Otherwise, both view and the receiver must belong to the same UIWindow object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func moveTextFieldIfNeeded(info: Info) {
guard let input = stackView.arrangedSubviews
.compactMap({ $0 as? UITextField })
.first(where: { $0.isFirstResponder })
else {
return

let inputFrame = input.convert(input.bounds, to: nil)
if inputFrame.intersects(info.frame) {
scrollView.setContentOffset(CGPoint(x: 0, y: inputFrame.height), animated: true)
} else {
scrollView.setContentOffset(.zero, animated: true)
}
}

Move up the entire view

For simplicity, we can move up the entire view

1
2
3
4
5
6
7
8
9
10
11
12
13
func move(info: Info) {
let isHiding = info.frame.origin.y == UIScreen.main.bounds.height
let moveUp = CGAffineTransform(translationX: 0, y: -info.frame.height)

switch (view.transform, isHiding) {
case (.identity, false):
view.transform = moveUp
case (moveUp, true):
view.transform = .identity
default:
break
}
}

Prefer willShow and willHide

There ‘s an edge case with the above switch on view.transform and isHiding with one time verification sms code, which make it into the correct case handling. It’s safe to just set view.transform depending on show with willHide and willShow

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

class KeyboardHandler {
let view: UIView
var observerForWillShow: AnyObject?
var observerForWillHide: AnyObject?
var keyboardHeightConstraint: NSLayoutConstraint!

struct Info {
let frame: CGRect
let duration: Double
let animationOptions: UIView.AnimationOptions
}

init(view: UIView) {
self.view = view
}

func setup() {
observerForWillShow = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillShowNotification,
object: nil,
queue: .main,
using: { [weak self] notification in
self?.handle(notification, show: true)
}
)

observerForWillHide = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main,
using: { [weak self] notification in
self?.handle(notification, show: false)
}
)
}

func handle(_ notification: Notification, show: Bool) {
guard let info = convert(notification: notification) else {
return
}

UIView.animate(
withDuration: info.duration,
delay: 0,
options: info.animationOptions,
animations: {
self.move(info: info, show: show)
}, completion: nil)
}

func move(info: Info, show: Bool) {
let moveUp = CGAffineTransform(translationX: 0, y: -info.frame.height)
if show {
view.transform = moveUp
} else {
view.transform = .identity
}
}

func convert(notification: Notification) -> Info? {
guard
let frameValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
let raw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber
else {
return nil
}

return Info(
frame: frameValue.cgRectValue,
duration: duration.doubleValue,
animationOptions: UIView.AnimationOptions(rawValue: raw.uintValue)
)
}
}

Read more


Updated at 2020-07-06 07:09:07

How to make simple form validator in Swift

Issue #328

Sometimes we want to validate forms with many fields, for example name, phone, email, and with different rules. If validation fails, we show error message.

We can make simple Validator and Rule

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
class Validator {
func validate(text: String, with rules: [Rule]) -> String? {
return rules.compactMap({ $0.check(text) }).first
}

func validate(input: InputView, with rules: [Rule]) {
guard let message = validate(text: input.textField.text ?? "", with: rules) else {
input.messageLabel.isHidden = true
return
}

input.messageLabel.isHidden = false
input.messageLabel.text = message
}
}

struct Rule {
// Return nil if matches, error message otherwise
let check: (String) -> String?

static let notEmpty = Rule(check: {
return $0.isEmpty ? "Must not be empty" : nil
})

static let validEmail = Rule(check: {
let regex = #"[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,64}"#

let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
return predicate.evaluate(with: $0) ? nil : "Must have valid email"
})

static let countryCode = Rule(check: {
let regex = #"^\+\d+.*"#

let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
return predicate.evaluate(with: $0) ? nil : "Must have prefix country code"
})
}

Then we can use very expressively

1
2
let validator = Validator()
validator.validate(input: inputView, with: [.notEmpty, .validEmail])

Then a few tests to make sure it works

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ValidatorTests: XCTestCase {
let validator = Validator()

func testEmpty() {
XCTAssertNil(validator.validate(text: "a", with: [.notEmpty]))
XCTAssertNotNil(validator.validate(text: "", with: [.notEmpty]))
}

func testEmail() {
XCTAssertNil(validator.validate(text: "onmyway133@gmail.com", with: [.validEmail]))
XCTAssertNotNil(validator.validate(text: "onmyway133", with: [.validEmail]))
XCTAssertNotNil(validator.validate(text: "onmyway133.com", with: [.validEmail]))
}

func testCountryCode() {
XCTAssertNil(validator.validate(text: "+47 11 222 333", with: [.countryCode]))
XCTAssertNotNil(validator.validate(text: "11 222 333", with: [.countryCode]))
XCTAssertNotNil(validator.validate(text: "47 11 222 333", with: [.countryCode]))
}
}

allSatisfy

To check if all rules are ok, we can use reduce

1
2
3
func check(text: String, with rules: [Rule]) -> Bool {
return rules.allSatisfy({ $0.check(text).isOk })
}

Or more concisely, use allSatisfy

1
2
3
4

func check(text: String, with rules: [Rule]) -> Bool {
return rules.allSatisfy({ $0.check(text).isOk })
}

How to organise test files

Issue #327

In terms of tests, we usually have files for unit test, UI test, integeration test and mock.

Out of sight, out of mind.

Unit tests are for checking specific functions and classes, it’s more convenient to browse them side by side with source file. For example in Javascript, Kotlin and Swift

1
2
3
index.js
index.test.js
index.mock.js
1
2
3
LocationManager.kt
LocationManager.mock.kt
LocationManager.test.kt
1
2
3
BasketHandler.swift
BasketHandler.mock.swift
BasketHandler.test.swift

Integration tests check features or sub features, and may cover many source files, it’s better to place them in feature folders

1
2
3
4
5
6
7
8
9
10
11
- Features
- Cart
- Sources
- Tests
- Cart.test.swift
- Validator.test.swift
- Profile
- Sources
- Tests
- Updater.test.swift
- AvatarUploader.test.swift