How to remove duplicates based on property in array in Swift

Issue #441

Make object conform to Equatable and Hashable and use Set to eliminate duplications. Set loses order so we need to sort after uniquing

1
2
3
4
5
6
7
8
9
10
11
12
13
struct App: Equatable, Hashable {
static func == (lhs: App, rhs: App) -> Bool {
return lhs.name == rhs.name && lhs.bundleId == rhs.bundleId
}

func hash(into hasher: inout Hasher) {
hasher.combine(name)
hasher.combine(bundleId)
}
}

let uniqueApps = Array(Set(unsortedApps))
let apps = uniqueApps.sorted(by: { $0.name.lowercased() < $1.name.lowercased() })

How to log Error in Swift

Issue #439

Use localizedDescription

We need to provide NSLocalizedDescriptionKey inside user info dictionary, otherwise the outputt string may not be what we want.

NSError

https://developer.apple.com/documentation/foundation/nserror/1414418-localizeddescription

A string containing the localized description of the error.
The object in the user info dictionary for the key NSLocalizedDescriptionKey. If the user info dictionary doesn’t contain a value for NSLocalizedDescriptionKey, a default string is constructed from the domain and code.

1
2
3
4
5
6
7
let error = NSError(domain: "com.onmyway133.MyApp", code: 2, userInfo: [
"status_code": 2,
"status_message": "not enough power"
])

error.localizedDescription
// "The operation couldn’t be completed. (com.onmyway133.MyApp error 2.)"

Error

https://developer.apple.com/documentation/swift/error/2292912-localizeddescription

Retrieve the localized description for this error.

1
2
3
4
5
6
7
enum AppError: Error {
case request
case invalid
}

AppError.request.localizedDescription
// "The operation couldn’t be completed. (MyApp.AppError error 0.)"

Use describing String

To have better control, we can have toString which prints closly to what we expect

1
2
3
4
5
6
7
8
9
10
11
extension Error {
func toString() -> String {
return String(describing: self)
}
}

AppError.request.toString()
// request

nsError.toString()
// Error Domain=com.onmyway133.MyApp Code=2 "(null)" UserInfo={status_message=not enough power, status_code=SwiftGRPC.StatusCode.powerRequired}

How to handle NSTextField change in macOS

Issue #438

Storyboard

