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 inject view model with Koin in Android

Issue #359

app/build.gradle

1
2
3
implementation "org.koin:koin-core:$Version.koin"
implementation "org.koin:koin-androidx-scope:$Version.koin"
implementation "org.koin:koin-androidx-viewmodel:$Version.koin"

MyApplication.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.dsl.module

class MyApplication: Application() {
var appModule = module {
single { MyRepo() }
viewModel { MyViewModel(get()) }
}

override fun onCreate() {
super.onCreate()

startKoin {
androidLogger()
androidContext(this@MyApplication)
modules(appModule)
}
}
}

MyFragment.kt

1
2
3
import org.koin.androidx.viewmodel.ext.android.viewModel

val viewModel: MyViewModel by viewModel()

How to use coroutine LiveData in Android

Issue #358

app/build.gradle

1
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha01"
1
2
3
4
5
6
7
8
9
10
11
12
13
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import kotlinx.coroutines.Dispatchers

class MainViewModel : ViewModel() {
val repository: TodoRepository = TodoRepository()

val firstTodo = liveData(Dispatchers.IO) {
val retrivedTodo = repository.getTodo(1)

emit(retrivedTodo)
}
}

Use coroutines with LiveData

https://developer.android.com/topic/libraries/architecture/coroutines

The liveData building block serves as a structured concurrency primitive between coroutines and LiveData. The code block starts executing when LiveData becomes active and is automatically canceled after a configurable timeout when the LiveData becomes inactive.

Source code

https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/lifecycle/lifecycle-livedata-ktx/src/main/java/androidx/lifecycle/CoroutineLiveData.kt
https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/lifecycle/lifecycle-livedata-ktx/src/main/java/androidx/lifecycle/FlowLiveData.kt

CoroutineLiveData.kt

1
2
3
4
5
6
@UseExperimental(ExperimentalTypeInference::class)
fun <T> liveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT,
@BuilderInference block: suspend LiveDataScope<T>.() -> Unit
): LiveData<T> = CoroutineLiveData(context, timeoutInMs, block)

a LiveData that tries to load the User from local cache first and then tries from the server and also yields the updated value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val user = liveData {
// dispatch loading first
emit(LOADING(id))
// check local storage
val cached = cache.loadUser(id)

if (cached != null) {
emit(cached)
}

if (cached == null || cached.isStale()) {
val fresh = api.fetch(id) // errors are ignored for brevity
cache.save(fresh)
emit(fresh)
}
}

Read more

How to declare generic RecyclerView adapter in Android

Issue #357

generic/Adapter.kt

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
package com.onmyway133.generic

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView

abstract class Adapter<T>(var items: ArrayList<T>): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
abstract fun configure(item: T, holder: ViewHolder)

fun update(items: ArrayList<T>) {
this.items = items
notifyDataSetChanged()
}

override fun getItemCount(): Int = items.count()

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater
.from(parent.context)
.inflate(viewType, parent, false)
return ViewHolder(view)
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
configure(items[position], holder as ViewHolder)
}

}

class ViewHolder(view: View): RecyclerView.ViewHolder(view) {}

hero/HeroAdapter.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.onmyway133.hero
import kotlinx.android.synthetic.main.hero_item_view.view.*

class Adapter(items: ArrayList<Hero>): com.onmyway133.generic.Adapter<Hero>(items) {
override fun configure(item: Hero, holder: ViewHolder) {
holder.itemView.titleLabel.text = item.name
holder.itemView.descriptionLabel.text = item.description
}

override fun getItemViewType(position: Int): Int {
return R.layout.hero_item_view
}
}

May run into https://stackoverflow.com/questions/49512629/default-interface-methods-are-only-supported-starting-with-android-n

app/build.gradle

1
2
3
4
5
6
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}

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 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 React JSX with Babel in Electron

Issue #352

For a normal electron app created with npm init, we can use all features of ES6, but not the JSX syntax for React. We can use just Babel to transpile JSX, as used in IconGenerator

.babelrc

1
2
3
4
5
6
{
"plugins": [
"transform-react-jsx-source"
],
"presets": ["env", "react"]
}

And in package.json, call babel to transpile src to dist

1
2
3
4
5
"main": "dist/main.js",
"scripts": {
"start": "npm run babel && electron .",
"babel": "babel ./src --out-dir ./dist --copy-files"
},

Remember to use dist/main.js as our starting point, and in index.html, specify ./dist/renderer.js

1
2
3
4
5
6
<body>
<div id="root" />
<script type="text/javascript">
require('./dist/renderer.js')
</script>
</body>

How to define version property in gradle

Issue #351

From Gradle tips and recipes, Configure project-wide properties

For projects that include multiple modules, it might be useful to define properties at the project level and share them across all modules. You can do this by adding extra properties to the ext block in the top-level build.gradle file.

1
2
3
4
5
ext {
navigationVersion = "2.0.0"
}

rootProject.ext.navigationVersion

