321 lines
13 KiB
Swift
321 lines
13 KiB
Swift
//
|
|
// CityServerCell.swift
|
|
// PrivadoVPN
|
|
//
|
|
// Created by Juraldinio on 3/15/21.
|
|
// Copyright © 2021 Privado LLC. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
protocol CityServerDelegate: AnyObject {
|
|
func location(isFavourite: Bool, for view: NSView)
|
|
}
|
|
|
|
final class CityServerCell: View {
|
|
|
|
// swiftlint:disable nesting
|
|
enum Constants {
|
|
|
|
static let height: CGFloat = 41
|
|
|
|
enum Images {
|
|
static let location = "pinLocation"
|
|
static let latencyLow = "Latency-low"
|
|
static let latencyMedium = "Latency-medium"
|
|
static let latencyHigh = "Latency-high"
|
|
//
|
|
static let pinLocation = "pinLocation"
|
|
static let pinLocationHover = "pinLocationHover"
|
|
//
|
|
static let favouriteFull = "favourite.full"
|
|
static let favouriteEmpty = "favourite.empty"
|
|
//
|
|
static let premium = "icon.premium"
|
|
}
|
|
|
|
enum Geometry {
|
|
static let premiumLeading: CGFloat = 17
|
|
static let premiumSize = CGSize(width: 14, height: 12)
|
|
|
|
static let locationLeading: CGFloat = 57
|
|
static let locationSize = CGSize(width: 13, height: 18)
|
|
|
|
static let locationBetweenTitle: CGFloat = 23
|
|
static let titleHeight: CGFloat = 20
|
|
|
|
static let titleBetweenLatency: CGFloat = 23
|
|
static let latencySize = CGSize(width: 15, height: 20)
|
|
static let latencyTrailing: CGFloat = 58
|
|
|
|
static let bottomLineHeight: CGFloat = 1.0
|
|
|
|
static let favouriteSize = CGSize(width: 18, height: 18)
|
|
}
|
|
|
|
enum Font {
|
|
enum Name {
|
|
static let title = PrivadoConstants.Font.medium
|
|
}
|
|
|
|
enum Size {
|
|
static let title: CGFloat = 13.0
|
|
}
|
|
}
|
|
|
|
enum Color {
|
|
static let title = NSColor(red: 218, green: 219, blue: 243)
|
|
static let bottomLine = NSColor(red: 25, green: 33, blue: 71)
|
|
//
|
|
static let hoverTitle = NSColor.white
|
|
static let hoverBackground = NSColor(red: 64, green: 71, blue: 110)
|
|
}
|
|
|
|
}
|
|
// swiftlint:enable nesting
|
|
|
|
private let locationImage = ImageView()
|
|
private let titleField = DynamicTextField(frame: .zero)
|
|
private let latencyImage = ImageView()
|
|
private let favouriteImage = ImageView()
|
|
private let premiumImage = ImageView()
|
|
private let bottomLine = NSView()
|
|
private var isFavourite: Bool
|
|
private var isLast: Bool
|
|
private var backgroundLayer = CAShapeLayer()
|
|
private var backgroundColor: NSColor?
|
|
private var setupStrokeBackground: Bool
|
|
private weak var delegate: CityServerDelegate?
|
|
|
|
// MARK: - Initializers
|
|
|
|
init(city: String, latency: ServerListCityRecord.LatencyType, isFavourite: Bool, setupStrokeBackground: Bool, showPremiumIcon: Bool, delegate: CityServerDelegate?, isLast: Bool, output: ServerCellOutput) {
|
|
self.isFavourite = isFavourite
|
|
self.isLast = isLast
|
|
self.setupStrokeBackground = setupStrokeBackground
|
|
super.init(frame: .zero)
|
|
output.scrollEmitter.addReaction { [weak self] isScrolling in
|
|
if isScrolling {
|
|
self?.backgroundLayer.fillColor = NSColor.clear.cgColor
|
|
self?.backgroundLayer.strokeColor = NSColor.clear.cgColor
|
|
}
|
|
return self.isExist
|
|
}
|
|
self.delegate = delegate
|
|
self.isHoverEnabled = true
|
|
self.useHandCursor = true
|
|
self.backgroundColor = showPremiumIcon && !isFavourite ? NSColor(red: 69, green: 77, blue: 181) : .clear
|
|
self.viewBackgroundColor = self.backgroundColor
|
|
self.wantsLayer = true
|
|
self.layer?.masksToBounds = false
|
|
self.layer?.addSublayer(self.strokeLayer)
|
|
self.layer?.addSublayer(self.backgroundLayer)
|
|
self.configure(city: city, latency: latency, isFavourite: isFavourite, showPremiumIcon: showPremiumIcon)
|
|
}
|
|
|
|
// MARK: - LifeCycle
|
|
|
|
override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
|
|
for trackingArea in self.trackingAreas {
|
|
self.removeTrackingArea(trackingArea)
|
|
}
|
|
|
|
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
|
|
let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
|
self.addTrackingArea(trackingArea)
|
|
}
|
|
|
|
// override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
|
|
override func onMouseHover(_ hover: Bool) {
|
|
self.backgroundLayer.fillColor = hover ? NSColor(red: 43, green: 50, blue: 137).cgColor : NSColor.clear.cgColor
|
|
self.backgroundLayer.strokeColor = hover ? NSColor(red: 105, green: 112, blue: 208).cgColor : NSColor.clear.cgColor
|
|
}
|
|
|
|
var strokeLayer: CAShapeLayer = CAShapeLayer()
|
|
|
|
var cornerRadius: CGFloat = 6
|
|
|
|
func setupStrokeLayer() {
|
|
|
|
let path = NSBezierPath()
|
|
|
|
let rect = CGRect(x: self.bounds.minX + 5, y: self.bounds.minY, width: self.bounds.width - 10, height: self.bounds.height)
|
|
|
|
let topRight = NSPoint(x: rect.maxX, y: rect.maxY)
|
|
|
|
let topLeft = NSPoint(x: rect.minX, y: rect.maxY)
|
|
|
|
let bottomLeft = NSPoint(x: rect.minX, y: rect.minY)
|
|
|
|
let bottomRight = NSPoint(x: rect.maxX, y: rect.minY)
|
|
|
|
path.move(to: bottomLeft)
|
|
path.line(to: topLeft)
|
|
path.line(to: topRight)
|
|
path.line(to: bottomRight)
|
|
path.close()
|
|
|
|
self.strokeLayer.path = path.CGPath
|
|
self.strokeLayer.lineWidth = 1
|
|
self.strokeLayer.strokeColor = .clear
|
|
self.strokeLayer.fillRule = .evenOdd
|
|
self.strokeLayer.fillColor = NSColor(red: 58, green: 66, blue: 183).cgColor
|
|
}
|
|
|
|
func setupCurvedStrokeLayer() {
|
|
|
|
let path = NSBezierPath()
|
|
|
|
let rect = CGRect(x: self.bounds.minX + 5, y: self.bounds.minY + 5, width: self.bounds.width - 10, height: self.bounds.height - 5)
|
|
|
|
let topRight = NSPoint(x: rect.maxX, y: rect.maxY)
|
|
|
|
let topLeft = NSPoint(x: rect.minX, y: rect.maxY)
|
|
|
|
let bottomLeft = NSPoint(x: rect.minX, y: rect.minY)
|
|
|
|
let bottomRight = NSPoint(x: rect.maxX, y: rect.minY)
|
|
|
|
path.move(to: topRight)
|
|
|
|
path.line(to: CGPoint(x: rect.maxX, y: rect.minY + cornerRadius))
|
|
|
|
path.appendArc(from: bottomRight, to: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY), radius: cornerRadius)
|
|
|
|
path.line(to: CGPoint(x: rect.minX + cornerRadius, y: rect.minY))
|
|
|
|
path.appendArc(from: bottomLeft, to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius), radius: cornerRadius)
|
|
|
|
path.line(to: topLeft)
|
|
|
|
path.close()
|
|
self.strokeLayer.path = path.CGPath
|
|
self.strokeLayer.lineWidth = 1
|
|
self.strokeLayer.strokeColor = .clear
|
|
self.strokeLayer.fillRule = .evenOdd
|
|
self.strokeLayer.fillColor = NSColor(red: 58, green: 66, blue: 183).cgColor
|
|
}
|
|
|
|
func setupBackgroundLayer() {
|
|
let rect = CGRect(x: self.bounds.minX + 10, y: self.bounds.minY + 7, width: self.bounds.width - 15, height: self.bounds.height - 10)
|
|
let path = NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6)
|
|
self.backgroundLayer.path = path.CGPath
|
|
self.backgroundLayer.lineWidth = 1
|
|
self.backgroundLayer.strokeColor = .clear
|
|
self.backgroundLayer.fillRule = .evenOdd
|
|
self.backgroundLayer.fillColor = NSColor.clear.cgColor
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
self.setupBackgroundLayer()
|
|
guard self.setupStrokeBackground else { return }
|
|
if self.isLast {
|
|
self.setupCurvedStrokeLayer()
|
|
} else {
|
|
self.setupStrokeLayer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Lifecycle View
|
|
|
|
override func addSubviews() {
|
|
self.addSubview(self.locationImage)
|
|
self.addSubview(self.titleField)
|
|
self.addSubview(self.latencyImage)
|
|
self.addSubview(self.bottomLine)
|
|
self.addSubview(self.favouriteImage)
|
|
self.addSubview(self.premiumImage)
|
|
|
|
// titleField
|
|
self.titleField.translatesAutoresizingMaskIntoConstraints = false
|
|
self.titleField.isEditable = false
|
|
self.titleField.isSelectable = false
|
|
self.titleField.usesSingleLineMode = true
|
|
self.titleField.isBezeled = false
|
|
self.titleField.drawsBackground = false
|
|
self.titleField.focusRingType = .none
|
|
self.titleField.textColor = Constants.Color.title
|
|
self.titleField.font = NSFont(name: Constants.Font.Name.title, size: Constants.Font.Size.title)
|
|
self.titleField.fontName = Constants.Font.Name.title
|
|
|
|
self.bottomLine.translatesAutoresizingMaskIntoConstraints = false
|
|
self.favouriteImage.translatesAutoresizingMaskIntoConstraints = false
|
|
self.favouriteImage.useHandCursor = true
|
|
self.favouriteImage.target = self
|
|
self.favouriteImage.action = #selector(self.favouriteImageTapped)
|
|
|
|
self.premiumImage.translatesAutoresizingMaskIntoConstraints = false
|
|
self.premiumImage.image = NSImage(named: Constants.Images.premium)
|
|
self.premiumImage.isHidden = true
|
|
}
|
|
|
|
@objc
|
|
private func favouriteImageTapped() {
|
|
self.delegate?.location(isFavourite: !self.isFavourite, for: self)
|
|
self.isFavourite = !self.isFavourite
|
|
self.favouriteImage.image = self.isFavourite ? NSImage(named: Constants.Images.favouriteFull) : NSImage(named: Constants.Images.favouriteEmpty)
|
|
}
|
|
|
|
override func addConstraints() {
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
|
self.premiumImage.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: Constants.Geometry.premiumLeading),
|
|
self.premiumImage.centerYAnchor.constraint(equalTo: self.centerYAnchor),
|
|
self.premiumImage.widthAnchor.constraint(equalToConstant: Constants.Geometry.premiumSize.width),
|
|
self.premiumImage.heightAnchor.constraint(equalToConstant: Constants.Geometry.premiumSize.height),
|
|
//
|
|
self.locationImage.widthAnchor.constraint(equalToConstant: Constants.Geometry.locationSize.width),
|
|
self.locationImage.heightAnchor.constraint(equalToConstant: Constants.Geometry.locationSize.height),
|
|
self.locationImage.centerYAnchor.constraint(equalTo: self.centerYAnchor),
|
|
self.locationImage.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: Constants.Geometry.locationLeading),
|
|
//
|
|
self.titleField.leadingAnchor.constraint(equalTo: self.locationImage.trailingAnchor, constant: Constants.Geometry.locationBetweenTitle),
|
|
self.titleField.centerYAnchor.constraint(equalTo: self.centerYAnchor),
|
|
self.titleField.heightAnchor.constraint(equalToConstant: Constants.Geometry.titleHeight),
|
|
//
|
|
self.latencyImage.widthAnchor.constraint(equalToConstant: Constants.Geometry.latencySize.width),
|
|
self.latencyImage.heightAnchor.constraint(equalToConstant: Constants.Geometry.latencySize.height),
|
|
self.latencyImage.leadingAnchor.constraint(greaterThanOrEqualTo: self.titleField.trailingAnchor,
|
|
constant: Constants.Geometry.titleBetweenLatency),
|
|
self.latencyImage.centerYAnchor.constraint(equalTo: self.centerYAnchor),
|
|
self.latencyImage.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -Constants.Geometry.latencyTrailing),
|
|
//
|
|
self.favouriteImage.widthAnchor.constraint(equalToConstant: Constants.Geometry.favouriteSize.width),
|
|
self.favouriteImage.heightAnchor.constraint(equalToConstant: Constants.Geometry.favouriteSize.height),
|
|
self.favouriteImage.centerYAnchor.constraint(equalTo: self.centerYAnchor),
|
|
self.favouriteImage.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -17),
|
|
//
|
|
self.bottomLine.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
self.bottomLine.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
self.bottomLine.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
self.bottomLine.heightAnchor.constraint(equalToConstant: Constants.Geometry.bottomLineHeight)
|
|
])
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func configure(city: String, latency: ServerListCityRecord.LatencyType, isFavourite: Bool, showPremiumIcon: Bool) {
|
|
|
|
self.locationImage.image = NSImage(named: Constants.Images.pinLocation)
|
|
self.titleField.stringValue = city
|
|
|
|
let imageName: String
|
|
switch latency {
|
|
case .low: imageName = Constants.Images.latencyLow
|
|
case .medium: imageName = Constants.Images.latencyMedium
|
|
case .high: imageName = Constants.Images.latencyHigh
|
|
}
|
|
|
|
self.latencyImage.image = NSImage(named: imageName)
|
|
self.favouriteImage.image = isFavourite ? NSImage(named: Constants.Images.favouriteFull) : NSImage(named: Constants.Images.favouriteEmpty)
|
|
|
|
self.premiumImage.isHidden = !showPremiumIcon
|
|
}
|
|
|
|
}
|