467 lines
15 KiB
Swift
467 lines
15 KiB
Swift
//
|
|
// Switch.swift
|
|
// PrivadoVPN
|
|
//
|
|
// Created by Murad Shabanov on 02.08.2021.
|
|
// Copyright © 2021 Privado LLC. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
public typealias SwitchValueChange = (_ value: Bool) -> Void
|
|
open class BaseControl: NSControl {
|
|
|
|
// MARK: - Property
|
|
open var valueChange: SwitchValueChange?
|
|
|
|
open var isOn: Bool = false
|
|
}
|
|
|
|
public class Switch: BaseControl {
|
|
|
|
public override var wantsUpdateLayer: Bool {
|
|
return true
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
//// corner radius of thumbnail in case of square
|
|
//// It has no effect if shape is rounded
|
|
|
|
public var thumbCornerRadius: CGFloat = 0 {
|
|
didSet {
|
|
self.layoutThumbLayer(for: layer!.bounds)
|
|
}
|
|
}
|
|
|
|
public override var isOn: Bool {
|
|
didSet {
|
|
self.stateDidChange()
|
|
self.updateLayer()
|
|
}
|
|
}
|
|
|
|
/// Width of the border... it can have any `float` value
|
|
public var borderWidth: CGFloat = 0 {
|
|
didSet {
|
|
self.trackLayer.borderWidth = borderWidth
|
|
self.updateLayer()
|
|
}
|
|
}
|
|
|
|
|
|
public var borderColor: NSColor? {
|
|
didSet { self.setBorderColor() }
|
|
}
|
|
|
|
public var onBorderColor: NSColor = .white {
|
|
didSet { self.setBorderColor() }
|
|
}
|
|
public var offBorderColor: NSColor = .white {
|
|
didSet { self.setBorderColor() }
|
|
}
|
|
|
|
public var textColor: NSColor? {
|
|
didSet {
|
|
(self.offContentLayer as? CATextLayer)?.foregroundColor = self.textColor?.cgColor
|
|
(self.onContentLayer as? CATextLayer)?.foregroundColor = self.textColor?.cgColor
|
|
}
|
|
}
|
|
|
|
public var onTextColor: NSColor = .white {
|
|
didSet {
|
|
(self.onContentLayer as? CATextLayer)?.foregroundColor = self.onTextColor.cgColor
|
|
}
|
|
}
|
|
|
|
public var offTextColor: NSColor = .white {
|
|
didSet {
|
|
(self.offContentLayer as? CATextLayer)?.foregroundColor = self.offTextColor.cgColor
|
|
}
|
|
}
|
|
|
|
public var trackTopBottomPadding: CGFloat = 0 {
|
|
didSet {
|
|
self.updateLayer()
|
|
}
|
|
}
|
|
|
|
public var contentLeadingTrailingPadding: CGFloat = 0 {
|
|
didSet {
|
|
self.updateLayer()
|
|
}
|
|
}
|
|
|
|
//// Distance of `thumb` from track layer
|
|
public var thumbRadiusPadding: CGFloat = 0.5 {
|
|
didSet {
|
|
self.layoutThumbLayer(for: self.layer!.bounds)
|
|
}
|
|
}
|
|
|
|
public var onTintColor = NSColor(red: 0, green: 122/255, blue: 1, alpha: 1) {
|
|
didSet {
|
|
self.trackLayer.backgroundColor = self.getBackgroundColor()
|
|
self.needsDisplay = true
|
|
}
|
|
}
|
|
|
|
public var offTintColor: NSColor = .white {
|
|
didSet {
|
|
self.trackLayer.backgroundColor = self.getBackgroundColor()
|
|
self.needsDisplay = true
|
|
}
|
|
}
|
|
|
|
public var thumbTintColor: NSColor? {
|
|
didSet { self.setThumbColor() }
|
|
}
|
|
|
|
public var onThumbTintColor: NSColor = .white {
|
|
didSet { self.setThumbColor() }
|
|
}
|
|
|
|
public var offThumbTintColor: NSColor = .white {
|
|
didSet { self.setThumbColor() }
|
|
}
|
|
|
|
public var onText: String? {
|
|
didSet {
|
|
self.addOnTextLayerIfNeeded()
|
|
(self.onContentLayer as? CATextLayer)?.string = self.onText
|
|
}
|
|
}
|
|
|
|
public var offText: String? {
|
|
didSet {
|
|
self.addOffTextLayerIfNeeded()
|
|
(self.offContentLayer as? CATextLayer)?.string = self.offText
|
|
|
|
}
|
|
}
|
|
|
|
public var onThumbImage: NSImage? {
|
|
didSet {
|
|
self.thumbLayer.contents = self.onThumbImage?.cgImage
|
|
}
|
|
}
|
|
|
|
public var offThumbImage: NSImage? {
|
|
didSet {
|
|
self.thumbLayer.contents = self.offThumbImage?.cgImage
|
|
}
|
|
}
|
|
|
|
public var thumbImage: NSImage? {
|
|
didSet {
|
|
self.thumbLayer.contents = self.thumbImage?.cgImage
|
|
}
|
|
}
|
|
|
|
|
|
public var onImage: NSImage? {
|
|
didSet {
|
|
self.addOnImageLayerIfNeeded()
|
|
self.onContentLayer?.contents = self.onImage?.cgImage
|
|
|
|
}
|
|
}
|
|
|
|
public var offImage: NSImage? {
|
|
didSet {
|
|
self.addOffImageLayerIfNeeded()
|
|
self.offContentLayer?.contents = self.offImage?.cgImage
|
|
}
|
|
}
|
|
|
|
public override var intrinsicContentSize: CGSize {
|
|
return CGSize(width: 52, height: 31)
|
|
}
|
|
|
|
// MARK: - Layers
|
|
//// `trackLayer`:- is main track layer
|
|
//// `innerLayer`:- over track layer
|
|
//// `thumLayer` :- it is used for thumb
|
|
|
|
fileprivate lazy var trackLayer = CALayer()
|
|
fileprivate lazy var innerLayer = CALayer()
|
|
fileprivate lazy var thumbLayer: CALayer = {
|
|
let layer = CALayer()
|
|
layer.shadowColor = NSColor.black.cgColor
|
|
layer.shadowRadius = 2
|
|
layer.shadowOpacity = 0.4
|
|
layer.shadowOffset = CGSize(width: 0.75, height: 2)
|
|
layer.contentsGravity = .resizeAspect
|
|
return layer
|
|
}()
|
|
private lazy var contentsLayer = CALayer()
|
|
|
|
private var onContentLayer: CALayer? {
|
|
willSet {
|
|
self.onContentLayer?.removeFromSuperlayer()
|
|
}
|
|
didSet {
|
|
self.layoutOnContentLayer(for: self.layer!.bounds)
|
|
}
|
|
}
|
|
|
|
private var offContentLayer: CALayer? {
|
|
willSet {
|
|
self.offContentLayer?.removeFromSuperlayer()
|
|
}
|
|
didSet {
|
|
self.layoutOffContentLayer(for: layer!.bounds)
|
|
}
|
|
}
|
|
|
|
// MARK: - Initializers
|
|
|
|
convenience init() {
|
|
self.init(frame: .zero)
|
|
self.frame.size = intrinsicContentSize
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
self.controlDidLoad()
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
super.init(coder: aDecoder)
|
|
self.controlDidLoad()
|
|
}
|
|
|
|
// MARK: - Common Init
|
|
|
|
fileprivate func controlDidLoad() {
|
|
wantsLayer = true
|
|
layer!.shadowOffset = .zero
|
|
layer!.shadowOpacity = 0.3
|
|
layer!.shadowRadius = 5
|
|
|
|
layer!.backgroundColor = .clear
|
|
|
|
layer!.addSublayer(self.trackLayer)
|
|
layer!.addSublayer(self.innerLayer)
|
|
layer!.addSublayer(self.contentsLayer)
|
|
layer!.addSublayer(self.thumbLayer)
|
|
|
|
self.trackLayer.backgroundColor = self.getBackgroundColor()
|
|
self.setBorderColor()
|
|
self.trackLayer.borderWidth = self.borderWidth
|
|
|
|
self.innerLayer.backgroundColor = NSColor.white.cgColor
|
|
|
|
self.contentsLayer.masksToBounds = true
|
|
|
|
self.setThumbColor()
|
|
|
|
self.updateLayer()
|
|
}
|
|
|
|
// MARK: - layoutSubviews
|
|
|
|
public override func updateLayer() {
|
|
super.updateLayer()
|
|
self.layoutTrackLayer(for: self.layer!.bounds)
|
|
self.layoutThumbLayer(for: self.layer!.bounds)
|
|
self.contentsLayer.frame = self.layer!.bounds
|
|
self.layoutOffContentLayer(for: self.layer!.bounds)
|
|
self.layoutOnContentLayer(for: self.layer!.bounds)
|
|
}
|
|
|
|
fileprivate func layoutTrackLayer(for bounds: CGRect) {
|
|
self.trackLayer.frame = bounds.insetBy(dx: trackTopBottomPadding, dy: trackTopBottomPadding)
|
|
(self.trackLayer.cornerRadius = self.trackLayer.bounds.height / 2)
|
|
}
|
|
|
|
fileprivate func layoutInnerLayer(for bounds: CGRect) {
|
|
let inset = self.borderWidth + self.trackTopBottomPadding
|
|
let isInnerHidden = self.isOn
|
|
|
|
self.innerLayer.frame = isInnerHidden
|
|
? CGRect(origin: self.trackLayer.position, size: .zero)
|
|
: bounds.insetBy(dx: inset, dy: inset)
|
|
|
|
self.innerLayer.cornerRadius = isInnerHidden ? 0 : bounds.height / 2 - inset
|
|
}
|
|
|
|
fileprivate func layoutThumbLayer(for bounds: CGRect) {
|
|
let size = getThumbSize()
|
|
let origin = getThumbOrigin(for: size.width)
|
|
self.thumbLayer.frame = CGRect(origin: origin, size: size)
|
|
|
|
if let thumb = thumbImage {
|
|
self.onThumbImage = thumb
|
|
self.offThumbImage = thumb
|
|
}
|
|
|
|
self.thumbLayer.contents = self.isOn ? self.onThumbImage?.cgImage : self.offThumbImage?.cgImage
|
|
|
|
self.thumbLayer.cornerRadius = size.height / 2
|
|
}
|
|
|
|
private func layoutOffContentLayer(for bounds: CGRect) {
|
|
|
|
let size = self.getContentLayerSize(for: self.offContentLayer)
|
|
let y = bounds.midY - size.height / 2
|
|
let leading = (bounds.maxX - (self.contentLeadingTrailingPadding + self.borderWidth + self.getThumbSize().width))/2 - size.width/2
|
|
let x = !self.isOn ? bounds.width - size.width - leading : bounds.width
|
|
let origin = CGPoint(x: x, y: y)
|
|
self.offContentLayer?.frame = CGRect(origin: origin, size: size)
|
|
bounds.height < 50 ? ((self.offContentLayer as? CATextLayer)?.fontSize = 12) : ((self.offContentLayer as? CATextLayer)?.fontSize = bounds.height * 0.2)
|
|
|
|
}
|
|
|
|
private func layoutOnContentLayer(for bounds: CGRect) {
|
|
let size = self.getContentLayerSize(for: self.onContentLayer)
|
|
let y = bounds.midY - size.height / 2
|
|
let leading = (bounds.maxX - (self.contentLeadingTrailingPadding + self.borderWidth + self.getThumbSize().width))/2 - size.width/2
|
|
let x = self.isOn ? leading : -bounds.width / 2
|
|
let origin = CGPoint(x: x, y: y)
|
|
self.onContentLayer?.frame = CGRect(origin: origin, size: size)
|
|
self.onContentLayer?.contentsCenter = CGRect(origin: origin, size: size)
|
|
bounds.height < 50 ? ((self.onContentLayer as? CATextLayer)?.fontSize = 12) : ((self.onContentLayer as? CATextLayer)?.fontSize = bounds.height * 0.2)
|
|
}
|
|
|
|
fileprivate func stateDidChange() {
|
|
self.trackLayer.backgroundColor = self.getBackgroundColor()
|
|
self.trackLayer.borderWidth = self.borderWidth
|
|
self.thumbLayer.contents = self.isOn ? self.onThumbImage?.cgImage : self.offThumbImage?.cgImage
|
|
self.setThumbColor()
|
|
self.valueChange?(isOn)
|
|
}
|
|
|
|
public func setOn(_ on: Bool, animated: Bool) {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(!animated)
|
|
self.isOn = on
|
|
self.updateLayer()
|
|
self.valueChange?(isOn)
|
|
if let selector = self.action {
|
|
NSApp.sendAction(selector, to: self.target, from: self)
|
|
}
|
|
CATransaction.commit()
|
|
}
|
|
|
|
public override func mouseDown(with event: NSEvent) {
|
|
super.mouseDown(with: event)
|
|
self.mouseDown()
|
|
}
|
|
|
|
fileprivate func mouseDown() {
|
|
self.isOn.toggle()
|
|
self.stateDidChange()
|
|
if let selector = self.action {
|
|
NSApp.sendAction(selector, to: self.target, from: self)
|
|
}
|
|
self.updateLayer()
|
|
}
|
|
|
|
// MARK: - Layout Helper
|
|
fileprivate func setBorderColor() {
|
|
if let borderClor = self.borderColor {
|
|
self.trackLayer.borderColor = borderClor.cgColor
|
|
} else {
|
|
self.trackLayer.borderColor = (self.isOn ? self.onBorderColor : self.offBorderColor).cgColor
|
|
}
|
|
}
|
|
|
|
private func setThumbColor() {
|
|
if let thumbColor = self.thumbTintColor {
|
|
self.thumbLayer.backgroundColor = thumbColor.cgColor
|
|
} else {
|
|
self.thumbLayer.backgroundColor = (self.isOn ? self.onThumbTintColor : self.offThumbTintColor).cgColor
|
|
}
|
|
}
|
|
|
|
final func getBackgroundColor() -> CGColor {
|
|
return (self.isOn ? self.onTintColor : self.offTintColor).cgColor
|
|
}
|
|
|
|
fileprivate func getThumbSize() -> CGSize {
|
|
let height = bounds.height - 2 * (self.borderWidth + self.thumbRadiusPadding)
|
|
let width = height
|
|
return CGSize(width: width, height: height)
|
|
}
|
|
|
|
final func getThumbOrigin(for width: CGFloat) -> CGPoint {
|
|
let inset = self.borderWidth + self.thumbRadiusPadding
|
|
let x = self.isOn ? bounds.width - width - inset : inset
|
|
return CGPoint(x: x, y: inset)
|
|
}
|
|
|
|
final func getContentLayerSize(for layer: CALayer?) -> CGSize {
|
|
let inset = 2 * (self.borderWidth + self.trackTopBottomPadding)
|
|
let diameter = bounds.height - inset - self.getThumbSize().height/2
|
|
if let textLayer = layer as? CATextLayer {
|
|
return textLayer.preferredFrameSize()
|
|
}
|
|
return CGSize(width: diameter, height: diameter)
|
|
}
|
|
|
|
// MARK: - Content Layers
|
|
private func addOffTextLayerIfNeeded() {
|
|
guard self.offText != nil else {
|
|
self.offContentLayer = nil
|
|
return
|
|
}
|
|
let textLayer = CATextLayer()
|
|
textLayer.alignmentMode = .center
|
|
textLayer.fontSize = bounds.height * 0.2
|
|
textLayer.font = NSFont.systemFont(ofSize: 10, weight: .bold)
|
|
|
|
textLayer.foregroundColor = (textColor == nil) ? offTextColor.cgColor : textColor?.cgColor
|
|
if let backingScale = NSScreen.main?.backingScaleFactor {
|
|
textLayer.contentsScale = backingScale
|
|
}
|
|
self.contentsLayer.addSublayer(textLayer)
|
|
self.offContentLayer = textLayer
|
|
}
|
|
|
|
private func addOnTextLayerIfNeeded() {
|
|
guard self.onText != nil else {
|
|
self.onContentLayer = nil
|
|
return
|
|
}
|
|
let textLayer = CATextLayer()
|
|
textLayer.alignmentMode = .center
|
|
textLayer.fontSize = bounds.height * 0.2
|
|
textLayer.font = NSFont.systemFont(ofSize: 10, weight: .bold)
|
|
textLayer.foregroundColor = (self.textColor == nil) ? self.onTextColor.cgColor : self.textColor?.cgColor
|
|
if let backingScale = NSScreen.main?.backingScaleFactor {
|
|
textLayer.contentsScale = backingScale
|
|
}
|
|
self.contentsLayer.addSublayer(textLayer)
|
|
self.onContentLayer = textLayer
|
|
}
|
|
|
|
private func addOnImageLayerIfNeeded() {
|
|
guard self.onImage != nil else {
|
|
self.onContentLayer = nil
|
|
return
|
|
}
|
|
let imageLayer = CALayer()
|
|
imageLayer.contentsGravity = .center
|
|
if let backingScale = NSScreen.main?.backingScaleFactor {
|
|
imageLayer.contentsScale = backingScale
|
|
}
|
|
self.contentsLayer.addSublayer(imageLayer)
|
|
self.onContentLayer = imageLayer
|
|
}
|
|
|
|
private func addOffImageLayerIfNeeded() {
|
|
guard self.offImage != nil else {
|
|
self.offContentLayer = nil
|
|
return
|
|
}
|
|
let imageLayer = CALayer()
|
|
imageLayer.contentsGravity = .resizeAspect
|
|
if let backingScale = NSScreen.main?.backingScaleFactor {
|
|
imageLayer.contentsScale = backingScale
|
|
}
|
|
self.contentsLayer.addSublayer(imageLayer)
|
|
self.offContentLayer = imageLayer
|
|
}
|
|
}
|