29 Commits

Author SHA1 Message Date
Sapozhnik Ivan f7122c6a55 Merge pull request #7 from iSapozhnik/feature/Custom-views
Custom views
2020-04-22 21:34:28 +02:00
Ivan Sapozhnik 78b2512f31 Custom views implementation 2020-04-22 20:14:33 +02:00
Ivan Sapozhnik 0084cee59c More configuration, fixing appearance position, selection item option 2020-04-22 17:10:04 +02:00
Ivan Sapozhnik 159d0a9a01 Readme 2020-04-19 17:21:11 +02:00
Ivan Sapozhnik d0bdb178c8 Readme 2020-04-19 17:06:41 +02:00
Ivan Sapozhnik 18fe80d65d update examples 2020-04-19 17:04:12 +02:00
Ivan Sapozhnik 34c1038265 Shoelds 2020-04-19 16:52:16 +02:00
Ivan Sapozhnik 05548b0201 Readme 2020-04-19 16:40:32 +02:00
Ivan Sapozhnik cb13380343 Removing Button -> will make another package for it 2020-04-18 11:39:01 +02:00
Ivan Sapozhnik ec077ed717 Add everything back 2020-04-17 18:02:40 +02:00
Ivan Sapozhnik 538d5a31be Remove all 2020-04-17 18:02:20 +02:00
Ivan Sapozhnik 5ccae8469e Readme 2020-04-17 17:31:05 +02:00
Ivan Sapozhnik a7c68cac47 Merge branch 'master' of https://github.com/iSapozhnik/Menu 2020-04-17 17:30:38 +02:00
Ivan Sapozhnik ae7fb9ecda Readme 2020-04-17 17:30:16 +02:00
Sapozhnik Ivan 69b93462a8 Create LICENSE 2020-04-17 17:29:41 +02:00
Ivan Sapozhnik 6853f0de1b Readme 2020-04-17 17:28:03 +02:00
Sapozhnik Ivan dba05d66d8 Merge pull request #1 from iSapozhnik/development
Clean up and API improvements
2020-04-17 17:02:34 +02:00
Ivan Sapozhnik 5937f7797d Handing tracking areas 2020-04-17 16:57:59 +02:00
Ivan Sapozhnik d8c542bdb1 Scroller clean up 🧼 2020-04-17 15:38:45 +02:00
Ivan Sapozhnik c2b02db557 Configurable animations duration 2020-04-17 14:50:09 +02:00
Ivan Sapozhnik 006e58697f More clean up and new public property 2020-04-17 14:02:15 +02:00
Ivan Sapozhnik 608ff6743b Clean up the code 🧼 2020-04-17 13:54:37 +02:00
Ivan Sapozhnik 447ba1e220 Disabling and enabling items, scroll view bugfix 2020-04-17 01:02:01 +02:00
Ivan Sapozhnik 79c9a36a36 Menu button 2020-04-16 23:34:23 +02:00
Ivan Sapozhnik 0528013386 Public methods and handling selected item 2020-04-16 23:16:36 +02:00
Ivan Sapozhnik aad10c007f Scroll view support 2020-04-16 20:07:50 +02:00
Ivan Sapozhnik 1cf55aa859 Readme requirements 2020-04-15 23:42:43 +02:00
Ivan Sapozhnik db47f2cbd6 Typo 2020-04-15 02:04:17 +02:00
Ivan Sapozhnik e976b0818f Readme, configuration element, handling content top and bottom space 2020-04-15 02:02:52 +02:00
16 changed files with 628 additions and 130 deletions
+21
View File
@@ -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
View File
@@ -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.
+106 -24
View File
@@ -1,18 +1,55 @@
# Menu
Fully customizable Mac OS drop-down menu
<div align="center">
![](menu_screenshot.png)
![Swift](https://img.shields.io/badge/%20in-swift%205.0-orange.svg)
![macOS](https://img.shields.io/badge/macOS-10.12-green.svg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
</div>
>Fully customizable macOS drop-down menu. It includes **30** settings you can play with.
![](screenshot2.png)
## 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.0.8")
]
```
### 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 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 +69,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 +79,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) | |
| ![](examples/twist.png) | ![](examples/example_twist.png) |
## Credits
Created and maintained by [**@iSapozhnik**](https://twitter.com/iSapozhnik).
## License
Released under the MIT License. See `LICENSE` for details.
>**Copyright &copy; 2020-present Sapozhnik Ivan.**
<!--
https://dribbble.com/shots/4953294-Daily-UI-Challenge-04-Dropdown-Menu
https://dribbble.com/shots/7055473-Dropdowns
-->
+45 -24
View File
@@ -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)
}
}
+33 -7
View File
@@ -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")
}
}
+8 -12
View File
@@ -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 -34
View File
@@ -9,45 +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 = nil
self?.stopMonitors()
}
if let menuWindow = window {
if animated {
@@ -58,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
}
}
@@ -72,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
}
@@ -80,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: {
@@ -113,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)
}
}
+28 -3
View File
@@ -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 }
@@ -73,19 +77,24 @@ public protocol Configuration {
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 +111,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)
}
@@ -181,4 +202,8 @@ open class MenuConfiguration: Configuration {
open var menuItemHoverImageTintColor: NSColor? {
return .white
}
open var menuItemHoverAnimationDuration: TimeInterval {
return 0.15
}
}
+57 -21
View File
@@ -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,15 +146,6 @@ 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
@@ -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
}
}
+21 -4
View File
@@ -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 {}
+134
View File
@@ -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
}
}
}
+36
View File
@@ -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)
}
}
}
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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB