420 lines
14 KiB
Swift
420 lines
14 KiB
Swift
//
|
|
// FlatButton.swift
|
|
// Privado
|
|
//
|
|
// Created by Jura on 11/21/19.
|
|
// Copyright © 2019 Omicronmedia. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import AppKit
|
|
|
|
final class FlatButton: NSButton, CALayerDelegate {
|
|
|
|
/// Restore button state when mouse up.
|
|
var momentary = false {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
/// The animation duration from NSOffState to NSOnState.
|
|
var onAnimateDuration: TimeInterval = 0.0
|
|
|
|
/// The animation duration from NSOnState to NSOffState.
|
|
var offAnimateDuration: TimeInterval = 0.0
|
|
|
|
/// Button's border corner radius.
|
|
var cornerRadius: CGFloat = 0.0 {
|
|
didSet {
|
|
self.layer?.cornerRadius = self.cornerRadius
|
|
}
|
|
}
|
|
|
|
/// Button's border width.
|
|
var borderWidth: CGFloat = 0.0 {
|
|
didSet {
|
|
self.layer?.borderWidth = self.borderWidth
|
|
}
|
|
}
|
|
|
|
/// Button's spacint between image and title.
|
|
var spacing: CGFloat = 0.0 {
|
|
didSet {
|
|
self.setupImageLayer()
|
|
self.setupTitleLayer()
|
|
}
|
|
}
|
|
|
|
/// Button's border color when state off.
|
|
var borderNormalColor: NSColor? {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
/// Button's border color when state on.
|
|
var borderHighlightColor: NSColor? {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
/// Button's background color when state off.
|
|
var backgroundNormalColor: NSColor? {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
/// Button's background color when state on.
|
|
var backgroundHighlightColor: NSColor? {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
/// Button's image color when state off.
|
|
var imageNormalColor: NSColor?
|
|
|
|
/// Button's image color when state on.
|
|
var imageHighlightColor: NSColor?
|
|
|
|
/// Button's title color when state off.
|
|
var titleNormalColor: NSColor? {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
/// Button's title color when state on.
|
|
var titleHighlightColor: NSColor? {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
private lazy var imageLayer: CAShapeLayer = {
|
|
let layer = CAShapeLayer()
|
|
layer.delegate = self
|
|
return layer
|
|
}()
|
|
private lazy var titleLayer: CATextLayer = {
|
|
let layer = CATextLayer()
|
|
layer.delegate = self
|
|
return layer
|
|
}()
|
|
private var mouseDown = false
|
|
|
|
// MARK: - Initializers
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
|
|
self.setup()
|
|
self.setupImageLayer()
|
|
self.setupTitleLayer()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func setup() {
|
|
// Setup layer
|
|
self.translatesAutoresizingMaskIntoConstraints = false
|
|
self.wantsLayer = true
|
|
self.layer?.masksToBounds = true
|
|
self.layer?.delegate = self
|
|
self.layer?.backgroundColor = NSColor.red.cgColor
|
|
self.alphaValue = self.isEnabled ? 1.0 : 0.5
|
|
}
|
|
|
|
// swiftlint:disable function_body_length
|
|
private func setupImageLayer() {
|
|
// Ignore image layer if has no image or imagePosition equal to NSNoImage
|
|
guard let image = self.image
|
|
, self.imagePosition != .noImage else {
|
|
self.imageLayer.removeFromSuperlayer()
|
|
return
|
|
}
|
|
|
|
let buttonSize = self.frame.size
|
|
let imageSize = image.size
|
|
let titleSize: CGSize
|
|
if let font = self.font {
|
|
titleSize = self.title.size(withAttributes: [.font: font])
|
|
} else {
|
|
titleSize = self.title.size(withAttributes: nil)
|
|
}
|
|
|
|
|
|
var x: CGFloat = 0.0 // Image's origin x
|
|
var y: CGFloat = 0.0 // Image's origin y
|
|
|
|
// Caculate the image's and title's position depends on button's imagePosition and imageHugsTitle property
|
|
switch self.imagePosition {
|
|
case .noImage:
|
|
return
|
|
case .imageOnly:
|
|
x = (buttonSize.width - imageSize.width) / 2.0
|
|
y = (buttonSize.height - imageSize.height) / 2.0
|
|
case .imageOverlaps:
|
|
x = (buttonSize.width - imageSize.width) / 2.0
|
|
y = (buttonSize.height - imageSize.height) / 2.0
|
|
case .imageLeading,
|
|
.imageLeft:
|
|
x = self.imageHugsTitle
|
|
? (buttonSize.width - imageSize.width - titleSize.width) / 2.0 - self.spacing
|
|
: self.spacing
|
|
y = (buttonSize.height - imageSize.height) / 2.0
|
|
case .imageTrailing,
|
|
.imageRight:
|
|
x = self.imageHugsTitle
|
|
? (buttonSize.width - imageSize.width + titleSize.width) / 2.0 + self.spacing
|
|
: buttonSize.width - imageSize.width - self.spacing
|
|
y = (buttonSize.height - imageSize.height) / 2.0
|
|
case .imageAbove:
|
|
x = (buttonSize.width - imageSize.width) / 2.0
|
|
y = self.imageHugsTitle
|
|
? (buttonSize.height - imageSize.height - titleSize.height) / 2.0 - self.spacing
|
|
: self.spacing
|
|
case .imageBelow:
|
|
x = (buttonSize.width - imageSize.width) / 2.0
|
|
y = self.imageHugsTitle
|
|
? (buttonSize.height - imageSize.height + titleSize.height) / 2.0 + self.spacing
|
|
: buttonSize.height - imageSize.height - self.spacing
|
|
default:
|
|
break
|
|
}
|
|
|
|
// Setup image layer
|
|
self.imageLayer.frame = self.bounds
|
|
|
|
let layer = CALayer()
|
|
var rect = NSRect(x: round(x), y: round(y), width: imageSize.width, height: imageSize.height)
|
|
layer.frame = rect
|
|
layer.contents = image.cgImage(forProposedRect: &rect, context: nil, hints: nil)
|
|
|
|
self.imageLayer.mask = layer
|
|
|
|
self.imageLayer.backgroundColor = self.state == .on ? self.imageHighlightColor?.cgColor : self.imageNormalColor?.cgColor
|
|
|
|
self.layer?.addSublayer(self.imageLayer)
|
|
}
|
|
// swiftlint:enable function_body_length
|
|
|
|
// swiftlint:disable function_body_length
|
|
private func setupTitleLayer() {
|
|
|
|
// Ignore title layer if has no title or imagePosition equal to NSImageOnly
|
|
if self.title.isEmpty || self.imagePosition == .imageOnly {
|
|
self.titleLayer.removeFromSuperlayer()
|
|
return
|
|
}
|
|
|
|
guard let font = self.font else { return }
|
|
|
|
let buttonSize = self.frame.size
|
|
let imageSize = self.image?.size ?? .zero
|
|
let titleSize = self.title.size(withAttributes: [.font: font])
|
|
|
|
var x: CGFloat = 0.0 // Title's origin x
|
|
var y: CGFloat = 0.0 // Title's origin y
|
|
|
|
// Caculate the image's and title's position depends on button's imagePosition and imageHugsTitle property
|
|
switch self.imagePosition {
|
|
case .imageOnly:
|
|
return
|
|
case .noImage:
|
|
x = (buttonSize.width - titleSize.width) / 2.0
|
|
y = (buttonSize.height - titleSize.height) / 2.0
|
|
case .imageOverlaps:
|
|
x = (buttonSize.width - titleSize.width) / 2.0
|
|
y = (buttonSize.height - titleSize.height) / 2.0
|
|
case .imageLeading,
|
|
.imageLeft:
|
|
x = self.imageHugsTitle
|
|
? (buttonSize.width + imageSize.width - titleSize.width) / 2.0 + self.spacing
|
|
: buttonSize.width - titleSize.width - self.spacing
|
|
y = (buttonSize.height - titleSize.height) / 2.0
|
|
case .imageTrailing,
|
|
.imageRight:
|
|
x = self.imageHugsTitle
|
|
? (buttonSize.width - imageSize.width - titleSize.width) / 2.0 - self.spacing
|
|
: self.spacing
|
|
y = (buttonSize.height - titleSize.height) / 2.0
|
|
case .imageAbove:
|
|
x = (buttonSize.width - titleSize.width) / 2.0
|
|
y = self.imageHugsTitle
|
|
? (buttonSize.height + imageSize.height - titleSize.height) / 2.0 + self.spacing
|
|
: buttonSize.height - titleSize.height - self.spacing
|
|
case .imageBelow:
|
|
y = self.imageHugsTitle
|
|
? (buttonSize.height - imageSize.height - titleSize.height) / 2.0 - self.spacing
|
|
: self.spacing
|
|
x = (buttonSize.width - titleSize.width) / 2.0
|
|
default:
|
|
break
|
|
}
|
|
|
|
// Setup title layer
|
|
self.titleLayer.frame = NSRect(x: round(x), y: round(y), width: ceil(titleSize.width), height: ceil(titleSize.height))
|
|
self.titleLayer.string = self.title
|
|
self.titleLayer.font = font
|
|
self.titleLayer.fontSize = font.pointSize
|
|
if let scale = NSScreen.main?.backingScaleFactor {
|
|
self.titleLayer.contentsScale = scale
|
|
}
|
|
self.layer?.addSublayer(self.titleLayer)
|
|
}
|
|
// swiftlint:enable function_body_length
|
|
|
|
// MARK: - Drawing method
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
setupTitleLayer()
|
|
setupImageLayer()
|
|
}
|
|
|
|
override func updateLayer() {
|
|
super.updateLayer()
|
|
// setupTitleLayer()
|
|
// setupImageLayer()
|
|
}
|
|
|
|
// MARK: - Animation method
|
|
|
|
func removeAllAnimations() {
|
|
self.layer?.removeAllAnimations()
|
|
self.layer?.sublayers?.forEach {
|
|
$0.removeAllAnimations()
|
|
}
|
|
}
|
|
|
|
func animateColor(with state: NSControl.StateValue) {
|
|
self.removeAllAnimations()
|
|
|
|
let duration = state == .on ? self.onAnimateDuration : self.offAnimateDuration
|
|
let borderColor = state == .on ? self.borderHighlightColor : self.borderNormalColor
|
|
let backgroundColor = state == .on ? self.backgroundHighlightColor : self.backgroundNormalColor
|
|
let titleColor = state == .on ? self.titleHighlightColor : self.titleNormalColor
|
|
let imageColor = state == .on ? self.imageHighlightColor : self.imageNormalColor
|
|
|
|
self.animate(layer: self.layer, color: borderColor, keyPath: "borderColor", duration: duration)
|
|
self.animate(layer: self.layer, color: backgroundColor, keyPath: "backgroundColor", duration: duration)
|
|
self.animate(layer: self.imageLayer, color: imageColor, keyPath: "backgroundColor", duration: duration)
|
|
self.animate(layer: self.titleLayer, color: titleColor, keyPath: "foregroundColor", duration: duration)
|
|
}
|
|
|
|
func animate(layer: CALayer?, color: NSColor?, keyPath: String, duration: TimeInterval) {
|
|
|
|
guard let currentLayer = layer
|
|
, let newColor = color else {
|
|
return
|
|
}
|
|
|
|
// swiftlint:disable force_cast
|
|
guard let value = currentLayer.value(forKeyPath: keyPath) else { return }
|
|
let oldColor = value as! CGColor
|
|
// swiftlint:enable force_cast
|
|
|
|
guard oldColor != newColor.cgColor else { return }
|
|
|
|
let animation = CABasicAnimation(keyPath: keyPath)
|
|
animation.fromValue = currentLayer.value(forKeyPath: keyPath)
|
|
animation.toValue = newColor.cgColor
|
|
animation.duration = duration
|
|
animation.isRemovedOnCompletion = false
|
|
|
|
currentLayer.setValue(newColor.cgColor, forKey: keyPath)
|
|
currentLayer.add(animation, forKey: keyPath)
|
|
}
|
|
|
|
// MARK: - Event Response
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
return self.isEnabled
|
|
? super.hitTest(point)
|
|
: nil
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
guard self.isEnabled else { return }
|
|
self.mouseDown = true
|
|
self.state = self.state == .on ? .off : . on
|
|
}
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
guard self.mouseDown else { return }
|
|
self.state = self.state == .on ? .off : .on
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
guard self.mouseDown else { return }
|
|
self.mouseDown = false
|
|
self.state = self.state == .on ? .off : .on
|
|
}
|
|
|
|
override func mouseUp(with event: NSEvent) {
|
|
guard self.mouseDown else { return }
|
|
self.mouseDown = false
|
|
if self.momentary {
|
|
self.state = (self.state == .on) ? .off : .on
|
|
}
|
|
|
|
if let selector = self.action {
|
|
NSApp.sendAction(selector, to: self.target, from: self)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
override var frame: NSRect {
|
|
didSet {
|
|
self.setupTitleLayer()
|
|
}
|
|
}
|
|
|
|
override var font: NSFont? {
|
|
didSet {
|
|
self.setupTitleLayer()
|
|
}
|
|
}
|
|
|
|
override var title: String {
|
|
didSet {
|
|
self.setupTitleLayer()
|
|
}
|
|
}
|
|
|
|
override var image: NSImage? {
|
|
didSet {
|
|
self.setupImageLayer()
|
|
}
|
|
}
|
|
|
|
override var state: NSControl.StateValue {
|
|
didSet {
|
|
self.animateColor(with: self.state)
|
|
}
|
|
}
|
|
|
|
override var imagePosition: NSControl.ImagePosition {
|
|
didSet {
|
|
self.setupImageLayer()
|
|
self.setupTitleLayer()
|
|
}
|
|
}
|
|
|
|
}
|