In Storyboard, NSTextField has an Action option that specify whether Send onSend on Enter only` should be the default behaviour.

textfield

Code

In code, NSTextFieldDelegate notifies whenever text field value changes, and target action notifies when Enter key is pressed

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

class ViewController: NSViewController, NSTextFieldDelegate {

@IBOutlet weak var textField: NSTextField!

override func viewDidLoad() {
super.viewDidLoad()

textField.delegate = self
}

func controlTextDidChange(_ obj: Notification) {
let textField = obj.object as! NSTextField
print(textField.stringValue)
}
}

Use EasyClosure

If we use EasyClosure then this is easy

1
2
3
4
5
6
7
8
9
let textField: NSTextField = 

textField.on.action { string in
print("User has pressed enter \(string)"
}

textField.on.change { string in
print("Text field value has changed")
}

Updated at 2020-11-27 07:39:43

How to add section header to NSCollectionView in macOS

Issue #437

Normal

Use Omnia for itemId extension

HeaderCell.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
final class HeaderCell: NSView, NSCollectionViewSectionHeaderView {
let label: NSTextField = withObject(NSTextField(labelWithString: "")) {
$0.textColor = R.color.header
$0.font = R.font.header
$0.alignment = .left
$0.lineBreakMode = .byTruncatingTail
}

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

addSubviews([label])

activate(
label.anchor.centerY,
label.anchor.left.constant(8)
)
}

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

ViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
collectionView.register(
HeaderCell.self,
forSupplementaryViewOfKind: NSCollectionView.elementKindSectionHeader,
withIdentifier: HeaderCell.itemId
)

func collectionView(_ collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind, at indexPath: IndexPath) -> NSView {

if kind == NSCollectionView.elementKindSectionHeader {
let view = collectionView.makeSupplementaryView(
ofKind: kind,
withIdentifier: HeaderCell.itemId,
for: indexPath
) as! HeaderCell

let menu = app.menus[indexPath.section]
view.label.stringValue = menu.name

return view
} else {
return NSView()
}
}

In generic subclass

If use CollectionViewHandler from Omnia, then need to add @objc due to a bug in Swift compiler for subclassing generic class

1
@objc (collectionView:viewForSupplementaryElementOfKind:atIndexPath:)

MyHandler.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
import AppKit
import Omnia

class MyHandler: CollectionViewHandler<App.Key, KeyCell> {
override init() {
super.init()

layout.headerReferenceSize = NSSize(width: 300, height: 30)

collectionView.register(
HeaderCell.self,
forSupplementaryViewOfKind: NSCollectionView.elementKindSectionHeader,
withIdentifier: HeaderCell.itemId
)
}

@objc (collectionView:viewForSupplementaryElementOfKind:atIndexPath:)
func collectionView(_ collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind, at indexPath: IndexPath) -> NSView {

if kind == NSCollectionView.elementKindSectionHeader {
let view = collectionView.makeSupplementaryView(
ofKind: kind,
withIdentifier: HeaderCell.itemId,
for: indexPath
) as! HeaderCell

let menu = app.menus[indexPath.section]
view.label.stringValue = menu.name

return view
} else {
return NSView()
}
}
}

Use Omnia

Use CollectionViewSectionHandler from Omnia

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class SectionHandler: CollectionViewSectionHandler<App.Key, KeyCell, HeaderCell> {

}

sectionHandler.configureHeader = { section, view in
view.label.stringValue = section.name
}

sectionHandler.configure = { item, cell in
cell.shortcutLabel.stringValue = item.shortcut
cell.nameLabel.stringValue = item.name
}

sectionHandler.itemSize = { [weak self] in
guard let self = self else {
return .zero
}

let width = self.sectionHandler.collectionView.frame.size.width
- self.sectionHandler.layout.sectionInset.left
- self.sectionHandler.layout.sectionInset.right

return CGSize(width: width, height: 18)
}

Read more

How to show context menu from NSButton in macOS

Issue #435

Use NSMenu and popUp

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
func showQuitMenu() {
let menu = NSMenu()
let aboutItem = NSMenuItem(
title: "About",
action: #selector(onAboutTouched(_:)),
keyEquivalent: ""
)

let quitItem = NSMenuItem(
title: "Quit Hacker Pad",
action: #selector(onQuitTouched(_:)),
keyEquivalent: ""
)

aboutItem.target = self
quitItem.target = self

menu.addItem(aboutItem)
menu.addItem(quitItem)

menu.popUp(
positioning: aboutItem,
at: bottomView.quitButton.frame.origin,
in: bottomView
)
}

Use Omnia

1
2
3
4
5
6
7
8
9
10
let menuHandler = MenuHandler()
menuHandler.add(title: "About", action: {
NSWorkspace.shared.open(URL(string: "https://onmyway133.github.io/")!)
})

menuHandler.add(title: "Quit Hacker Pad", action: {
NSApp.terminate(nil)
})

menuHandler.show(from: self.bottomView.gearButton, in: self.bottomView)

How to handle UICollectionView reloadData with selected index path

Issue #434

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

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

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

How to make checked NSButton in AppKit

Issue #433

  • Use Omnia for convenient style and isOn property
1
2
3
let checkButton = NSButton(checkboxWithTitle: "", target: nil, action: nil)
checkButton.stylePlain(title: "Autosave", color: R.color.text, font: R.font.text)
checkButton.isOn = true

How to save files in sandboxed macOS app

Issue #432

Read Container Directories and File System Access

When you adopt App Sandbox, your application has access to the following locations:

The app container directory. Upon first launch, the operating system creates a special directory for use by your app—and only by your app—called a container. Each user on a system gets an individual container for your app, within their home directory; your app has unfettered read/write access to the container for the user who ran it.

Use EasyStash

1
2
3
var options = Options()
options.searchPathDirectory = .documentDirectory
storage = try! Storage(options: options)

How to use marked in WKWebView in AppKit

Issue #429

Use https://github.com/markedjs/marked

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Marked in the browser</title>
</head>
<body>
<div id="content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.getElementById('content').innerHTML =
marked(`# Marked in the browser\n\nRendered by **marked**.`);
</script>
</body>
</html>

