335 lines
13 KiB
Swift
335 lines
13 KiB
Swift
//
|
|
// PreferencesViewController.swift
|
|
// PrivadoVPN
|
|
//
|
|
// Created by Murad Shabanov on 20.07.2021.
|
|
// Copyright © 2021 Privado LLC. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import AppKit
|
|
|
|
protocol PreferencesViewInput: AnyObject {}
|
|
|
|
protocol PreferencesViewOutput: AnyObject {}
|
|
|
|
final class PreferencesViewController: NSViewController, PreferencesViewInput {
|
|
|
|
typealias HeaderCreationClosure = () -> NSViewController
|
|
|
|
enum Constants {
|
|
static let columnIdentifier = NSUserInterfaceItemIdentifier("PreferencesSegmentList")
|
|
static let cellIdentifier = NSUserInterfaceItemIdentifier("PreferencesSegmentCell")
|
|
}
|
|
|
|
var preferencePanes = [PreferencePane]() {
|
|
didSet {
|
|
self.segmentView.reloadData()
|
|
}
|
|
}
|
|
|
|
private var headerCreationClosure: HeaderCreationClosure
|
|
private var output: PreferencesViewOutput?
|
|
|
|
private var activeTabIndex: Int?
|
|
|
|
private let titleText: NSTextField
|
|
|
|
private let segmentView: NSTableView
|
|
|
|
private let controllerView: NSView
|
|
|
|
private var header: NSViewController?
|
|
|
|
private let line: View
|
|
|
|
// MARK: - JSNavigationBarViewControllerProvider
|
|
|
|
var navigationController: JSNavigationController?
|
|
|
|
init(output: PreferencesViewOutput, headerCreation: @escaping HeaderCreationClosure) {
|
|
self.titleText = NSTextField(frame: .zero)
|
|
self.segmentView = NSTableView()
|
|
self.controllerView = NSView(frame: .zero)
|
|
self.output = output
|
|
self.headerCreationClosure = headerCreation
|
|
self.line = View(frame: .zero)
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func loadView() {
|
|
self.view = BottomView(backgroundColor: NSColor(red: 0, green: 8, blue: 44))
|
|
self.view.wantsLayer = true
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
self.configureUI()
|
|
self.bind()
|
|
self.layoutView()
|
|
}
|
|
|
|
override func viewDidAppear() {
|
|
if let superview = self.view.superview {
|
|
NSLayoutConstraint.activate([
|
|
self.view.topAnchor.constraint(equalTo: superview.topAnchor),
|
|
self.view.leadingAnchor.constraint(equalTo: superview.leadingAnchor),
|
|
self.view.trailingAnchor.constraint(equalTo: superview.trailingAnchor),
|
|
self.view.bottomAnchor.constraint(equalTo: superview.bottomAnchor)
|
|
])
|
|
}
|
|
}
|
|
|
|
private func configureUI() {
|
|
|
|
self.titleText.translatesAutoresizingMaskIntoConstraints = false
|
|
self.titleText.stringValue = NSLocalizedString("preference.identifier.preferences", comment: "")
|
|
self.titleText.backgroundColor = .clear
|
|
self.titleText.isBezeled = false
|
|
self.titleText.isEditable = false
|
|
self.titleText.sizeToFit()
|
|
self.titleText.textColor = .white
|
|
self.titleText.font = NSFont(name: PrivadoConstants.Font.light, size: 13.5)
|
|
|
|
self.segmentView.translatesAutoresizingMaskIntoConstraints = false
|
|
self.segmentView.backgroundColor = .clear
|
|
self.segmentView.selectionHighlightStyle = .none
|
|
self.segmentView.focusRingType = .none
|
|
self.segmentView.rowSizeStyle = .custom
|
|
if #available(OSX 11.0, *) {
|
|
self.segmentView.style = .fullWidth
|
|
}
|
|
self.segmentView.cornerView = NSView()
|
|
|
|
self.controllerView.translatesAutoresizingMaskIntoConstraints = false
|
|
self.controllerView.wantsLayer = true
|
|
|
|
self.line.translatesAutoresizingMaskIntoConstraints = false
|
|
self.line.viewBackgroundColor = NSColor(red: 21, green: 25, blue: 53)
|
|
}
|
|
|
|
private func bind() {
|
|
self.segmentView.delegate = self
|
|
self.segmentView.dataSource = self
|
|
|
|
let column = NSTableColumn(identifier: Constants.columnIdentifier)
|
|
column.width = 160
|
|
column.isEditable = false
|
|
self.segmentView.addTableColumn(column)
|
|
|
|
self.view.addSubview(self.segmentView)
|
|
self.view.addSubview(self.controllerView)
|
|
self.view.addSubview(self.line)
|
|
}
|
|
|
|
private func layoutView() {
|
|
NSLayoutConstraint.activate([
|
|
|
|
self.segmentView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
|
self.segmentView.topAnchor.constraint(equalTo: self.view.topAnchor),
|
|
self.segmentView.widthAnchor.constraint(equalToConstant: 160),
|
|
//
|
|
self.line.widthAnchor.constraint(equalToConstant: 1),
|
|
self.line.heightAnchor.constraint(equalToConstant: 484),
|
|
self.line.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 20),
|
|
self.line.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -20),
|
|
self.line.leadingAnchor.constraint(equalTo: self.segmentView.trailingAnchor),
|
|
//
|
|
self.controllerView.topAnchor.constraint(equalTo: self.view.topAnchor),
|
|
self.controllerView.leadingAnchor.constraint(equalTo: self.line.trailingAnchor),
|
|
self.controllerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
self.controllerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
])
|
|
}
|
|
|
|
private func preferencePaneLabels() -> [String] {
|
|
var labels = [String]()
|
|
self.preferencePanes.forEach { labels.append($0.preferencePaneIdentifier.rawValue) }
|
|
return labels
|
|
}
|
|
|
|
func activateTab(preferenceIdentifier: PreferencePane.Identifier, animated: Bool) {
|
|
guard let index = (self.preferencePanes.firstIndex { $0.preferencePaneIdentifier == preferenceIdentifier }) else {
|
|
return self.activateTab(index: 0, animated: animated)
|
|
}
|
|
self.animtionTabTranstition(index: index)
|
|
}
|
|
|
|
func activateTab(index: Int, animated: Bool) {
|
|
defer {
|
|
self.activeTabIndex = index
|
|
}
|
|
|
|
if self.activeTabIndex.isExist {
|
|
guard index != self.activeTabIndex else {
|
|
return
|
|
}
|
|
self.animtionTabTranstition(index: index)
|
|
} else {
|
|
self.immediatelyDisplayTab(index: index)
|
|
}
|
|
}
|
|
|
|
func restoreInitialTab() {
|
|
if !self.activeTabIndex.isExist {
|
|
self.activateTab(index: 0, animated: false)
|
|
}
|
|
}
|
|
|
|
private func animtionTabTranstition(index: Int) {
|
|
|
|
guard let activeTab = self.activeTabIndex else {
|
|
assertionFailure("animateTabTransition called before a tab was displayed; transition only works from one tab to another")
|
|
self.immediatelyDisplayTab(index: index)
|
|
return
|
|
}
|
|
|
|
let down = index > activeTab
|
|
let fromViewController = self.preferencePanes[activeTab]
|
|
let toViewController = self.preferencePanes[index]
|
|
let viewBounds = self.controllerView.bounds
|
|
let height = viewBounds.height
|
|
let duration = 0.3
|
|
|
|
fromViewController.view.wantsLayer = true
|
|
toViewController.view.wantsLayer = true
|
|
|
|
let fadeInAnimation = CABasicAnimation(keyPath: "opacity")
|
|
fadeInAnimation.fromValue = 0.0
|
|
fadeInAnimation.toValue = 1.0
|
|
fadeInAnimation.duration = duration
|
|
fadeInAnimation.timingFunction = .easeInEaseOut
|
|
fadeInAnimation.fillMode = .forwards
|
|
fadeInAnimation.isRemovedOnCompletion = false
|
|
|
|
let fadeOutAnimation = CABasicAnimation(keyPath: "opacity")
|
|
fadeOutAnimation.fromValue = 1.0
|
|
fadeOutAnimation.toValue = 0.0
|
|
fadeOutAnimation.duration = duration
|
|
fadeOutAnimation.timingFunction = .easeInEaseOut
|
|
fadeOutAnimation.fillMode = .forwards
|
|
fadeOutAnimation.isRemovedOnCompletion = false
|
|
|
|
let slideFromBottomAnimation = CABasicAnimation(keyPath: "position.y")
|
|
slideFromBottomAnimation.fromValue = -height
|
|
slideFromBottomAnimation.toValue = 0
|
|
slideFromBottomAnimation.duration = duration
|
|
slideFromBottomAnimation.timingFunction = .easeInEaseOut
|
|
slideFromBottomAnimation.fillMode = .forwards
|
|
slideFromBottomAnimation.isRemovedOnCompletion = false
|
|
|
|
let slideFromTopToBottom = CABasicAnimation(keyPath: "position.y")
|
|
slideFromTopToBottom.fromValue = height
|
|
slideFromTopToBottom.toValue = 0
|
|
slideFromTopToBottom.duration = duration
|
|
slideFromTopToBottom.timingFunction = .easeInEaseOut
|
|
slideFromTopToBottom.fillMode = .forwards
|
|
slideFromTopToBottom.isRemovedOnCompletion = false
|
|
|
|
let slideToBottomAnimation = CABasicAnimation(keyPath: "position.y")
|
|
slideToBottomAnimation.fromValue = 0
|
|
slideToBottomAnimation.toValue = -height
|
|
slideToBottomAnimation.duration = duration
|
|
slideToBottomAnimation.timingFunction = .easeInEaseOut
|
|
slideToBottomAnimation.fillMode = .forwards
|
|
slideToBottomAnimation.isRemovedOnCompletion = false
|
|
|
|
let slideToTopAnimation = CABasicAnimation(keyPath: "position.y")
|
|
slideToTopAnimation.fromValue = 0
|
|
slideToTopAnimation.toValue = height
|
|
slideToTopAnimation.duration = duration
|
|
slideToTopAnimation.timingFunction = .easeInEaseOut
|
|
slideToTopAnimation.fillMode = .forwards
|
|
slideToTopAnimation.isRemovedOnCompletion = false
|
|
|
|
self.controllerView.addSubview(toViewController.view, positioned: .above, relativeTo: fromViewController.view)
|
|
|
|
CATransaction.begin()
|
|
CATransaction.setCompletionBlock {
|
|
fromViewController.view.removeFromSuperview()
|
|
fromViewController.view.layer?.removeAllAnimations()
|
|
}
|
|
fromViewController.view.layer?.add(fadeOutAnimation, forKey: nil)
|
|
toViewController.view.layer?.add(fadeInAnimation, forKey: nil)
|
|
fromViewController.view.layer?.add(down ? slideToTopAnimation : slideToBottomAnimation, forKey: nil)
|
|
toViewController.view.layer?.add(down ? slideFromBottomAnimation : slideFromTopToBottom, forKey: nil)
|
|
CATransaction.commit()
|
|
}
|
|
|
|
private func immediatelyDisplayTab(index: Int) {
|
|
guard let viewController = self.preferencePanes[safe: index] else { return }
|
|
self.controllerView.addSubview(viewController.view)
|
|
}
|
|
|
|
private func animate(fromView: NSView?, toView: NSView?, animation: AnimationBlock) {
|
|
fromView?.wantsLayer = true
|
|
toView?.wantsLayer = true
|
|
for animation in animation(fromView, toView).fromViewAnimations {
|
|
fromView?.layer?.add(animation, forKey: nil)
|
|
}
|
|
for animation in animation(fromView, toView).toViewAnimations {
|
|
toView?.layer?.add(animation, forKey: nil)
|
|
}
|
|
}
|
|
|
|
@objc func tabSelected(_ sender: SegmentedControl) {
|
|
self.activateTab(index: sender.index, animated: true)
|
|
}
|
|
|
|
}
|
|
|
|
extension PreferencesViewController: NSTableViewDelegate {
|
|
|
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
|
|
|
guard tableView.tableColumns[safe: 0] == tableColumn
|
|
, tableColumn.isExist else {
|
|
return nil
|
|
}
|
|
|
|
guard let title = self.preferencePanes[safe: row]?.preferencePaneIdentifier.rawValue else { return nil }
|
|
|
|
tableView.makeView(withIdentifier: Constants.cellIdentifier, owner: nil)
|
|
|
|
let cell = SegmentCell(frame: .zero)
|
|
cell.update(with: title)
|
|
cell.cellIsSelected(self.activeTabIndex == row)
|
|
return cell
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
|
|
guard let selectedRow = self.activeTabIndex, row != selectedRow else { return false }
|
|
guard let previousView = self.segmentView.view(atColumn: 0, row: selectedRow, makeIfNecessary: true) as? SegmentCell,
|
|
let nextView = self.segmentView.view(atColumn: 0, row: row, makeIfNecessary: true) as? SegmentCell else { return false}
|
|
previousView.cellIsSelected(false)
|
|
nextView.cellIsSelected(true)
|
|
self.activateTab(index: row, animated: true )
|
|
return true
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
|
|
return 55
|
|
}
|
|
}
|
|
|
|
extension PreferencesViewController: NSTableViewDataSource {
|
|
func numberOfRows(in tableView: NSTableView) -> Int {
|
|
return self.preferencePanes.count
|
|
}
|
|
}
|
|
|
|
extension PreferencesViewController: JSNavigationBarViewControllerProvider {
|
|
|
|
func navigationBarViewController() -> NSViewController {
|
|
|
|
if let header = self.header { return header }
|
|
|
|
let header = self.headerCreationClosure()
|
|
self.header = header
|
|
return header
|
|
}
|
|
}
|