今まで作成してきたサンプルのまとめとして Realm の機能を作って株価(※)のウォッチリストを作ります。
※ 実際のデータは使わないで、データをデバッグ用に仮で作ります。
細かい解説は以下の記事を参考にしてください。
- Macアプリ初心者:NSTableView で一覧を作ってみる
- Macアプリ初心者:NSTableView でカスタムした一覧を作ってみる
- Macアプリ初心者:NSSearchField について調べてみる
- Macアプリ初心者:Realmの導入(CocoaPods利用)
- Macアプリ初心者:Realm で更新されたコレクションに対して自動でUIを描画
- Macアプリ初心者:Realm で削除されたコレクションに対して自動でUIに反映
一部リファクタリングしてるのでソースコードなどでわかりづらい点あるかもです。。。
完成イメージ
環境
- macOS Mojave:10.14.6
- Xcode:11.1
全体の構成
View
Watchリスト用と検索用のViewControllerを用意して、Watchリスト用のViewControllerはWindowControllerから初期表示用のViewControllerとして設定し、検索用のViewControllerは「+」ボタンがクリックされた場合に表示されるViewControllerとして設定しています。
データ構造
- 企業名
- 価格
- ステータス(株価が上がった、下がった、変わらない)
- Watchリストの表示/非表示
- データ作成日時
- データ更新日時
ソースコード
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
}
}
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
}
}
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
}
}
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)
}
}
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
}
}
}
}
}
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
}
}
}
}