Versions are used mostly in dependencies block so having them defined in global ext is not quite right. We can use def to define variables

1
2
3
4
dependencies {
def navigationVersion = "2.0.0"
implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
}

For better namespacing, we can use a class

1
2
3
4
5
6
7
8
9
10
class Version {
static def navigation = "2.0.0"
static def drawerLayout = "1.0.0"
static def koin = "2.0.1"
static def moshi = "1.8.0"
}

dependencies {
implementation "androidx.navigation:navigation-fragment-ktx:$Version.navigation"
}

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 use Navigation component with DrawerLayout in Android

Issue #349

Screenshot_1565169686

build.gradle

1
2
3
dependencies {
classpath 'android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0-alpha05'
}

app/build.gradle

1
2
3
4
5
6
7
8
9
10
apply plugin: 'androidx.navigation.safeargs'

dependencies {
def navigationVersion = "2.0.0"
def drawerLayoutVersion = "1.0.0"

implementation "androidx.drawerlayout:drawerlayout:$drawerLayoutVersion"
implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion"
}

main_activity.xml

  • Use CoordinatorLayout and ToolBar
  • Define layout_gravity for NavigationView
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
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
android:layout_height="match_parent"
android:layout_width="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
tools:context=".MainActivity">

<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:id="@+id/toolbar"/>
</com.google.android.material.appbar.AppBarLayout>
<fragment
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/hostFragment"
android:name="androidx.navigation.fragment.NavHostFragment"
app:defaultNavHost="true"
app:navGraph="@navigation/navigation_graph"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.navigation.NavigationView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/navigationView"
android:fitsSystemWindows="true"
android:layout_gravity="start"
app:menu="@menu/drawer_menu"/>
</androidx.drawerlayout.widget.DrawerLayout>

navigation/navigation_graph.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/navigationGraph"
xmlns:tools="http://schemas.android.com/tools"
app:startDestination="@id/aboutFragment">
<fragment
android:id="@+id/aboutFragment"
android:name="com.onmyway133.whatsupintech.AboutFragment"
android:label="@string/menu_about"
tools:layout="@layout/about_fragment" />
<fragment
android:id="@+id/feedFragment"
android:name="com.onmyway133.whatsupintech.FeedFragment"
android:label="@string/menu_git_hub"
tools:layout="@layout/feed_fragment" />
<fragment
android:id="@+id/webFragment"
android:name="com.onmyway133.whatsupintech.WebFragment"
tools:layout="@layout/web_fragment"/>
</navigation>

menu/drawer_menu.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<group android:checkableBehavior="single">
<item
android:id="@+id/about"
android:title="@string/menu_about" />
<item
android:id="@+id/hackerNews"
android:title="@string/menu_hacker_news" />
<item
android:id="@+id/reddit"
android:title="@string/menu_reddit" />
<item
android:id="@+id/dev"
android:title="@string/menu_dev" />
<item
android:id="@+id/gitHub"
android:title="@string/menu_git_hub" />
</group>
</menu>

MainActivity.kotlin

  • Use AppBarConfiguration to define multiple top level destinations
  • Convert Toolbar to ActionBar
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
package com.onmyway133.whatsupintech

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.GravityCompat
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import kotlinx.android.synthetic.main.main_activity.*

class MainActivity : AppCompatActivity() {

lateinit var appBarConfig: AppBarConfiguration

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
setupNavigationController()
}

fun setupNavigationController() {
val navigationController = findNavController(R.id.hostFragment)

setSupportActionBar(toolbar)

appBarConfig = AppBarConfiguration(setOf(R.id.aboutFragment, R.id.feedFragment), drawerLayout)
setupActionBarWithNavController(navigationController, appBarConfig)
navigationView.setupWithNavController(navigationController)
navigationView.setNavigationItemSelectedListener { menuItem ->
drawerLayout.closeDrawers()
menuItem.isChecked = true
when (menuItem.itemId) {
R.id.about -> navigationController.navigate(R.id.aboutFragment)
R.id.gitHub, R.id.reddit, R.id.hackerNews, R.id.dev -> navigationController.navigate(R.id.feedFragment)
}

true
}
}

override fun onSupportNavigateUp(): Boolean {
val navigationController = findNavController(R.id.hostFragment)
return navigationController.navigateUp(appBarConfig) || super.onSupportNavigateUp()
}

override fun onBackPressed() {
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
drawerLayout.closeDrawer(GravityCompat.START)
} else {
return super.onBackPressed()
}
}
}

Update UI components with NavigationUI

Tie destinations to menu items

NavigationUI also provides helpers for tying destinations to menu-driven UI components. NavigationUI contains a helper method, onNavDestinationSelected(), which takes a MenuItem along with the NavController that hosts the associated destination. If the id of the MenuItem matches the id of the destination, the NavController can then navigate to that destination.

Add a navigation drawer

The drawer icon is displayed on all top-level destinations that use a DrawerLayout. Top-level destinations are the root-level destinations of your app. They do not display an Up button in the app bar.