Should use back tick instead of ' to deal with multiline string, and escape back tick

1
2
3
4
let markdown = markdown.replacingOccurrences(of: "`", with: "\\`")
marked(`\(markdown)`);

webView.loadHTMLString(string, baseURL: nil)

For WKWebView to load even local content, need to enable Outgoing connections, and maybe Incoming connections in Sandbox

How to enable NSMenuItem in AppKit

Issue #428

Need to set target

1
2
3
4
5
6
7
let item = NSMenuItem(
title: title,
action: #selector(onMenuItemClicked(_:)),
keyEquivalent: ""
)

item.target = self

Sometimes, need to check autoenablesItems

Indicates whether the menu automatically enables and disables its menu items.

This property contains a Boolean value, indicating whether the menu automatically enables and disables its menu items. If set to true, menu items of the menu are automatically enabled and disabled according to rules computed by the NSMenuValidation informal protocol. By default, NSMenu objects autoenable their menu items.

How to use generic NSCollectionView in macOS

Issue #427

See CollectionViewHandler

Use ClickedCollectionView to detect clicked index for context menu.
Embed NSCollectionView inside NSScrollView to enable scrolling

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

public class CollectionViewHandler<Item: Equatable, Cell: NSCollectionViewItem>
: NSObject, NSCollectionViewDataSource, NSCollectionViewDelegateFlowLayout {

public let layout = NSCollectionViewFlowLayout()
public let scrollView = NSScrollView()
public let collectionView = ClickedCollectionView()

public var items = [Item]()
public var itemSize: () -> CGSize = { .zero }
public var configure: (Item, Cell) -> Void = { _, _ in }

override init() {
super.init()

layout.minimumLineSpacing = 4
layout.sectionInset = NSEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)

collectionView.dataSource = self
collectionView.delegate = self
collectionView.collectionViewLayout = layout
collectionView.allowsMultipleSelection = false
collectionView.backgroundColors = [.clear]
collectionView.isSelectable = true

collectionView.register(Cell.self, forItemWithIdentifier: Cell.itemId)

scrollView.documentView = collectionView
}

// MARK: - Items

public func add(item: Item) {
items.insert(item, at: 0)
let indexPath = IndexPath(item: 0, section: 0)
collectionView.animator().insertItems(at: Set(arrayLiteral: indexPath))
}

public func remove(item: Item) {
guard let index = items.firstIndex(where: { $0 == item }) else {
return
}

remove(index: index)
}

public func remove(index: Int) {
items.remove(at: index)
let indexPath = IndexPath(item: index, section: 0)
collectionView.animator().deleteItems(at: Set(arrayLiteral: indexPath))
}

// MARK: - NSCollectionViewDataSource

public func numberOfSections(in collectionView: NSCollectionView) -> Int {
return 1
}

public func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
return items.count
}

public func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {

let cell = collectionView.makeItem(withIdentifier: Cell.itemId, for: indexPath) as! Cell
let item = items[indexPath.item]
configure(item, cell)
return cell
}

// MARK: - NSCollectionViewDelegateFlowLayout

public func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize {

return itemSize()
}
}

How to use throttle and debounce in RxSwift

Issue #426

throttle

https://rxmarbles.com/#throttle

throttle

Returns an Observable that emits the first and the latest item emitted by the source Observable during sequential time windows of a specified duration.
This operator makes sure that no two elements are emitted in less then dueTime.

1
.throttle(.milliseconds(500), scheduler: MainScheduler.instance)

In a time window, only the first item gets emitted.

💡 In other words, in a time window, take first and discard following.

For example, when failure, we show error message but don’t want to show error messages consecutively. We can use throttle to discard consecutive errors.

1
2
3
viewModel.fetchBooksFail
.observeOn(MainScheduler.instance)
.throttle(.seconds(2), scheduler: MainScheduler.instance)

debounce

https://rxmarbles.com/#debounce

debounce

Ignores elements from an observable sequence which are followed by another element within a specified relative time duration, using the specified scheduler to run throttling timers.

1
.debounce(.milliseconds(500), scheduler: MainScheduler.instance)

