Macアプリ初心者：Realm を使って株価のウォッチリストを作る（まとめ）


今まで作成してきたサンプルのまとめとして Realm の機能を作って株価（※）のウォッチリストを作ります。
※ 実際のデータは使わないで、データをデバッグ用に仮で作ります。

細かい解説は以下の記事を参考にしてください。

* [Macアプリ初心者：NSTableView で一覧を作ってみる](https://qiita.com/ynozue/items/0fe35afd8eb7cd4a7f0f)
* [Macアプリ初心者：NSTableView でカスタムした一覧を作ってみる](https://qiita.com/ynozue/items/da96d8bf1a2e2e1f3da0)
* [Macアプリ初心者：NSSearchField について調べてみる](https://qiita.com/ynozue/items/e839f25426bbd1be313b)
* [Macアプリ初心者：Realmの導入(CocoaPods利用)](https://qiita.com/ynozue/items/15a6145096219ed1cab7)
* [Macアプリ初心者：Realm で更新されたコレクションに対して自動でUIを描画](https://qiita.com/ynozue/items/e549196513fc3032fd03)
* [Macアプリ初心者：Realm で削除されたコレクションに対して自動でUIに反映](https://qiita.com/ynozue/items/8b95e9aa636e49c3f4ac)

一部リファクタリングしてるのでソースコードなどでわかりづらい点あるかもです。。。

# 完成イメージ
[![完成イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/68effa4f-1a3e-c55c-1472-ec4d789455dc.png)](https://youtu.be/xb8W2mKTl60)

# 環境
* macOS Mojave：10.14.6
* Xcode：11.1

# 全体の構成
## View
Watchリスト用と検索用のViewControllerを用意して、Watchリスト用のViewControllerはWindowControllerから初期表示用のViewControllerとして設定し、検索用のViewControllerは「+」ボタンがクリックされた場合に表示されるViewControllerとして設定しています。
![Kobito.CmJ0aq.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/a5c7e668-f139-2c98-8979-69f42d7d5b4d.png "Kobito.CmJ0aq.png")

![Kobito.Y9ofv2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/8c64d89c-d012-883b-45a7-c90272b0aa22.png "Kobito.Y9ofv2.png")

![Kobito.kOpmwL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/e1c402d0-4c71-408b-1b41-59ca08953be2.png "Kobito.kOpmwL.png")


## データ構造
* 企業名
* 価格
* ステータス（株価が上がった、下がった、変わらない）
* Watchリストの表示/非表示
* データ作成日時
* データ更新日時


# ソースコード


```swift:AppDelegate.swift
import Cocoa

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    /// update stock info
    private var timer: Timer? = nil

    func applicationWillFinishLaunching(_ notification: Notification) {
        
        // debug
        StockInfo.debugInitData()
        self.timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true, block: { (timer) in
            StockInfo.debugRandomUpdate()
        })
    }

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Insert code here to initialize your application
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }


}
```

```swift:WatchListViewController.swift
import Cocoa
import RealmSwift

class WatchListViewController: NSViewController {

    // MARK: - IBOutlet
    @IBOutlet private weak var tableView: NSTableView! {
        didSet {
            self.tableView.dataSource = self
            self.tableView.delegate = self
            
            self.tableView.headerView = nil
            self.tableView.rowHeight = 88
            
            let name = String(describing: CustomNSTableCellView.self)
            if let nib = NSNib(nibNamed: name, bundle: Bundle(for: type(of: self))) {
                nib.instantiate(withOwner: self, topLevelObjects: nil)
                self.tableView.register(nib, forIdentifier: NSUserInterfaceItemIdentifier.init("MyView"))
            }
        }
    }
    @IBOutlet private weak var countTextField: NSTextField!
    
    
    // MARK: - private variables
    private let stockValues = StockInfo.objects(isWatchList: true)
    private lazy var notificationToken = StockInfo.notificationToken(stockValues: self.stockValues, tableView: self.tableView) {
        self.updateStockValuesCount()
    }

    // MARK: - override variables
    override var representedObject: Any? {
        didSet {
            // Update the view, if already loaded.
        }
    }

    // MARK: - override func
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        print(self.notificationToken.description)
        // 株価情報をViewに反映
        self.updateStockValuesCount()
    }
    
    deinit {
        self.notificationToken.invalidate()
    }

    
    // MARK: - private func
    
    private func updateStockValuesCount() {
        let numberFormatter = NumberFormatter()
        numberFormatter.numberStyle = .decimal
        let commaCount = numberFormatter.string(from: NSNumber(value: self.stockValues.count))!
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH:mm:ss"
        let lastUpdatedString = dateFormatter.string(from: Date())
        self.countTextField.stringValue = "\(commaCount)件 (last updated:\(lastUpdatedString))"
    }
    
}

extension WatchListViewController: NSTableViewDataSource, NSTableViewDelegate {
    
    // MARK: - NSTableViewDataSource
    func numberOfRows(in tableView: NSTableView) -> Int {
        return self.stockValues.count
    }
    
    
    // MARK: - NSTableViewDelegate
    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        let result = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "MyView"), owner: self) as! CustomNSTableCellView
        
        // Set the stringValue of the cell's text field to the nameArray value at row
        result.stockInfo = self.stockValues[row]
        result.isHiddenAddArea = true
        result.isHiddenDeleteArea = false

        // Return the result
        return result
    }
}
```

```swift:SearchStockViewController.swift
import Cocoa

class SearchStockViewController: NSViewController {

    // MARK: - IBOutlet
    @IBOutlet private weak var tableView: NSTableView! {
        didSet {
            self.tableView.dataSource = self
            self.tableView.delegate = self
            
            self.tableView.headerView = nil
            self.tableView.rowHeight = 88
            
            let name = String(describing: CustomNSTableCellView.self)
            if let nib = NSNib(nibNamed: name, bundle: Bundle(for: type(of: self))) {
                nib.instantiate(withOwner: self, topLevelObjects: nil)
                self.tableView.register(nib, forIdentifier: NSUserInterfaceItemIdentifier.init("MyView"))
            }
        }
    }
    @IBOutlet private weak var countTextField: NSTextField!
    
    
    // MARK: - private variables
    private var stockValues = StockInfo.objects(isWatchList: false)
    private lazy var notificationToken = StockInfo.notificationToken(stockValues: self.stockValues, tableView: self.tableView) {
        self.updateStockValuesCount()
    }
    
    // MARK: - override variables
    override var representedObject: Any? {
        didSet {
            // Update the view, if already loaded.
        }
    }

    // MARK: - override func
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        print(self.notificationToken.description)
        // 株価情報をViewに反映
        self.updateStockValuesCount()
    }
    
    deinit {
        self.notificationToken.invalidate()
    }

    
    // MARK: - private func
    
    private func updateStockValuesCount() {
        let numberFormatter = NumberFormatter()
        numberFormatter.numberStyle = .decimal
        let commaCount = numberFormatter.string(from: NSNumber(value: self.stockValues.count))!
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH:mm:ss"
        let lastUpdatedString = dateFormatter.string(from: Date())
        self.countTextField.stringValue = "\(commaCount)件 (last updated:\(lastUpdatedString))"
    }
    

    // MARK: - actions
    
    @IBAction func actionSearchField(_ sender: NSSearchField) {
        self.stockValues = StockInfo.objects(isWatchList: false, searchValue: sender.stringValue)
        self.notificationToken.invalidate()
        self.notificationToken = StockInfo.notificationToken(stockValues: self.stockValues, tableView: self.tableView) {
            self.updateStockValuesCount()
        }
        self.tableView.reloadData()
        self.updateStockValuesCount()
    }
    
}

extension SearchStockViewController: NSTableViewDataSource, NSTableViewDelegate {
    
    // MARK: - NSTableViewDataSource
    func numberOfRows(in tableView: NSTableView) -> Int {
        return self.stockValues.count
    }
    
    
    // MARK: - NSTableViewDelegate
    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        let result = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "MyView"), owner: self) as! CustomNSTableCellView
        
        // Set the stringValue of the cell's text field to the nameArray value at row
        result.stockInfo = self.stockValues[row]
        result.isHiddenAddArea = false
        result.isHiddenDeleteArea = true

        // Return the result
        return result
    }

}
```

```swift:CustomNSTableCellView.swift
import Cocoa

class CustomNSTableCellView: NSTableCellView {

    // MARK: - IBOutlet
    @IBOutlet private weak var stockImage: NSImageView!
    @IBOutlet private weak var companyName: NSTextField!
    @IBOutlet private weak var stockPrice: NSTextField!
    @IBOutlet private weak var addArea: NSView!
    @IBOutlet private weak var deleteArea: NSView!

    
    // MARK: - public variables
    var stockInfo: StockInfo? {
        didSet {
            guard let stockInfo = self.stockInfo else {
                return
            }
            self.companyName.stringValue = stockInfo.name
            self.stockImage.image = stockInfo.stockStatus.image
            self.companyName.textColor = stockInfo.stockStatus.color
            self.stockImage.contentTintColor = stockInfo.stockStatus.color
            
            let formatter = NumberFormatter()
            formatter.numberStyle = .decimal
            let commaString = formatter.string(from: NSNumber(value: stockInfo.price))!
            self.stockPrice.stringValue = commaString
        }
    }
    
    var isHiddenDeleteArea: Bool = true {
        didSet { self.deleteArea.isHidden = self.isHiddenDeleteArea }
    }
    
    var isHiddenAddArea: Bool = true {
        didSet { self.addArea.isHidden = self.isHiddenAddArea }
    }

    // MARK: - IBAction
    @IBAction func actionDelete(_ sender: NSButton) {
        self.stockInfo?.update(isWatchList: false)
    }
    
    @IBAction func actionAdd(_ sender: NSButton) {
        self.stockInfo?.update(isWatchList: true)
    }

}
```

```swift:StockInfo.swift
import Cocoa
import RealmSwift

class StockInfo: Object {
    @objc dynamic var name = ""
    @objc dynamic var price = -1
    @objc dynamic var status = StockStatus.flat.rawValue
    @objc dynamic var isWatchList = true
    @objc dynamic var createdAt = Date()
    @objc dynamic var updatedAt = Date()
}

extension StockInfo {
    /// 状態をEnum形式で返却
    var stockStatus: StockStatus {
        get {
            return StockStatus(rawValue: self.status) ?? StockStatus.flat
        }
        set {
            self.status = newValue.rawValue
        }
    }
    
    /// watch list の表示/非表示を切り替える
    func update(isWatchList: Bool) {
        let realm = try! Realm()
        try! realm.write {
            self.isWatchList = isWatchList
            self.updatedAt = Date()
        }
    }
    
    /// StockInfo オブジェクトを作成
    class func create(realm: Realm, name: String, price: Int, status: StockStatus, isWatchList: Bool) -> StockInfo {
        let stockInfo = realm.create(StockInfo.self)
        stockInfo.name = name
        stockInfo.price = price
        stockInfo.stockStatus = status
        stockInfo.isWatchList = isWatchList
        
        return stockInfo
    }
    
    /// 企業名で部分一致検索（空の場合は全件取得）
    class func objects(realm: Realm = try! Realm(),isWatchList: Bool? = true, searchValue: String = "") -> Results<StockInfo> {
        var result = realm.objects(StockInfo.self)
        let searchValue = searchValue.trimmingCharacters(in: .whitespaces)
        if let isWatchList = isWatchList {
            result = result.filter("isWatchList == %@", isWatchList)
        }
        if searchValue != "" {
            result = result.filter("name CONTAINS %@", searchValue)
        }
        
        return result.sorted(byKeyPath: "createdAt", ascending: true)
    }
    
    /// 監視用処理
    class func notificationToken(stockValues: Results<StockInfo>, tableView: NSTableView, completion: (() -> Void)?) -> NotificationToken {
        return stockValues.observe { [weak tableView, completion] (changes: RealmCollectionChange) in
            guard let tableView = tableView else { return }
            switch changes {
            case .initial:
                // Results are now populated and can be accessed without blocking the UI
                tableView.reloadData()
            case .update(let collectionType, let deletions, let insertions, let modifications):
                // Resultsに変更があったので、UITableViewに変更を適用します
                print(collectionType)
                tableView.beginUpdates()
                tableView.insertRows(at: IndexSet(insertions), withAnimation: .effectFade)
                tableView.removeRows(at: IndexSet(deletions), withAnimation: .effectFade)
                tableView.reloadData(forRowIndexes: IndexSet(modifications), columnIndexes: IndexSet(integer: 0))
                tableView.endUpdates()
            case .error(let error):
                // バックグラウンドのワーカースレッドがRealmファイルを開く際にエラーが起きました
                fatalError("\(error)")
            }
            // 件数を更新
            completion?()
        }
    }
}

// MARK: - debug
extension StockInfo {

    /// データ初期化
    class func debugInitData() {
        let realm = try! Realm()
        try! realm.write {
            // データクリア
            realm.deleteAll()
            
            // サンプルデータ作成
            let _ = StockInfo.create(realm: realm, name: "○○株式会社", price: 1000, status: .up, isWatchList: false)
            let _ = StockInfo.create(realm: realm, name: "○×水産", price: 12345, status: .down, isWatchList: false)
            let _ = StockInfo.create(realm: realm, name: "株式会社□○", price: 345, status: .flat, isWatchList: false)
            let _ = StockInfo.create(realm: realm, name: "×△ホールディングス", price: 321, status: .up, isWatchList: false)
            let _ = StockInfo.create(realm: realm, name: "ABC BANK", price: 20, status: .down, isWatchList: false)
            let _ = StockInfo.create(realm: realm, name: "▼○重工", price: 98000, status: .up, isWatchList: false)
            
            // サンプル用に1,000 件データを作成
            for i in 1...1_000 {
                let status = StockStatus.init(rawValue: Int.random(in: 0..<3))!
                let price = Int.random(in: -1..<10_000_000)
                let _ = StockInfo.create(realm: realm, name: "○△×株式会社 \(i)", price: price, status: status, isWatchList: false)
            }
        }
    }
    
    /// データ更新
    class func debugRandomUpdate() {
        let realm = try! Realm()
        try! realm.write {
            let stockValues = StockInfo.objects(realm: realm, isWatchList: nil)
            print("update stock values \(stockValues.count)")
            for stockValue in stockValues {
                let status =
                    StockStatus(rawValue: Int.random(in: 0..<3))!
                stockValue.stockStatus = status
                
                var value = Int(Double(stockValue.price) * 0.05)
                if value <= 1 {
                    value = 2
                }
                switch status {
                case .down:
                    stockValue.price += -Int.random(in: 1..<value)
                    if stockValue.price < 0 {
                        stockValue.price = 1
                    }
                    stockValue.updatedAt = Date()
                case .up:
                    stockValue.price += Int.random(in: 1..<value)
                    stockValue.updatedAt = Date()
                case .flat:
                    break
                }
            }
        }
    }

}
```

```swift:StockStatus.swift
import Cocoa

enum StockStatus: Int {
    case flat = 0
    case up
    case down
    
    /// 状態ごとのImageオブジェクト
    var image: NSImage {
        get {
            switch self {
            case .flat:
                return NSImage(named: NSImage.Name("flat"))!
            case .up:
                return NSImage(named: NSImage.Name("up"))!
            case .down:
                return NSImage(named: NSImage.Name("down"))!
            }
        }
    }
    
    /// 状態ごとのカラーオブジェクト
    var color: NSColor {
        get {
            switch self {
            case .flat:
                return .gray
            case .up:
                return .green
            case .down:
                return .red
            }
        }
    }
}
```