Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0ab9eb3db | |||
| 322413636d | |||
| e6489c051b | |||
| f7122c6a55 | |||
| 78b2512f31 | |||
| 0084cee59c | |||
| 159d0a9a01 | |||
| d0bdb178c8 | |||
| 18fe80d65d | |||
| 34c1038265 | |||
| 05548b0201 | |||
| cb13380343 | |||
| ec077ed717 | |||
| 538d5a31be |
@@ -1,13 +1,21 @@
|
||||
# Menu
|
||||
|
||||
>Fully customizable Mac OS drop-down menu. It includes **30** settings you can play with.
|
||||
<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+
|
||||
* Mac OS 10.12+
|
||||
* macOS 10.12+
|
||||
* Swift 5.0 and higher
|
||||
|
||||
## Installation
|
||||
@@ -19,7 +27,7 @@ Update your `Package.swift` dependencies:
|
||||
|
||||
```
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/iSapozhnik/Menu", from: "1.0.8")
|
||||
.package(url: "https://github.com/iSapozhnik/Menu", from: "1.10.0")
|
||||
]
|
||||
```
|
||||
|
||||
@@ -39,6 +47,7 @@ public protocol Configuration {
|
||||
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 }
|
||||
@@ -117,6 +126,15 @@ class ViewController: NSViewController {
|
||||
}
|
||||
```
|
||||
|
||||
## 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).
|
||||
@@ -127,3 +145,9 @@ 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
|
||||
-->
|
||||
|
||||
|
||||
|
||||
@@ -136,14 +136,7 @@ class ContentViewController: NSViewController {
|
||||
}
|
||||
|
||||
private func addMenuElement(with menuItem: MenuItem, isSelected: Bool) {
|
||||
let menuElement = MenuElement(
|
||||
text: menuItem.title,
|
||||
image: menuItem.image,
|
||||
isSelected: isSelected,
|
||||
isEnabled: menuItem.isEnabled,
|
||||
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)
|
||||
|
||||
@@ -174,19 +174,19 @@ extension NSEdgeInsets {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+15
-5
@@ -58,6 +58,13 @@ public final class Menu {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
public func selectItem(at index: Int) {
|
||||
guard let item = item(at: index) else { return }
|
||||
selectedId = item.id
|
||||
item.action?()
|
||||
}
|
||||
|
||||
// MARK: - Adding and Removing Menu Items
|
||||
public func insertItem(_ item: MenuItem, at index: Int) {
|
||||
items.insert(item, at: index)
|
||||
@@ -208,13 +215,16 @@ public final class Menu {
|
||||
}
|
||||
|
||||
private func setFrame(for window: NSWindow, relativeTo view: NSView) {
|
||||
guard let parentWindow = view.window else { return }
|
||||
guard let parentWindow = view.window, let topMostSuperView = parentWindow.contentView else { return }
|
||||
|
||||
let presentationFrame = parentWindow.convertToScreen(view.frame)
|
||||
let presentationPoint = presentationFrame.origin
|
||||
let additionalYOffset = configuration.appearsBelowSender ? 0 : NSHeight(view.frame)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Ivan Sapozhnik on 16.04.20.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
public class MenuButton: NSButton, CALayerDelegate {
|
||||
private var containerLayer = CALayer()
|
||||
private var titleLayer = CATextLayer()
|
||||
|
||||
required public init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setup()
|
||||
}
|
||||
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
setup()
|
||||
}
|
||||
|
||||
func setup() {
|
||||
wantsLayer = true
|
||||
layer?.masksToBounds = false
|
||||
|
||||
layer?.cornerRadius = 4
|
||||
layer?.borderWidth = 1
|
||||
layer?.delegate = self
|
||||
|
||||
titleLayer.delegate = self
|
||||
if let scale = window?.backingScaleFactor {
|
||||
titleLayer.contentsScale = scale
|
||||
}
|
||||
|
||||
containerLayer.masksToBounds = false
|
||||
containerLayer.shadowOffset = NSSize.zero
|
||||
containerLayer.shadowColor = NSColor.clear.cgColor
|
||||
containerLayer.frame = NSMakeRect(0, 0, bounds.width, bounds.height)
|
||||
|
||||
containerLayer.addSublayer(titleLayer)
|
||||
layer?.addSublayer(containerLayer)
|
||||
|
||||
setupTitle()
|
||||
}
|
||||
|
||||
func setupTitle() {
|
||||
guard let font = font else { return }
|
||||
titleLayer.string = title
|
||||
titleLayer.font = font
|
||||
titleLayer.fontSize = font.pointSize
|
||||
|
||||
let attributes = [NSAttributedString.Key.font: font as Any]
|
||||
let titleSize = title.size(withAttributes: attributes)
|
||||
var titleRect = NSMakeRect(0, 0, titleSize.width, titleSize.height)
|
||||
|
||||
titleRect.origin.y = round((bounds.height - titleSize.height)/2)
|
||||
titleRect.origin.x = round((bounds.width - titleSize.width)/2)
|
||||
|
||||
titleLayer.frame = titleRect
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ public protocol Configuration {
|
||||
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 }
|
||||
@@ -73,6 +74,7 @@ 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 }
|
||||
@@ -110,6 +112,10 @@ open class MenuConfiguration: Configuration {
|
||||
return true
|
||||
}
|
||||
|
||||
open var presentingOffset: CGFloat {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
open var animationDuration: TimeInterval {
|
||||
return 0.15
|
||||
}
|
||||
@@ -186,6 +192,10 @@ open class MenuConfiguration: Configuration {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
open var menuItemHorizontalSpacing: CGFloat {
|
||||
return .grid1
|
||||
}
|
||||
|
||||
open var menuItemImageHeight: CGFloat? {
|
||||
return .grid3
|
||||
}
|
||||
|
||||
@@ -17,18 +17,58 @@ class MenuElement: NSView {
|
||||
private let configuration: Configuration
|
||||
private var checkmark: CheckmarkView!
|
||||
|
||||
init(text: String, image: NSImage? = nil, isSelected: Bool = false, isEnabled: Bool, 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 = isEnabled ? 1.0 : 0.5
|
||||
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)
|
||||
@@ -36,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)
|
||||
@@ -70,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, isEnabled: isEnabled)
|
||||
let control = makeHoverControl(update: label, leftImageView: lImageView, rightImageView: rImageView, isEnabled: menuItem.isEnabled, isSelected: isSelected)
|
||||
|
||||
addSubview(control)
|
||||
addSubview(stackView)
|
||||
@@ -102,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
|
||||
}
|
||||
|
||||
@@ -168,15 +203,15 @@ class MenuElement: NSView {
|
||||
return lImageView
|
||||
}
|
||||
|
||||
private func makeHoverControl(update label: NSTextField, leftImageView: NSImageView?, rightImageView: NSImageView?, isEnabled: Bool) -> 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,25 @@ import Cocoa
|
||||
|
||||
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 let id: UUID
|
||||
|
||||
public init(_ title: String, image: NSImage? = nil, isSelectable: Bool = true, action: (() -> 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()
|
||||
}
|
||||
|
||||
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: 211 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
Reference in New Issue
Block a user