Write a Digital Clock on macOS

昨天打着打着电话,突然玲就问我,macOS 能不能同时显示两个数字时钟,于是我就找了一圈,DashBoard 上虽然可以显示两个,但是都是模拟时钟,而且玲也没买触摸板,在桌面和 DashBoard 之间切换不那么方便。

就计划帮她写一个,于是“产品经理玲”就发给我了一个 prototype (゚o゚;;

写,都可以写(x

于是结合产品经理玲和我的想法的话,就要自己来画 NSWindow 和它下面NSViewController 对应的 NSView

NSWindow 就自然是全部透明,无边框,这个很好实现~

import Cocoa

class KiminoWindow: NSWindow {
    override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
        super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
        setupWindow()
    }
    
    func setupWindow() {
        isOpaque = false
        backgroundColor = NSColor.clear
        isMovableByWindowBackground = true
        styleMask = .borderless
    }
}

接下来就是 NSView 了,这里就先用 PaintCode 画一个接近玲想的 prototype

接下来就是把这个 NSView 显示的内容从模版里静态的文字、颜色,变成可以通过代码调整、自动更新的

import Cocoa

class KiminoView: NSView {
    /// timezone
    var timeZone: String = "Asia/Chongqing" {
        didSet {
            self.update()
        }
    }
    
    /// font colour
    var clockColor: NSColor = NSColor(red: 0.946, green: 0.829, blue: 0.243, alpha: 1) {
        didSet {
            self.update()
        }
    }
    
    /// redraw
    func update() {
        self.setNeedsDisplay(self.frame)
    }
    
    /// draw clock interface
    ///
    /// - Parameter dirtyRect: rect
    override func draw(_ dirtyRect: NSRect) {
        // rectangle drawing
        NSGraphicsContext.saveGraphicsState()
        let rectanglePath = NSBezierPath(roundedRect: NSRect(x: 2, y: 0, width: 150, height: 55), xRadius: 10, yRadius: 10)
        // clock background
        NSColor(red: 0, green: 0, blue: 0, alpha: 0.916).setFill()
        rectanglePath.fill()
        NSGraphicsContext.restoreGraphicsState()
        
        // digits on the clock
        let textRect = NSRect(x: 2, y: 2, width: 150, height: 55)
        let formatter = DateFormatter()
        // 24 hrs
        formatter.dateFormat = "HH:mm"
        // specific timezone
        formatter.timeZone = TimeZone.init(identifier: timeZone)
        let textTextContent = formatter.string(from: Date())
        let textStyle = NSMutableParagraphStyle()
        textStyle.alignment = .center
        let textFontAttributes = [
            .font: NSFont(name: "digital-7", size: 63)!,
            .foregroundColor: clockColor,
            .paragraphStyle: textStyle,
            ] as [NSAttributedString.Key: Any]
        let textTextHeight: CGFloat = textTextContent.boundingRect(with: NSSize(width: textRect.width, height: CGFloat.infinity), options: .usesLineFragmentOrigin, attributes: textFontAttributes).height
        let textTextRect: NSRect = NSRect(x: textRect.minX, y: textRect.minY + (textRect.height - textTextHeight) / 2, width: textRect.width, height: textTextHeight)
        NSGraphicsContext.saveGraphicsState()
        textRect.clip()
        textTextContent.draw(in: textTextRect.offsetBy(dx: 0, dy: 0), withAttributes: textFontAttributes)
        NSGraphicsContext.restoreGraphicsState()
    }
}

接下来就在应用启动时添加第二个 NSWindow 就可以啦~

顺便想到也许会有需要将两个时钟放在最前的需要,于是就再做了个Status Bar的坑~

//
//  AppDelegate.swift
//  kimino
//
//  Created by Cocoa on 8/11/2019.
//  Copyright © 2019 Cocoa. All rights reserved.
//

import Cocoa

@NSApplicationMain
class AppDelegate: NSWindowController, NSApplicationDelegate {
    var keepFrontMenuItem: NSMenuItem!
    var statusBarItem: NSStatusItem!
    var clockWindow: Array<NSWindow>!
    var isFloating: Bool!
    
    /// Button Action
    @objc func keepFrontAction() {
        // toggle floating
        isFloating = !isFloating
        // save user preference
        UserDefaults.standard.set(isFloating, forKey: "keepfront")
        // do the actual functionality
        keepFront()
    }
    
    /// Actual keep front functionality
    func keepFront() {
        // update diplayed status
        keepFrontMenuItem.title = "Keep Front: \(isFloating ? "On" : "Off")"
        
        // change window level accordingly
        if isFloating {
            self.clockWindow.forEach { window in
                window.level = .floating
            }
        } else {
            self.clockWindow.forEach { window in
                window.level = .normal
            }
        }
    }
    
    /// Quit this application
    @objc func quit() {
        NSApplication.shared.terminate(self)
    }
    
    /// Setup after application finishing launching
    ///
    /// - Parameter aNotification: unused
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // put the first clock window into `clockWindow` array
        clockWindow = [NSApplication.shared.windows[0]]
        
        // prepare and display the second clock window, London
        let storyboard = NSStoryboard(name: "Main", bundle: nil)
        let kiminoSecondaryWindowController = storyboard.instantiateInitialController() as! NSWindowController
        if let kiminoSecondaryWindow = kiminoSecondaryWindowController.window {
            let kiminoSecondaryView = kiminoSecondaryWindow.contentViewController?.view as! KiminoView
            // timezone, Europe/London
            kiminoSecondaryView.timeZone = "Europe/London"
            kiminoSecondaryView.clockColor = NSColor(red: 0, green: 0.628, blue: 1, alpha: 1)
            // bring front
            kiminoSecondaryWindow.orderFront(self)
            // append the second window to `clockWindow` array
            clockWindow.append(kiminoSecondaryWindow)
        }
        
        // place clock windows to top right
        placeWindow(clockWindow[0], 100, 100)
        placeWindow(clockWindow[1], 120, 40)
        
        // update every second
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
            self.clockWindow.forEach({ window in
                let view = window.contentViewController?.view as! KiminoView
                view.update()
            })
        }
        
        // Status Bar
        let statusMenu = NSMenu(title: "kimino")
        keepFrontMenuItem = NSMenuItem(title: "Keep Front", action: #selector(AppDelegate.keepFrontAction), keyEquivalent: "")
        keepFrontMenuItem.keyEquivalentModifierMask = .command
        keepFrontMenuItem.keyEquivalent = "L"
        let quiteMenuItem = NSMenuItem(title: "Quit", action: #selector(AppDelegate.quit), keyEquivalent: "")
        quiteMenuItem.keyEquivalentModifierMask = .command
        quiteMenuItem.keyEquivalent = "Q"
        
        statusMenu.addItem(keepFrontMenuItem)
        statusMenu.addItem(quiteMenuItem)
        statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
        statusBarItem.menu = statusMenu
        statusBarItem.button?.image = NSImage(named: "kimino-status-bar")
        
        // restore user defaults
        isFloating = UserDefaults.standard.bool(forKey: "keepfront")
        keepFront()
        
        print("every day, every night, every dream")
    }
    
    
    /// Place NSWindow to specific postion with respect to the screen
    ///
    /// - Parameters:
    ///   - window: window to be replaced
    ///   - right: pixels to the right edge
    ///   - top: pixels to the top edge
    func placeWindow(_ window: NSWindow, _ right: CGFloat, _ top: CGFloat) {
        if let screen = window.screen {
            let screenRect = screen.visibleFrame
            let newOriginX = screenRect.maxX - window.frame.width - right
            let newOriginY = screenRect.maxY - window.frame.height - top
            window.setFrameOrigin(NSPoint(x: newOriginX, y: newOriginY))
        }
    }
}

其实最后效果还是不错的(*^3^)

声明: 本文为0xBBC原创, 转载注明出处喵~

Leave a Reply

Your email address will not be published. Required fields are marked *