Macアプリ初心者：Realm で更新されたコレクションに対して自動でUIを描画


[以前作成したプロジェクト](https://qiita.com/ynozue/items/15a6145096219ed1cab7)にRealmを使ってみましたが、値が更新された時にUIも連動して更新されるようにしてみました。

# 完成イメージ
3秒に1回 Realm のデータを更新してます。
[![完成イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/3e8bd25c-eb21-db88-5cab-6df131faaf9a.png)](https://youtu.be/LDTEeKTASPk)


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

# データの更新イメージ
![Kobito.c6K0zH.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/e0e98294-a49f-c3e4-a63e-db486cfe16cd.png "Kobito.c6K0zH.png")


# デバッグ用にデータ更新処理を作成
登録されている情報に対してランダムに株価の変更を行います。
3秒に1回最大で5%くらい更新されます。
## Realm データの更新処理

```swift:StockInfo.swift
// MARK: - debug
extension StockInfo {
    
    /// データ更新
    class func debugRandomUpdate(stockValues: Results<StockInfo>) {
        let realm = try! Realm()
        try! realm.write {
            for stockValue in stockValues {
                let status = StockStatus(rawValue: Int.random(in: 0..<3))!
                stockValue.stockStatus = status
                
                var value = Int(Double(stockValue.price) * 0.2)
                if value == 0 {
                    value = 1
                }
                switch status {
                case .down:
                    stockValue.price += -Int.random(in: 0..<value)
                    if stockValue.price < 0 {
                        stockValue.price = 1
                    }
                    stockValue.updatedAt = Date()
                case .up:
                    stockValue.price += Int.random(in: 0..<value)
                    stockValue.updatedAt = Date()
                case .flat:
                    break
                }
            }
        }
    }

}

```

## データ更新用のタイマーを作成
これで Realm に登録されているデータが3秒に1回更新されるようになります。

```swift:ViewController.swift
class ViewController: NSViewController {

    // MARK: - private variables
    private var timer: Timer? = nil

	override func viewDidLoad() {
		super.viewDidLoad()
        
		self.timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true, block: { (timer) in
			StockInfo.debugRandomUpdate(stockValues: self.stockValues)
		})
	}
}

    deinit {
        self.timer?.invalidate()
    }

```

# 自動でUIを描画する処理を作成
## NotificationToken を利用して、Realm オブジェクトのコレクションを監視
削除、登録、更新されたインデックスが通知されるため、その該当行に対して適切な処理を実行します。
アニメーションは気に入ったものを適当に適用してください。
また、件数と最終更新日時を表示するように修正しています。

```swift:ViewController.swift
class ViewController: NSViewController {

    // MARK: - private variables
    private var stockValues = StockInfo.objects()
    private var notificationToken: NotificationToken? = nil

    override func viewDidLoad() {
        super.viewDidLoad()

        // 株価情報をViewに反映
        self.updateStockValuesCount()
        
        // Resultsの通知を監視します
        notificationToken = self.stockValues.observe { [weak self] (changes: RealmCollectionChange) in
            guard let tableView = self?.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)")
            }
            // 件数を更新
            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))"
    }
```

# ソースコード

<details><summary>展開して確認</summary><div>

```swift:ViewController.swift
import Cocoa
import RealmSwift

class ViewController: 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
        }
    }
    @IBOutlet private weak var countTextField: NSTextField!
    
    
    // MARK: - private variables
    private var stockValues = StockInfo.objects()
    private var timer: Timer? = nil
    private var notificationToken: NotificationToken? = nil
    
    // 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.
        // デバッグ用にデータ作成
        StockInfo.debugInitData()
        // 株価情報をViewに反映
        self.updateStockValuesCount()
        
        // Resultsの通知を監視します
        notificationToken = self.stockValues.observe { [weak self] (changes: RealmCollectionChange) in
            guard let tableView = self?.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)")
            }
            // 件数を更新
            self?.updateStockValuesCount()
        }
        self.timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true, block: { (timer) in
            StockInfo.debugRandomUpdate(stockValues: self.stockValues)
        })
    }
    
    deinit {
        self.notificationToken?.invalidate()
        self.timer?.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(searchValue: sender.stringValue)
        self.tableView.reloadData()
        self.updateStockValuesCount()
    }    
    
}

extension ViewController: 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
        let stock = self.stockValues[row]
        result.image = stock.stockStatus.image
        result.name = stock.name
        result.color = stock.stockStatus.color
        result.price = stock.price

        // 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!
    
    
    // MARK: - public variables
    var name: String {
        get { return self.companyName.stringValue }
        set { self.companyName.stringValue = newValue }
    }
    
    var image: NSImage? {
        get { return self.stockImage.image }
        set { self.stockImage.image = newValue }
    }
    
    var color: NSColor? {
        get {
            return self.companyName.textColor
        }
        set {
            self.companyName.textColor = newValue
            self.stockImage.contentTintColor = newValue
        }
    }
    
    var price: Int = -1 {
        didSet {
            guard self.price > 0 else {
                self.stockPrice.stringValue = "-"
                return
            }
            let formatter = NumberFormatter()
            formatter.numberStyle = .decimal
            let commaString = formatter.string(from: NSNumber(value: self.price))!
            self.stockPrice.stringValue = commaString
        }
    }
}
```

```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 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
        }
    }
    
    /// StockInfo オブジェクトを作成
    class func create(realm: Realm, name: String, price: Int, status: StockStatus) -> StockInfo {
        let stockInfo = realm.create(StockInfo.self)
        stockInfo.name = name
        stockInfo.price = price
        stockInfo.stockStatus = status
        
        return stockInfo
    }
    
    /// 企業名で部分一致検索（空の場合は全件取得）
    class func objects(searchValue: String = "") -> Results<StockInfo> {
        var result = try! Realm().objects(StockInfo.self)
        let searchValue = searchValue.trimmingCharacters(in: .whitespaces)
        if searchValue != "" {
            result = result.filter("name CONTAINS %@", searchValue)
        }
        
        return result.sorted(byKeyPath: "createdAt", ascending: true)
    }
}

// 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)
            let _ = StockInfo.create(realm: realm, name: "○×水産", price: 12345, status: .down)
            let _ = StockInfo.create(realm: realm, name: "株式会社□○", price: 345, status: .flat)
            let _ = StockInfo.create(realm: realm, name: "×△ホールディングス", price: 321, status: .up)
            let _ = StockInfo.create(realm: realm, name: "ABC BANK", price: 20, status: .down)
            let _ = StockInfo.create(realm: realm, name: "▼○重工", price: 98000, status: .up)
            
            // サンプル用に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)
//            }
        }
    }
    
    /// データ更新
    class func debugRandomUpdate(stockValues: Results<StockInfo>) {
        let realm = try! Realm()
        try! realm.write {
            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 == 0 {
                    value = 1
                }
                switch status {
                case .down:
                    stockValue.price += -Int.random(in: 0..<value)
                    if stockValue.price < 0 {
                        stockValue.price = 1
                    }
                    stockValue.updatedAt = Date()
                case .up:
                    stockValue.price += Int.random(in: 0..<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
            }
        }
    }
}
```

</div></details>