Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0ab9eb3db | |||
| 322413636d | |||
| e6489c051b | |||
| f7122c6a55 | |||
| 78b2512f31 | |||
| 0084cee59c | |||
| 159d0a9a01 | |||
| d0bdb178c8 | |||
| 18fe80d65d | |||
| 34c1038265 | |||
| 05548b0201 | |||
| cb13380343 | |||
| ec077ed717 | |||
| 538d5a31be | |||
| 5ccae8469e | |||
| a7c68cac47 | |||
| ae7fb9ecda | |||
| 69b93462a8 | |||
| 6853f0de1b | |||
| dba05d66d8 | |||
| 5937f7797d | |||
| d8c542bdb1 | |||
| c2b02db557 | |||
| 006e58697f | |||
| 608ff6743b | |||
| 447ba1e220 | |||
| 79c9a36a36 | |||
| 0528013386 | |||
| aad10c007f | |||
| 1cf55aa859 | |||
| db47f2cbd6 | |||
| e976b0818f | |||
| 65abdd6244 |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Sapozhnik Ivan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+1
-1
@@ -17,7 +17,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
// .package(url: /* package url */, from: "1.0.0"),
|
||||
.package(url: "https://github.com/iSapozhnik/EventMonitor", from: "1.0.0")
|
||||
.package(url: "https://github.com/iSapozhnik/EventMonitor", from: "1.0.1")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
|
||||
@@ -1,18 +1,56 @@
|
||||
# Menu
|
||||
|
||||
Fully customizable Mac OS drop-down menu
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
</div>
|
||||
|
||||
>Fully customizable macOS drop-down menu. It includes **30** settings you can play with.
|
||||
|
||||

