Macアプリ初心者：Realm で削除されたコレクションに対して自動でUIに反映


[前回作成したプロジェクト](https://qiita.com/ynozue/items/15a6145096219ed1cab7)ではRealmで更新された情報を自動的にアップデートするための処理を実装してみましたが、今回はにRealmを使ってみましたが、値が更新された時にUIも連動して更新されるようにしてみました。
Watchリスト風にしてみました。次回は追加機能を作ります。

# 完成イメージ
「×」ボタンで Watch List から削除されます。
[![完成イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/24b5c9fe-8b49-9668-88a3-77010f64b49e.png)](https://youtu.be/_l7VP75X3yM)


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

# データの更新イメージ
Watchリスト風にするために、前回作成した時からデータ構造を変更しています。
![Kobito.WuChKe.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/713d7f33-783f-729b-05cd-235f91c35027.png "Kobito.WuChKe.png")
株価情報をマスターデータにしてWatchリストへの表示/非表示をフラグで管理します。

# データクラスの変更

## データ構造を変更
Watchリストの表示/非表示をフラグとして、`isWatchList` を追加しています。

```swift:StockInfo.swift
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()
}
```

## データ構造変化に伴い処理の追加と変更
`isWatchList` の更新用の関数を追加

```swift:StockInfo.swift
extension StockInfo {
    /// watch list の表示/非表示を切り替える
    func update(isWatchList: Bool) {
        let realm = try! Realm()
        try! realm.write {
            self.isWatchList = isWatchList
            self.updatedAt = Date()
        }
    }
```

検索用の関数にWatchリストの表示/非表示をフラグを指定できるように修正

```swift:StockInfo.swift
    /// 企業名で部分一致検索（空の場合は全件取得）
    class func objects(isWatchList: Bool? = true, searchValue: String = "") -> Results<StockInfo> {
        var result = try! 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)
    }
}
```

# Watchリストからの削除用UIを作成
## カスタムセルのイメージ
以前に作成したセルの右側に新しくカスタムViewとNSButtonを配置してます。
![Kobito.BwLoZb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203864/9b471ba0-51c6-349b-40ac-77dc7cdbc6cb.png "Kobito.BwLoZb.png")

## ×ボタンクリック時に表示フラグをオフ
以前のプロジェクトからリファクタリングして、株価情報Objectをカスタムセルに渡して株価情報の描画と更新処理を行うようにしてます。
データオブジェクトをUIクラスで保持するのはどうなんだって話もありますが、わかりやすいのでこんな感じで作ってしまってます。

```swift:CustomNSTableCellView.swift
class CustomNSTableCellView: NSTableCellView {
    // 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
        }
    }
    
    // MARK: - IBAction
    @IBAction func actionDelete(_ sender: NSButton) {
        self.stockInfo?.update(isWatchList: false)
    }
}
```

カスタムセルのI/Fを変更したのでそれに合わせて、ViewController 側も変更


```swift:ViewController.swift
extension ViewController: NSTableViewDataSource, NSTableViewDelegate {
    
    // 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]

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


# ソースコード

<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
        result.stockInfo = self.stockValues[row]

        // 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 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
        }
    }
    
    // MARK: - IBAction
    @IBAction func actionDelete(_ sender: NSButton) {
        self.stockInfo?.update(isWatchList: false)
    }

}
```

```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) -> StockInfo {
        let stockInfo = realm.create(StockInfo.self)
        stockInfo.name = name
        stockInfo.price = price
        stockInfo.stockStatus = status
        
        return stockInfo
    }
    
    /// 企業名で部分一致検索（空の場合は全件取得）
    class func objects(isWatchList: Bool? = true, searchValue: String = "") -> Results<StockInfo> {
        var result = try! 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)
    }
}

// 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
                }
            }
        }
    }
}
```

</div></details>