If an element is about to get emitted, wait for a time window to see if there are other elements emitted. If yes, start the waiting again.

💡 In other words, in a time window, wait and take last

For example, when receiving data, we may need to wait for the final data if there are many data events emitted consecutively

1
2
3
viewModel.fetchBooks
.filter({ !$0.isEmpty })
.debounce(.milliseconds(500), scheduler: MainScheduler.instance)

How to constrain to views inside UICollectionViewCell in iOS

Issue #422

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

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

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

How to secure CVC in STPPaymentCardTextField in Stripe for iOS

Issue #421

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

cvcField.isSecureTextEntry = true
}

where tag is in STPPaymentCardTextFieldViewModel.h

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

Also, need to check accessibilityLabel in STPPaymentCardTextField.m

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

How to easily parse deep json in Swift

Issue #414

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

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

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

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

return current as? T
}

So we can just resolve via key path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class JSONTests: XCTestCase {
func test() {
let json: [String: Any] = [
"outside": [
"object": [
"number": 1,
"text": "hello"
],
"arrayOfObjects": [
[
"number": 2,
"text": "two"
],
[
"number": 3,
"text": "three"
]
],
"arrayOfArrays": [
[
"one", "two", "three", "four"
],
[
"five", "six", "seven"
]
]
]
]

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

How to speed up GMSMarker in Google Maps for iOS

Issue #412

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

shouldRasterize

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

In the class PinView: UIView

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

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

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

Read more

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

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

How to support drag and drop in UICollectionView iOS

Issue #411

See DragAndDrop example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ViewController: UIViewController, UICollectionViewDropDelegate, UICollectionViewDragDelegate {

// MARK: - UICollectionViewDragDelegate

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

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

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

// MARK: - UICollectionViewDropDelegate

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

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

let controller = rightController

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

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

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

How to support drag and drop in NSView

Issue #410

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
import AppKit
import Anchors

class DraggingView: NSView {
var didDrag: ((FileInfo) -> Void)?
let highlightView = NSView()

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

registerForDraggedTypes([
.fileURL
])

highlightView.isHidden = true
addSubview(highlightView)
activate(highlightView.anchor.edges)
highlightView.wantsLayer = true
highlightView.layer?.borderColor = NSColor(hex: "#FF6CA8").cgColor
highlightView.layer?.borderWidth = 6
}

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

override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
highlightView.isHidden = false
return NSDragOperation()
}

override func draggingEnded(_ sender: NSDraggingInfo) {
guard let pathAlias = sender.draggingPasteboard.propertyList(forType: .fileURL) as? String else {
return
}

let url = URL(fileURLWithPath: pathAlias).standardized
let fileInfo = FileInfo(url: url)
didDrag?(fileInfo)
}

override func draggingExited(_ sender: NSDraggingInfo?) {
highlightView.isHidden = true
}

override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
return NSDragOperation()
}
}

To get information about multiple files

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
override func draggingEnded(_ sender: NSDraggingInfo) {
guard let pasteBoardItems = sender.draggingPasteboard.pasteboardItems else {
return
}

let fileInfos: [FileInfo] = pasteBoardItems
.compactMap({
return $0.propertyList(forType: .fileURL) as? String
})
.map({
let url = URL(fileURLWithPath: $0).standardized
return FileInfo(url: url)
})

didDrag(fileInfos)
}

How to use NSStepper in Appkit

Issue #409

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let stepper = NSStepper()
let textField = NSTextField(wrappingLabelWithString: "\(myLocalCount)")

stepper.integerValue = myLocalCount
stepper.minValue = 5
stepper.maxValue = 24
stepper.valueWraps = false

stepper.target = self
stepper.action = #selector(onStepperChange(_:))

@objc func onStepperChange(_ sender: NSStepper) {
myLocalCount = sender.integerValue
textField.stringValue = "\(sender.integerValue)"
}

How to handle shortcut in AppKit

Issue #408

Podfile

1
pod 'MASShortcut'
1
2
3
4
5
let shortcut = MASShortcut(keyCode: kVK_ANSI_K, modifierFlags: [.command, .shift])

MASShortcutMonitor.shared()?.register(shortcut, withAction: {
self.showPopover(sender: self.statusItem.button)
})