|
||||
|
||||
## Requirements
|
||||
|
||||
* Xcode 11+
|
||||
* macOS 10.12+
|
||||
* Swift 5.0 and higher
|
||||
|
||||
## Installation
|
||||
|
||||
Since this is a Swift Package, the installation process is pretty stright forward.
|
||||
|
||||
### Manual way
|
||||
Update your `Package.swift` dependencies:
|
||||
|
||||
```
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/iSapozhnik/Menu", from: "1.10.0")
|
||||
]
|
||||
```
|
||||
|
||||
### Via Xcode:
|
||||
1. Go to `File -> Swift Packages -> Add Package Dependency`.
|
||||
2. Put GitHub URL `https://github.com/iSapozhnik/Menu` and click `Next`
|
||||
3. Select the latest version
|
||||
4. Click `Finish`
|
||||
|
||||
## What can be customized? Everything!
|
||||
```swift
|
||||
public protocol Configuration {
|
||||
var titlePadding: Padding.Vertical { get }
|
||||
var titleBottomSpace: CGFloat { get }
|
||||
var titleFont: NSFont? { get }
|
||||
var titleColor: NSColor { get }
|
||||
var backgroundColor: NSColor { get }
|
||||
var cornerRadius: CGFloat { get }
|
||||
var hasShadow: Bool { get }
|
||||
var appearsBelowSender: Bool { get }
|
||||
var presentingOffset: CGFloat { get }
|
||||
var animationDuration: TimeInterval { get }
|
||||
var contentEdgeInsets: NSEdgeInsets { get }
|
||||
var maximumContentHeight: CGFloat? { get }
|
||||
var separatorColor: NSColor { get }
|
||||
var separatorThickness: CGFloat { get }
|
||||
var separatorHorizontalPadding: Padding.Horizontal { get }
|
||||
@@ -32,8 +70,8 @@ public protocol Configuration {
|
||||
var menuItemImageHeight: CGFloat? { get }
|
||||
var menuItemImageTintColor: NSColor? { get }
|
||||
var menuItemHoverImageTintColor: NSColor? { get }
|
||||
var menuItemHoverAnimationDuration: TimeInterval { get }
|
||||
}
|
||||
|
||||
```
|
||||
## How to use
|
||||
|
||||
@@ -42,29 +80,74 @@ import Cocoa
|
||||
import Menu
|
||||
|
||||
class ViewController: NSViewController {
|
||||
private let myMenu = Menu(with: "Select search engine:")
|
||||
private let myMenu = Menu(with: "Select a search engine:")
|
||||
|
||||
@IBOutlet var showMenuButton: NSButton!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let bing = MenuItem("Bing search", image: Icn.bing.image, action: { [weak self] in
|
||||
self?.showMenuButton.title = "Bing"
|
||||
})
|
||||
let item = MenuItem("DuckDuckGo search", image: Icn.duck.image, action: { [weak self] in
|
||||
self?.showMenuButton.title = "DuckDuckGo"
|
||||
})
|
||||
let google = MenuItem("Google search", image: Icn.google.image, action: { [weak self] in
|
||||
self?.showMenuButton.title = "Google"
|
||||
})
|
||||
let longText = MenuItem("Some very-very-very long text with no icon", action: { [weak self] in
|
||||
self?.showMenuButton.title = "Some very long text"
|
||||
})
|
||||
let emojiItem = MenuItem("Emojis are here 😎🚀", action: { [weak self] in
|
||||
self?.showMenuButton.title = "Emojis are here 😎🚀"
|
||||
})
|
||||
let exit = MenuItem("Exit", image: Icn.exit.image, action: {
|
||||
NSApplication.shared.terminate(nil)
|
||||
})
|
||||
let separator = MenuItem.separator()
|
||||
let menuItems = [
|
||||
bing,
|
||||
item,
|
||||
google,
|
||||
separator,
|
||||
longText,
|
||||
emojiItem,
|
||||
separator,
|
||||
exit
|
||||
]
|
||||
|
||||
myMenu.addItems(menuItems)
|
||||
}
|
||||
|
||||
@IBAction func didClickedButton(_ sender: NSButton) {
|
||||
myMenu.show(items: [
|
||||
MenuItem("Bing search", image: NSImage(named: "icons8-bing-50"), action: {
|
||||
sender.title = "Bing"
|
||||
}),
|
||||
MenuItem("DuckDuckGo search", image: NSImage(named: "icons8-duckduckgo-50"), action: {
|
||||
sender.title = "DuckDuckGo"
|
||||
}),
|
||||
MenuItem("Google search", image: NSImage(named: "icons8-google-50"), action: {
|
||||
sender.title = "Google"
|
||||
}),
|
||||
MenuItem.separator(),
|
||||
MenuItem("Some very-very-very long text and no icon", action: {
|
||||
sender.title = "Some very long text"
|
||||
}),
|
||||
MenuItem.separator(),
|
||||
MenuItem("Exit", image: NSImage(named: "icons8-exit-50"), action: {
|
||||
NSApplication.shared.terminate(nil)
|
||||
})
|
||||
], view: sender)
|
||||
myMenu.show(from: sender)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
In this section I've collected some examples of what can be implemented by using **Menu** control. On the left side some random examples from Dribbble and on the right side there is my implementation.
|
||||
|
||||
| Dribbble | Menu |
|
||||
| ------------- |:-------------:|
|
||||
| [link](https://dribbble.com/shots/4233782-Snooze-notifications-in-Twist) | |
|
||||
|  |  |
|
||||
|
||||
## Credits
|
||||
|
||||
Created and maintained by [**@iSapozhnik**](https://twitter.com/iSapozhnik).
|
||||
|
||||
## License
|
||||
|
||||
Released under the MIT License. See `LICENSE` for details.
|
||||
|
||||
>**Copyright © 2020-present Sapozhnik Ivan.**
|
||||
|
||||
<!--
|
||||
https://dribbble.com/shots/4953294-Daily-UI-Challenge-04-Dropdown-Menu
|
||||
https://dribbble.com/shots/7055473-Dropdowns
|
||||
-->
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Cocoa
|
||||
|
||||
protocol ContentViewControllerDelegate: AnyObject {
|
||||
func didClickMenuElement(with index: Int)
|
||||
func didClickMenuItem(withId id: UUID)
|
||||
}
|
||||
|
||||
class ContentViewController: NSViewController {
|
||||
@@ -19,7 +19,7 @@ class ContentViewController: NSViewController {
|
||||
private let configuration: Configuration
|
||||
|
||||
private var menuElements = [NSView]()
|
||||
private var slectedIndex: Int = .defaultSelectedIndex
|
||||
private let selectedId: UUID?
|
||||
|
||||
private let stackView: NSStackView = {
|
||||
let stackView = NSStackView()
|
||||
@@ -29,10 +29,19 @@ class ContentViewController: NSViewController {
|
||||
return stackView
|
||||
}()
|
||||
|
||||
init(with titleString: String?, menuItems: [MenuItem], selectedIndex: Int, configuration: Configuration) {
|
||||
private let clipView = FlippedClipView()
|
||||
|
||||
private let scrollView: ScrollView = {
|
||||
let scrollView = ScrollView()
|
||||
scrollView.verticalScroller = MenuScroller(withType: .vertical)
|
||||
scrollView.drawsBackground = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
init(with titleString: String?, menuItems: [MenuItem], selectedId: UUID?, configuration: Configuration) {
|
||||
self.titleString = titleString
|
||||
self.menuItems = menuItems
|
||||
self.slectedIndex = selectedIndex
|
||||
self.selectedId = selectedId
|
||||
self.configuration = configuration
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
@@ -57,30 +66,47 @@ class ContentViewController: NSViewController {
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: configuration.contentEdgeInsets.left),
|
||||
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -configuration.contentEdgeInsets.right),
|
||||
label.topAnchor.constraint(equalTo: view.topAnchor, constant: configuration.titlePadding.top),
|
||||
label.topAnchor.constraint(equalTo: view.topAnchor, constant: configuration.contentEdgeInsets.top)
|
||||
])
|
||||
}
|
||||
|
||||
scrollView.isScrollingEnabled = configuration.maximumContentHeight != nil
|
||||
scrollView.contentView = clipView
|
||||
scrollView.documentView = stackView
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(scrollView)
|
||||
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -configuration.contentEdgeInsets.bottom),
|
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: clipView.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: clipView.trailingAnchor),
|
||||
stackView.topAnchor.constraint(equalTo: clipView.topAnchor),
|
||||
stackView.widthAnchor.constraint(equalTo: clipView.widthAnchor),
|
||||
])
|
||||
|
||||
if let titleLabel = titleLabel {
|
||||
stackView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: configuration.titlePadding.bottom).isActive = true
|
||||
scrollView.hasVerticalScroller = configuration.maximumContentHeight != nil
|
||||
if let maxContentHeight = configuration.maximumContentHeight {
|
||||
scrollView.heightAnchor.constraint(equalToConstant: abs(maxContentHeight)).isActive = true
|
||||
} else {
|
||||
stackView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
||||
stackView.bottomAnchor.constraint(equalTo: clipView.bottomAnchor).isActive = true
|
||||
}
|
||||
|
||||
if let titleLabel = titleLabel {
|
||||
scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: configuration.titleBottomSpace).isActive = true
|
||||
} else {
|
||||
scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: configuration.contentEdgeInsets.top).isActive = true
|
||||
}
|
||||
|
||||
menuItems.enumerated().forEach { index, item in
|
||||
if item.isSeparator {
|
||||
addSeparator()
|
||||
} else {
|
||||
addMenuElement(with: item, isSelected: index == slectedIndex)
|
||||
addMenuElement(with: item, isSelected: item.id == selectedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,13 +136,7 @@ class ContentViewController: NSViewController {
|
||||
}
|
||||
|
||||
private func addMenuElement(with menuItem: MenuItem, isSelected: Bool) {
|
||||
let menuElement = MenuElement(
|
||||
text: menuItem.title,
|
||||
image: menuItem.image,
|
||||
isSelected: isSelected,
|
||||
configuration: configuration,
|
||||
action: menuItem.action
|
||||
)
|
||||
let menuElement = MenuElement(with: menuItem, isSelected: isSelected, configuration: configuration)
|
||||
menuElement.translatesAutoresizingMaskIntoConstraints = false
|
||||
menuElement.delegate = self
|
||||
menuElement.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
@@ -141,7 +161,7 @@ class ContentViewController: NSViewController {
|
||||
label.isEditable = false
|
||||
label.isBordered = false
|
||||
label.font = configuration.titleFont
|
||||
label.textColor = configuration.menuItemTextColor
|
||||
label.textColor = configuration.titleColor
|
||||
switch configuration.textAlignment {
|
||||
case .left:
|
||||
label.alignment = .left
|
||||
@@ -154,8 +174,9 @@ class ContentViewController: NSViewController {
|
||||
|
||||
extension ContentViewController: MenuElementDelegate {
|
||||
func didClickMenuElement(_ menuElement: MenuElement) {
|
||||
let index = menuElements.firstIndex(of: menuElement)
|
||||
slectedIndex = index ?? .defaultSelectedIndex
|
||||
delegate?.didClickMenuElement(with: slectedIndex)
|
||||
guard let index = menuElements.firstIndex(of: menuElement) else { return }
|
||||
guard menuItems.indices.contains(index) else { return }
|
||||
let selectedMenuItem = menuItems[index]
|
||||
delegate?.didClickMenuItem(withId: selectedMenuItem.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,16 @@ class Control: NSControl {
|
||||
var hover: ((Bool) -> Void)?
|
||||
|
||||
private let hoverLayer = CAShapeLayer()
|
||||
private let configuratuon: Configuration
|
||||
private let hoverColor: NSColor
|
||||
private let hoverAnimationDuration: TimeInterval
|
||||
private var trackingArea: NSTrackingArea?
|
||||
|
||||
init(with configuration: Configuration) {
|
||||
self.configuratuon = configuration
|
||||
self.hoverColor = configuration.menuItemHoverBackgroundColor
|
||||
self.hoverAnimationDuration = configuration.menuItemHoverAnimationDuration
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
wantsLayer = true
|
||||
|
||||
hoverLayer.fillColor = .clear
|
||||
@@ -29,13 +33,22 @@ class Control: NSControl {
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
|
||||
addTrackingArea(NSTrackingArea.init(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: nil))
|
||||
|
||||
hoverLayer.path = CGPath(rect: bounds, transform: nil)
|
||||
|
||||
if let trackingArea = trackingArea, trackingAreas.contains(trackingArea) {
|
||||
removeTrackingArea(trackingArea)
|
||||
}
|
||||
createTrackingArea()
|
||||
}
|
||||
|
||||
private func createTrackingArea() {
|
||||
let newTrackingArea = NSTrackingArea.init(rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp], owner: self, userInfo: nil)
|
||||
addTrackingArea(newTrackingArea)
|
||||
trackingArea = newTrackingArea
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
guard isEnabled else { return }
|
||||
if let action = action {
|
||||
hoverLayer.fillColor = .clear
|
||||
hover?(false)
|
||||
@@ -47,12 +60,14 @@ class Control: NSControl {
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
hoverLayer.fillColor = configuratuon.menuItemHoverBackgroundColor.cgColor
|
||||
guard isEnabled else { return }
|
||||
animateFillColor(from: .clear, to: hoverColor)
|
||||
hover?(true)
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
hoverLayer.fillColor = .clear
|
||||
guard isEnabled else { return }
|
||||
animateFillColor(from: hoverColor, to: .clear)
|
||||
hover?(false)
|
||||
}
|
||||
|
||||
@@ -60,4 +75,15 @@ class Control: NSControl {
|
||||
NSColor.clear.setFill()
|
||||
NSBezierPath(rect: bounds).fill()
|
||||
}
|
||||
|
||||
private func animateFillColor(from oldColor: NSColor, to newColor: NSColor) {
|
||||
let animation = CABasicAnimation(keyPath: "fillColor")
|
||||
animation.duration = hoverAnimationDuration
|
||||
animation.fromValue = oldColor.cgColor
|
||||
animation.toValue = newColor.cgColor
|
||||
animation.fillMode = .both
|
||||
animation.timingFunction = .easeInEaseOut
|
||||
animation.isRemovedOnCompletion = false
|
||||
hoverLayer.add(animation, forKey: "fillColor")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,25 +172,21 @@ extension NSEdgeInsets {
|
||||
static let zero = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
|
||||
}
|
||||
|
||||
extension Int {
|
||||
static let defaultSelectedIndex = -1
|
||||
}
|
||||
|
||||
extension CGFloat {
|
||||
/// 0 points
|
||||
public static let grid0: CGFloat = 0.0
|
||||
static let grid0: CGFloat = 0.0
|
||||
/// 4 points
|
||||
public static let gridHalf: CGFloat = 4.0
|
||||
static let gridHalf: CGFloat = 4.0
|
||||
/// 8 points
|
||||
public static let grid1: CGFloat = 8.0
|
||||
static let grid1: CGFloat = 8.0
|
||||
/// 16 points
|
||||
public static let grid2: CGFloat = 16.0
|
||||
static let grid2: CGFloat = 16.0
|
||||
/// 24 points
|
||||
public static let grid3: CGFloat = 24.0
|
||||
static let grid3: CGFloat = 24.0
|
||||
/// 32 points
|
||||
public static let grid4: CGFloat = 32.0
|
||||
static let grid4: CGFloat = 32.0
|
||||
/// 40 points
|
||||
public static let grid5: CGFloat = 40.0
|
||||
static let grid5: CGFloat = 40.0
|
||||
/// 48 points
|
||||
public static let grid6: CGFloat = 48.0
|
||||
static let grid6: CGFloat = 48.0
|
||||
}
|
||||
|
||||
+138
-35
@@ -9,46 +9,43 @@ import Cocoa
|
||||
import EventMonitor
|
||||
|
||||
public final class Menu {
|
||||
public private(set) var items: [MenuItem]
|
||||
public var numberOfItems: Int {
|
||||
return items.count
|
||||
}
|
||||
public var selectedItem: MenuItem? {
|
||||
return items.first { $0.id == selectedId }
|
||||
}
|
||||
|
||||
private var window: Window?
|
||||
private var lostFocusObserver: Any?
|
||||
private var localMonitor: EventMonitor?
|
||||
private let configuration: Configuration
|
||||
private var selectedIndex: Int = .defaultSelectedIndex
|
||||
private var selectedId: UUID?
|
||||
private let title: String?
|
||||
private weak var targetView: NSView?
|
||||
|
||||
public convenience init() {
|
||||
self.init(with: nil)
|
||||
}
|
||||
|
||||
public init(with title: String?, configuration: Configuration = MenuConfiguration()) {
|
||||
public init(with title: String?, items: [MenuItem] = [MenuItem](), configuration: Configuration = MenuConfiguration()) {
|
||||
self.title = title
|
||||
self.items = items
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
public func show(items: [MenuItem], view: NSView) {
|
||||
guard window == nil, let parentWindow = view.window else { return }
|
||||
|
||||
let contentViewController = ContentViewController(with: title, menuItems: items, selectedIndex: selectedIndex, configuration: configuration)
|
||||
contentViewController.delegate = self
|
||||
|
||||
let window = Window.make(with: configuration)
|
||||
window.contentViewController = contentViewController
|
||||
view.window?.addChildWindow(window, ordered: .above)
|
||||
|
||||
self.window = window
|
||||
setPositionRelativeTo(view)
|
||||
|
||||
setupMonitors(for: parentWindow, targetView: view)
|
||||
|
||||
fadeIn(window)
|
||||
// MARK: - Show and dismiss
|
||||
public func show(from view: NSView, animated: Bool = true) {
|
||||
show(items, from: view, animated: animated)
|
||||
}
|
||||
|
||||
public func dismiss(animated: Bool) {
|
||||
public func dismiss(animated: Bool = true) {
|
||||
let actualDismiss: (NSWindow) -> Void = { [weak self] menuWindow in
|
||||
self?.window?.parent?.removeChildWindow(menuWindow)
|
||||
self?.window?.orderOut(self)
|
||||
self?.window?.close()
|
||||
self?.window = nil
|
||||
self?.stopMonitors()
|
||||
}
|
||||
if let menuWindow = window {
|
||||
if animated {
|
||||
@@ -59,13 +56,93 @@ public final class Menu {
|
||||
actualDismiss(menuWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localMonitor?.stop()
|
||||
localMonitor = nil
|
||||
// MARK: - Selection
|
||||
public func selectItem(at index: Int) {
|
||||
guard let item = item(at: index) else { return }
|
||||
selectedId = item.id
|
||||
item.action?()
|
||||
}
|
||||
|
||||
if let lostFocusObserver = lostFocusObserver {
|
||||
NotificationCenter.default.removeObserver(lostFocusObserver)
|
||||
self.lostFocusObserver = nil
|
||||
// MARK: - Adding and Removing Menu Items
|
||||
public func insertItem(_ item: MenuItem, at index: Int) {
|
||||
items.insert(item, at: index)
|
||||
}
|
||||
|
||||
public func addItem(_ item: MenuItem) {
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
public func addItems(_ items: [MenuItem]) {
|
||||
self.items.append(contentsOf: items)
|
||||
}
|
||||
|
||||
public func removeItem(at index: Int) {
|
||||
guard items.indices.contains(index) else { return }
|
||||
let deletedItem = items.remove(at: index)
|
||||
if deletedItem.id == selectedId {
|
||||
selectedId = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func removeItem(_ item: MenuItem) {
|
||||
items.removeAll { $0.id == item.id }
|
||||
if item.id == selectedId {
|
||||
selectedId = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func removeAllItems() {
|
||||
items.removeAll()
|
||||
selectedId = nil
|
||||
}
|
||||
|
||||
// MARK: - Finding Menu Items
|
||||
public func item(at index: Int) -> MenuItem? {
|
||||
guard items.indices.contains(index) else { return nil }
|
||||
return items[index]
|
||||
}
|
||||
|
||||
public func item(withTitle title: String) -> MenuItem? {
|
||||
items.first { $0.title == title }
|
||||
}
|
||||
|
||||
// MARK: - Disabling&Enabling
|
||||
public func disableAllItems() {
|
||||
items = items.map { oldItem in
|
||||
var newItem = oldItem
|
||||
newItem.isEnabled = false
|
||||
return newItem
|
||||
}
|
||||
}
|
||||
|
||||
public func enableAllItems() {
|
||||
items = items.map { oldItem in
|
||||
var newItem = oldItem
|
||||
newItem.isEnabled = true
|
||||
return newItem
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private func show(_ items: [MenuItem], from view: NSView, animated: Bool) {
|
||||
guard window == nil, let parentWindow = view.window else { return }
|
||||
|
||||
let menuWindow = makeWindow(
|
||||
with: title,
|
||||
menuItems: items,
|
||||
attachedTo: parentWindow,
|
||||
relativeTo: view
|
||||
)
|
||||
self.window = menuWindow
|
||||
|
||||
setupMonitors(for: parentWindow, targetView: view)
|
||||
|
||||
if animated {
|
||||
fadeIn(menuWindow)
|
||||
} else {
|
||||
menuWindow.alphaValue = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +150,7 @@ public final class Menu {
|
||||
window.alphaValue = 0.0
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.15
|
||||
context.duration = configuration.animationDuration
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
window.animator().alphaValue = 1.0
|
||||
}
|
||||
@@ -81,7 +158,7 @@ public final class Menu {
|
||||
|
||||
private func fadeOut(window: NSWindow, completion: @escaping () -> Void) {
|
||||
NSAnimationContext.runAnimationGroup ({ context in
|
||||
context.duration = 0.15
|
||||
context.duration = configuration.animationDuration
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().alphaValue = 0.0
|
||||
}, completionHandler: {
|
||||
@@ -114,21 +191,47 @@ public final class Menu {
|
||||
localMonitor?.start()
|
||||
}
|
||||
|
||||
private func setPositionRelativeTo(_ view: NSView) {
|
||||
guard let presentationWindow = view.window, let window = self.window else { return }
|
||||
private func stopMonitors() {
|
||||
localMonitor?.stop()
|
||||
localMonitor = nil
|
||||
|
||||
let presentationFrame = presentationWindow.convertToScreen(view.frame)
|
||||
let presentationPoint = presentationFrame.origin
|
||||
let additionalYOffset = configuration.appearsBelowSender ? 0 : NSHeight(view.frame)
|
||||
if let lostFocusObserver = lostFocusObserver {
|
||||
NotificationCenter.default.removeObserver(lostFocusObserver)
|
||||
self.lostFocusObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeWindow(with title: String?, menuItems: [MenuItem], attachedTo parentWindow: NSWindow, relativeTo targetView: NSView) -> Window {
|
||||
let contentViewController = ContentViewController(with: title, menuItems: menuItems, selectedId: selectedId, configuration: configuration)
|
||||
contentViewController.delegate = self
|
||||
|
||||
let window = Window.make(with: configuration)
|
||||
window.contentViewController = contentViewController
|
||||
parentWindow.addChildWindow(window, ordered: .above)
|
||||
|
||||
setFrame(for: window, relativeTo: targetView)
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
private func setFrame(for window: NSWindow, relativeTo view: NSView) {
|
||||
guard let parentWindow = view.window, let topMostSuperView = parentWindow.contentView else { return }
|
||||
|
||||
let locationInWindow = view.convert(topMostSuperView.frame.origin, to: nil)
|
||||
let rectInWindow = NSRect(origin: locationInWindow, size: CGSize(width: NSWidth(view.frame), height: NSHeight(window.frame)))
|
||||
let rectInScreen = parentWindow.convertToScreen(rectInWindow)
|
||||
let origin = rectInScreen.origin
|
||||
var additionalYOffset = configuration.appearsBelowSender ? NSHeight(view.frame) : 0
|
||||
additionalYOffset += abs(configuration.presentingOffset)
|
||||
let newFrame = NSRect(x: origin.x, y: origin.y - NSHeight(window.frame) - additionalYOffset, width: NSWidth(view.frame), height: NSHeight(window.frame))
|
||||
|
||||
let newFrame = NSRect(x: presentationPoint.x, y: presentationPoint.y - NSHeight(window.frame) + additionalYOffset, width: NSWidth(view.frame), height: NSHeight(window.frame))
|
||||
window.setFrame(newFrame, display: true, animate: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension Menu: ContentViewControllerDelegate {
|
||||
func didClickMenuElement(with index: Int) {
|
||||
selectedIndex = index
|
||||
func didClickMenuItem(withId id: UUID) {
|
||||
selectedId = id
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,17 @@ extension Padding.Vertical {
|
||||
}
|
||||
|
||||
public protocol Configuration {
|
||||
var titlePadding: Padding.Vertical { get }
|
||||
var titleBottomSpace: CGFloat { get }
|
||||
var titleFont: NSFont? { get }
|
||||
var titleColor: NSColor { get }
|
||||
var backgroundColor: NSColor { get }
|
||||
var cornerRadius: CGFloat { get }
|
||||
var hasShadow: Bool { get }
|
||||
var appearsBelowSender: Bool { get }
|
||||
var presentingOffset: CGFloat { get }
|
||||
var animationDuration: TimeInterval { get }
|
||||
var contentEdgeInsets: NSEdgeInsets { get }
|
||||
var maximumContentHeight: CGFloat? { get }
|
||||
var separatorColor: NSColor { get }
|
||||
var separatorThickness: CGFloat { get }
|
||||
var separatorHorizontalPadding: Padding.Horizontal { get }
|
||||
@@ -70,22 +74,28 @@ public protocol Configuration {
|
||||
var menuItemHoverCheckmarkColor: NSColor { get }
|
||||
var menuItemCheckmarkHeight: CGFloat { get }
|
||||
var menuItemCheckmarkThikness: CGFloat { get }
|
||||
var menuItemHorizontalSpacing: CGFloat { get }
|
||||
var menuItemImageHeight: CGFloat? { get }
|
||||
var menuItemImageTintColor: NSColor? { get }
|
||||
var menuItemHoverImageTintColor: NSColor? { get }
|
||||
var menuItemHoverAnimationDuration: TimeInterval { get }
|
||||
}
|
||||
|
||||
open class MenuConfiguration: Configuration {
|
||||
public init() {}
|
||||
|
||||
open var titlePadding: Padding.Vertical {
|
||||
return .init(top: .grid1, bottom: .grid1)
|
||||
open var titleBottomSpace: CGFloat {
|
||||
return .grid1
|
||||
}
|
||||
|
||||
open var titleFont: NSFont? {
|
||||
return NSFont.systemFont(ofSize: 18, weight: .light)
|
||||
}
|
||||
|
||||
open var titleColor: NSColor {
|
||||
return NSColor.white
|
||||
}
|
||||
|
||||
open var backgroundColor: NSColor {
|
||||
return NSColor.init(calibratedRed: 84/255, green: 181/255, blue: 146/255, alpha: 1.0)
|
||||
}
|
||||
@@ -102,10 +112,22 @@ open class MenuConfiguration: Configuration {
|
||||
return true
|
||||
}
|
||||
|
||||
open var presentingOffset: CGFloat {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
open var animationDuration: TimeInterval {
|
||||
return 0.15
|
||||
}
|
||||
|
||||
open var contentEdgeInsets: NSEdgeInsets {
|
||||
return NSEdgeInsets(top: .grid2, left: .grid2, bottom: .grid2, right: .grid2)
|
||||
}
|
||||
|
||||
open var maximumContentHeight: CGFloat? {
|
||||
return nil
|
||||
}
|
||||
|
||||
open var separatorColor: NSColor {
|
||||
return NSColor.init(calibratedRed: 76/255, green: 161/255, blue: 132/255, alpha: 1.0)
|
||||
}
|
||||
@@ -170,6 +192,10 @@ open class MenuConfiguration: Configuration {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
open var menuItemHorizontalSpacing: CGFloat {
|
||||
return .grid1
|
||||
}
|
||||
|
||||
open var menuItemImageHeight: CGFloat? {
|
||||
return .grid3
|
||||
}
|
||||
@@ -181,4 +207,8 @@ open class MenuConfiguration: Configuration {
|
||||
open var menuItemHoverImageTintColor: NSColor? {
|
||||
return .white
|
||||
}
|
||||
|
||||
open var menuItemHoverAnimationDuration: TimeInterval {
|
||||
return 0.15
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,58 @@ class MenuElement: NSView {
|
||||
private let configuration: Configuration
|
||||
private var checkmark: CheckmarkView!
|
||||
|
||||
init(text: String, image: NSImage? = nil, isSelected: Bool = false, configuration: Configuration, action: @escaping () -> Void) {
|
||||
init(with menuItem: MenuItem, isSelected: Bool = false, configuration: Configuration) {
|
||||
self.configuration = configuration
|
||||
handler = action
|
||||
handler = menuItem.action ?? {}
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
alphaValue = menuItem.isEnabled ? 1.0 : 0.5
|
||||
|
||||
if let customView = menuItem.customView {
|
||||
makeCustomViewElement(with: customView)
|
||||
} else {
|
||||
makeStandardElement(with: menuItem, isSelected: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func menuElementClicked(_ sender: Control) {
|
||||
handler()
|
||||
delegate?.didClickMenuElement(self)
|
||||
}
|
||||
|
||||
private func makeCustomViewElement(with customView: NSView) {
|
||||
customView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(customView)
|
||||
|
||||
let leadingConstraint: NSLayoutConstraint
|
||||
let trailingConstraint: NSLayoutConstraint
|
||||
switch configuration.textAlignment {
|
||||
case .left:
|
||||
leadingConstraint = customView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: configuration.contentEdgeInsets.left)
|
||||
trailingConstraint = customView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -configuration.contentEdgeInsets.right)
|
||||
case .right:
|
||||
leadingConstraint = customView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: configuration.contentEdgeInsets.left)
|
||||
trailingConstraint = customView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -configuration.contentEdgeInsets.right)
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
leadingConstraint,
|
||||
trailingConstraint,
|
||||
customView.topAnchor.constraint(equalTo: topAnchor),
|
||||
customView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func makeStandardElement(with menuItem: MenuItem, isSelected: Bool) {
|
||||
let stackView = makeHorizontalStackView()
|
||||
|
||||
var lImageView: NSImageView? = nil
|
||||
var rImageView: NSImageView? = nil
|
||||
let image = menuItem.image
|
||||
switch configuration.iconAlignment {
|
||||
case .left:
|
||||
lImageView = makeLeftImageView(with: image)
|
||||
@@ -34,7 +76,7 @@ class MenuElement: NSView {
|
||||
rImageView = makeRightImageView(with: image)
|
||||
}
|
||||
|
||||
let label = makeLabel(with: text)
|
||||
let label = makeLabel(with: menuItem.title)
|
||||
|
||||
if let lImageView = lImageView {
|
||||
stackView.addArrangedSubview(lImageView)
|
||||
@@ -68,11 +110,15 @@ class MenuElement: NSView {
|
||||
|
||||
if isSelected {
|
||||
checkmarkView.animate(duration: 0)
|
||||
if #available(OSX 10.14, *) {
|
||||
lImageView?.contentTintColor = isSelected ? configuration.menuItemHoverImageTintColor : configuration.menuItemImageTintColor
|
||||
}
|
||||
label.textColor = isSelected ? configuration.menuItemHoverTextColor : configuration.menuItemTextColor
|
||||
}
|
||||
checkmark = checkmarkView
|
||||
}
|
||||
|
||||
let control = makeHoverControl(update: label, leftImageView: lImageView, rightImageView: rImageView)
|
||||
let control = makeHoverControl(update: label, leftImageView: lImageView, rightImageView: rImageView, isEnabled: menuItem.isEnabled, isSelected: isSelected)
|
||||
|
||||
addSubview(control)
|
||||
addSubview(stackView)
|
||||
@@ -100,21 +146,12 @@ class MenuElement: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func menuElementClicked(_ sender: Control) {
|
||||
handler()
|
||||
delegate?.didClickMenuElement(self)
|
||||
}
|
||||
|
||||
private func makeHorizontalStackView() -> NSStackView {
|
||||
let stackView = NSStackView()
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.orientation = .horizontal
|
||||
stackView.distribution = .fill
|
||||
stackView.spacing = .grid1
|
||||
stackView.spacing = configuration.menuItemHorizontalSpacing
|
||||
return stackView
|
||||
}
|
||||
|
||||
@@ -144,8 +181,7 @@ class MenuElement: NSView {
|
||||
rImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
rImageView.image = image
|
||||
if #available(OSX 10.14, *) {
|
||||
if let menuItemImageTintColor = configuration.menuItemImageTintColor {
|
||||
assert(image?.isTemplate ?? true, "In order to set an image tint color, the image should have template rendering mode")
|
||||
if let menuItemImageTintColor = configuration.menuItemImageTintColor, let image = image, image.isTemplate {
|
||||
rImageView.contentTintColor = menuItemImageTintColor
|
||||
}
|
||||
}
|
||||
@@ -159,8 +195,7 @@ class MenuElement: NSView {
|
||||
lImageView.imageScaling = .scaleProportionallyUpOrDown
|
||||
lImageView.image = image
|
||||
if #available(OSX 10.14, *) {
|
||||
if let menuItemImageTintColor = configuration.menuItemImageTintColor {
|
||||
assert(image?.isTemplate ?? true, "In order to set an image tint color, the image should have template rendering mode")
|
||||
if let menuItemImageTintColor = configuration.menuItemImageTintColor, let image = image, image.isTemplate {
|
||||
lImageView.contentTintColor = menuItemImageTintColor
|
||||
}
|
||||
}
|
||||
@@ -168,14 +203,15 @@ class MenuElement: NSView {
|
||||
return lImageView
|
||||
}
|
||||
|
||||
private func makeHoverControl(update label: NSTextField, leftImageView: NSImageView?, rightImageView: NSImageView?) -> Control {
|
||||
private func makeHoverControl(update label: NSTextField, leftImageView: NSImageView?, rightImageView: NSImageView?, isEnabled: Bool, isSelected: Bool) -> Control {
|
||||
let control = Control(with: configuration)
|
||||
control.isEnabled = isEnabled
|
||||
control.hover = { [weak self] isHover in
|
||||
guard let self = self else { return }
|
||||
label.textColor = isHover ? self.configuration.menuItemHoverTextColor : self.configuration.menuItemTextColor
|
||||
label.textColor = isHover ? self.configuration.menuItemHoverTextColor : isSelected ? self.configuration.menuItemHoverImageTintColor : self.configuration.menuItemTextColor
|
||||
if #available(OSX 10.14, *) {
|
||||
leftImageView?.contentTintColor = isHover ? self.configuration.menuItemHoverImageTintColor : self.configuration.menuItemImageTintColor
|
||||
rightImageView?.contentTintColor = isHover ? self.configuration.menuItemHoverImageTintColor : self.configuration.menuItemImageTintColor
|
||||
leftImageView?.contentTintColor = isHover ? self.configuration.menuItemHoverImageTintColor : isSelected ? self.configuration.menuItemHoverImageTintColor : self.configuration.menuItemImageTintColor
|
||||
rightImageView?.contentTintColor = isHover ? self.configuration.menuItemHoverImageTintColor : isSelected ? self.configuration.menuItemHoverImageTintColor : self.configuration.menuItemImageTintColor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,23 +7,40 @@
|
||||
|
||||
import Cocoa
|
||||
|
||||
public struct MenuItem {
|
||||
let action: () -> Void
|
||||
public struct MenuItem: CustomDebugStringConvertible {
|
||||
public var isEnabled = true
|
||||
public let id: UUID
|
||||
|
||||
let action: (() -> Void)?
|
||||
let title: String
|
||||
let image: NSImage?
|
||||
let isSelectable: Bool
|
||||
var customView: NSView?
|
||||
var isSeparator = false
|
||||
|
||||
public init(_ title: String, image: NSImage? = nil, isSelectable: Bool = true, action: @escaping () -> Void = {}) {
|
||||
|
||||
public init(_ customView: NSView) {
|
||||
self.init("", image: nil, customView: customView, isSelectable: false, action: nil)
|
||||
}
|
||||
|
||||
public init(_ title: String, image: NSImage? = nil, customView: NSView? = nil, isSelectable: Bool = true, action: (() -> Void)?) {
|
||||
self.action = action
|
||||
self.title = title
|
||||
self.image = image
|
||||
self.customView = customView
|
||||
self.isSelectable = isSelectable
|
||||
id = UUID()
|
||||
}
|
||||
|
||||
public static func separator() -> MenuItem {
|
||||
var separatorItem = self.init("", isSelectable: false)
|
||||
var separatorItem = self.init("", isSelectable: false, action: nil)
|
||||
separatorItem.isSeparator = true
|
||||
return separatorItem
|
||||
}
|
||||
|
||||
public var debugDescription: String {
|
||||
return isSeparator ? "separator" : "title: \(title), id: \(id.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuItem: Identifiable {}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Ivan Sapozhnik on 15.04.20.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
enum MenuScrollerType {
|
||||
case horizontal
|
||||
case vertical
|
||||
}
|
||||
|
||||
class MenuScroller: NSScroller {
|
||||
override var floatValue: Float {
|
||||
get {
|
||||
return super.floatValue
|
||||
}
|
||||
set {
|
||||
super.floatValue = newValue
|
||||
updateAlpha(1.0, animated: true)
|
||||
rescheduleFadeOut()
|
||||
}
|
||||
}
|
||||
|
||||
private var alpha: CGFloat = 0.0
|
||||
private var type: MenuScrollerType = .vertical
|
||||
private var trackingArea: NSTrackingArea!
|
||||
|
||||
init(withType scrollerType: MenuScrollerType) {
|
||||
type = scrollerType
|
||||
super.init(frame: .zero)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
commonInit()
|
||||
}
|
||||
|
||||
func commonInit() {
|
||||
trackingArea = NSTrackingArea.init(rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp, .mouseMoved], owner: self, userInfo: nil)
|
||||
addTrackingArea(trackingArea)
|
||||
}
|
||||
|
||||
@objc func fadeOut() {
|
||||
updateAlpha(0.3, animated: true, anumationDuration: 0.25)
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
self.drawKnob()
|
||||
self.drawKnobSlot(in: bounds, highlight: false)
|
||||
}
|
||||
|
||||
override func drawKnob() {
|
||||
NSColor.white.setFill()
|
||||
|
||||
let dx, dy: CGFloat
|
||||
switch type {
|
||||
case .horizontal:
|
||||
dx = 0; dy = 3
|
||||
case .vertical:
|
||||
dx = 5; dy = 0
|
||||
}
|
||||
|
||||
let frame = rect(for: .knob).insetBy(dx: dx, dy: dy)
|
||||
NSBezierPath.init(roundedRect: frame, xRadius: 3, yRadius: 3).fill()
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
if trackingAreas.contains(trackingArea) {
|
||||
removeTrackingArea(trackingArea)
|
||||
}
|
||||
|
||||
trackingArea = NSTrackingArea.init(rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp, .mouseMoved], owner: self, userInfo: nil)
|
||||
addTrackingArea(trackingArea)
|
||||
}
|
||||
|
||||
override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) {
|
||||
NSColor.init(white: 1.0, alpha: 0.15).setFill()
|
||||
let frame = rect(for: .knobSlot).insetBy(dx: 3, dy: 0)
|
||||
NSBezierPath.init(roundedRect: frame, xRadius: 5, yRadius: 5).fill()
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
super.mouseExited(with: event)
|
||||
self.fadeOut()
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
super.mouseEntered(with: event)
|
||||
updateAlpha(1.0, animated: true)
|
||||
cancelPreviuousFadeOut()
|
||||
}
|
||||
|
||||
override func mouseMoved(with event: NSEvent) {
|
||||
super.mouseMoved(with: event)
|
||||
updateAlpha(1.0, animated: false)
|
||||
}
|
||||
|
||||
private func rescheduleFadeOut() {
|
||||
cancelPreviuousFadeOut()
|
||||
perform(#selector(fadeOut), with: nil, afterDelay: 1.0)
|
||||
}
|
||||
|
||||
private func cancelPreviuousFadeOut() {
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(fadeOut), object: nil)
|
||||
}
|
||||
|
||||
private func updateAlpha(_ newAlpha: CGFloat, animated: Bool, anumationDuration duration: TimeInterval = 0.1) {
|
||||
guard alpha != newAlpha else { return }
|
||||
|
||||
alpha = newAlpha
|
||||
if animated {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = duration
|
||||
animator().alphaValue = newAlpha
|
||||
}
|
||||
} else {
|
||||
alphaValue = newAlpha
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Ivan Sapozhnik on 16.04.20.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
final class FlippedClipView: NSClipView {
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
}
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
drawsBackground = false
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var isFlipped: Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollView: NSScrollView {
|
||||
var isScrollingEnabled = true
|
||||
override func scrollWheel(with event: NSEvent) {
|
||||
if isScrollingEnabled {
|
||||
super.scrollWheel(with: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Cocoa
|
||||
|
||||
final class Window: NSPanel {
|
||||
final class Window: NSWindow {
|
||||
private var childContentView: NSView?
|
||||
private var backgroundView: RoundedRectangleView?
|
||||
private let configuration: Configuration
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 315 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
Reference in New Issue
Block a user