Read more

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 run app on beta iOS devices

Issue #343

Xcode 10.3 with iOS 13

1
sudo ln -s /Applications/Xcode-beta.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/13.0 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport

Xcode 10.3 with iOS 13.1 beta 2

1
sudo ln -s /Applications/Xcode-beta.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/13.0/ /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/13.1

Use DeviceSupport

1
/Applications/Xcode/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport

How to submit electron app to AppStore

Issue #342

Before

Install electron as dev npm install electron --save-dev
Update electron-packager npm install electron-packager@latest --save-dev
Use no space in app name

Package with electron-packager

Follow https://github.com/electron/electron-osx-sign/wiki/Packaging-and-Submitting-an-Electron-App-to-the-Mac-App-Store

1
2
3
npx electron-packager . "MyApp" --app-bundle-id=com.onmyway133.MyApp --helper-bundle-id=com.onmyway133.MyApp.helper --app-version=1.4.0 --build-version=1.0.100 --platform=mas --arch=x64 --icon=Icon/Icon.icns --overwrite
npx electron-osx-sign "MyApp-mas-x64/MyApp.app" --verbose
npx electron-osx-flat "MyApp-mas-x64/MyApp.app" --verbose

If you have multiple developer identities in your keychain:

electron-osx-sign searches your keychain for the first signing certificates that it can locate. If you have multiple certificates then it may not know which cert you want to use for signing and you need to explicitly provide the name:

1
electron-osx-sign "My App-mas-x64/My App.app" --identity="3rd Party Mac Developer Application: My Company, Inc (ABCDEFG1234)" --verbose

Read more

Sign with electron-osx-sign

Read README https://github.com/electron/electron-osx-sign

For distribution in the Mac App Store: Have the provisioning profile for distribution placed in the current working directory and the signing identity installed in the default keychain.

Certificate

On developer.apple.com, create Mac App Distribution certificate. Make sure when we download in Keychain Access, it has associated private key

Manually upload

1
/Applications/Xcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m upload  -assetFile MyApp/MyApp.pkg  -u onmyway133@gmail.com -p mypassword

Use Application Loader

Use Using app-specific passwords

Troubleshooting

electron Bad CFBundleExecutable. Cannot find executable file

ERROR ITMS-90261: “Bad CFBundleExecutable. Cannot find executable file that matches the value of CFBundleExecutable in the nested bundle MyApp [com.onmyway133.MyApp.pkg/Payload/MyApp.app/Contents/Frameworks/MyApp (GPU).app] property list file.”

https://github.com/electron/electron-packager/issues?utf8=%E2%9C%93&q=helper

Try electron 5.0.0 npm install electron@5.0.0 --save-dev

Specifically, we found that when the user closes the main application window there is no menu item to re-open it.

https://stackoverflow.com/questions/35008347/electron-close-w-x-vs-right-click-dock-and-quit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createMenu() {
const application = {
label: "MyApp",
submenu: [
{
label: "New",
accelerator: "Command+N",
click: () => {
if (win === null) {
createWindow()
}
}
}
]
}
}

‘electron-osx-flat@latest’ is not in the npm registry

1
npm install -g electron-osx-sign@latest

App sandbox not enabled

electron-osx-sign Command failed: codesign

1
ran xattr -cr *

Command failed: codesign bundle format is ambiguous

Perhaps you accidentally packaged the previous generated app bundle into your newly packaged app?

Remove dist folder generated by electron-builder

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 cache URLSession response

Issue #339

For simple cases, we don’t need to. Let’s use urlCache

The URL cache for providing cached responses to requests within the session.

Accessing Cached Data

The URL Loading System caches responses both in memory and on disk, improving performance and reducing network traffic.

The URLCache class is used for caching responses from network resources. Your app can directly access the shared cache instance by using the shared property of URLCache. Or, you can create your own caches for different purposes, setting distinct caches on your URLSessionConfiguration objects.

Read more

How to use ext in gradle in Android

Issue #338

Gradle uses Groovy and it has ext, also known as ExtraPropertiesExtension

Additional, ad-hoc, properties for Gradle domain objects.

Extra properties extensions allow new properties to be added to existing domain objects. They act like maps, allowing the storage of arbitrary key/value pairs. All ExtensionAware Gradle domain objects intrinsically have an extension named “ext” of this type.

1
2
3
4
5
6
7
8
9
project.ext {
myprop = "a"
}
assert project.myprop == "a"
assert project.ext.myprop == "a"

project.myprop = "b"
assert project.myprop == "b"
assert project.ext.myprop == "b"

In root build.gradle, ext adds extra property to rootProject object. There we can access rootProject.ext or just ext

1
2
3
ext {
myLibraryVersion = '1.0.0'
}

In module app/build.gradle, ext adds extra property to project object. There we can access project.ext or just ext

1
2
3
ext {
myLibraryVersion = '1.0.0'
}

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