How to select file in its directory in AppKit

Issue #407

https://developer.apple.com/documentation/appkit/nsworkspace/1524399-selectfile

In macOS 10.5 and later, this method does not follow symlinks when selecting the file. If the fullPath parameter contains any symlinks, this method selects the symlink instead of the file it targets. If you want to select the target file, use the resolvingSymlinksInPath method to resolve any symlinks before calling this method.

It is safe to call this method from any thread of your app.

1
2
3
NSWorkspace.shared.selectFile(
url.path,
inFileViewerRootedAtPath: url.deletingLastPathComponent().path)

How to use NSProgressIndicator in AppKit

Issue #406

1
2
3
4
let progressIndicator = NSProgressIndicator()
progressIndicator.isIndeterminate = true
progressIndicator.style = .spinning
progressIndicator.startAnimation(nil)

How to show save panel in AppKit

Issue #405

Enable Read/Write for User Selected File under Sandbox to avoid bridge absent error

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
func save() {
let panel = NSSavePanel()
// 3
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
// 4
panel.nameFieldStringValue = "abc.gif"

// 5
guard let window = view.window else {
return
}

panel.beginSheetModal(for: window) { (result) in
guard result == .OK, let url = panel.url else {
self.showAlert()
return
}
}
}

func showAlert() {
let alert = NSAlert()
alert.messageText = "Hello world"
alert.informativeText = "Information text"
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
alert.runModal()
}

To save multiple files, use NSOpenPanel

1
2
3
4
5
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.canChooseDirectories = true
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser

Read more

How to animate NSView using keyframe

Issue #404

1
2
3
4
5
6
7
8
let animation = CAKeyframeAnimation(keyPath: "position.y")
animation.values = [50, 20, 50]
animation.keyTimes = [0.0, 0.5, 1.0]
animation.duration = 2
animation.repeatCount = Float.greatestFiniteMagnitude
animation.autoreverses = true
myView.wantsLayer = true
myView.layer?.add(animation, forKey: "bounce")

How to quit macOS on last window closed

Issue #403

https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428381-applicationshouldterminateafterl?language=objc

The application sends this message to your delegate when the application’s last window is closed. It sends this message regardless of whether there are still panels open. (A panel in this case is defined as being an instance of NSPanel or one of its subclasses.)

If your implementation returns NO, control returns to the main event loop and the application is not terminated. If you return YES, your delegate’s applicationShouldTerminate: method is subsequently invoked to confirm that the application should be terminated.

1
2
3
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}

How to test Date with timezone aware in Swift

Issue #402

I want to test if a date has passed another date

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

My hasPassed is using Calendar.current

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

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

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

return hour * 60 + minute
}

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

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

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

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

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

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

Another way is to have a fixed timezone for Calendar

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

Another way is to adjust existing date

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

How to do simple analytics in iOS

Issue #395

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import Foundation
import Firebase
import FirebaseAnalytics

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

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

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

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

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

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

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

let log = Log()
}

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

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

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

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

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

How to manage OneSignal push notification in iOS

Issue #377

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

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

Every official push notification SDK can do many things

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

OneSignal

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

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

Here is the implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import UIKit

struct Utils {

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

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

return string
}

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

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

return deviceModel
}

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

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

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

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

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

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

return files ?? []
}

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

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

static func netType() -> Int {
// Reachability
return 0
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import Foundation

public struct UserDefaults {

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

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

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

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

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

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

set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: Key.subscribed)
NSUserDefaults.standardUserDefaults().synchronize()
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import Foundation
import Alamofire

public struct OneSignal {

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

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

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

enum Provisioning: Int {
case development = 1
}

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

OneSignal.appID = appID
}

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

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

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

let url: NSURL

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

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

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

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

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

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

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

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

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

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

}
}

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

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

How to do throttle and debounce using DispatchWorkItem in Swift

Issue #376

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

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

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

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

/// Trigger the action after some delay
public func run(action: @escaping () -> Void) {
workItem?.cancel()
workItem = DispatchWorkItem(block: action)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem!)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import XCTest

class DebouncerTests: XCTestCase {

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

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

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

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

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

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