Skip to content

Commit

Permalink
Update PopupButton
Browse files Browse the repository at this point in the history
  • Loading branch information
ktiays authored and unixzii committed Jan 16, 2022
1 parent 212ab2d commit 396604a
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 26 deletions.
125 changes: 108 additions & 17 deletions Sources/CyanUI/PopupButton/PopupButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,58 @@ public struct PopupButton<S>: View where S: StringProtocol {

@Binding public var selection: S?

private let backgroundColor: Color

public init(contents: [S], selection: Binding<S?>) {
self.init(contents: contents, selection: selection, backgroundColor: .secondary)
}

private init(contents: [S], selection: Binding<S?>, backgroundColor: Color) {
self.contents = contents
_selection = selection
self.backgroundColor = backgroundColor
}

public var body: some View {
HStack {
Text(selection ?? "")
.padding(.trailing, 48)
.padding(.trailing, 12)
Spacer()
// Draw a rounded corner triangle.
CGSize(width: 25, height: 25) |> { size in
CGSize(width: 8, height: 4) |> { triangleSize in
Path { path in
let origin = CGPoint(x: (size.width - triangleSize.width) / 2, y: (size.height - triangleSize.height) / 2)
path.move(to: origin)
path.addLine(to: .init(x: origin.x + triangleSize.width, y: origin.y))
path.addLine(to: .init(x: origin.x + triangleSize.width / 2, y: origin.y + triangleSize.height))
path.closeSubpath()
} |> { trianglePath in
Color(nsColor: .init(lightColor: .init(red: 30.0 / 255, green: 30.0 / 255, blue: 30.0 / 255, alpha: 1),
darkColor: .init(red: 212.0 / 255, green: 212.0 / 255, blue: 212.0 / 255, alpha: 1))) |> { foregroundColor in
ZStack {
trianglePath.fill(foregroundColor)
trianglePath.stroke(foregroundColor, style: .init(lineWidth: 3, lineCap: .round, lineJoin: .round))
}
.aspectRatio(1, contentMode: .fit)
.frame(width: size.width)
}
}
}
}
}
.background(Color.systemBlue.opacity(0.3))
.padding(.leading, 10)
.padding(.trailing, 4)
.padding(.vertical, 5)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(_PopupButtonTrigger(contents: contents, selection: $selection))
}

public func backgroundColor(_ color: Color) -> PopupButton {
.init(contents: self.contents, selection: _selection, backgroundColor: color)
}

}

// MARK: - Trigger
Expand All @@ -45,17 +83,29 @@ struct _PopupButtonTrigger<S>: NSViewRepresentable where S: StringProtocol {
}

func updateNSView(_ nsView: _PopupButtonTriggerView<S>, context: Context) {
nsView.reloadDataSource(contents)
nsView.popupListContent = contents
}

}

@available(macOS 12.0, *)
class _PopupButtonTriggerView<S>: NSView where S: StringProtocol {

private var popupListContent: [S] = []
/// The contents that acts as the data source of the pop-up menu.
var popupListContent: [S] = [] {
didSet {
reloadContent()
}
}
@Binding private var selection: S?

private var currentSelectedIndex: Int? {
if let selection = selection {
return popupListContent.firstIndex(of: selection)
}
return nil
}

private var localMonitor: Any?
private var globalMonitor: Any?

Expand All @@ -77,17 +127,29 @@ class _PopupButtonTriggerView<S>: NSView where S: StringProtocol {
}
}

/// The position where the mouse click when the menu pops up.
private var presentedPoint: NSPoint?

private func handleMouseEvent(_ event: NSEvent) {
switch event.type {
case .leftMouseDown:
if let window = popupWindow {
let mouseLocation = window.convertPoint(toScreen: event.locationInWindow)
if !window.frame.contains(mouseLocation) {
// removePopupWindow()
removePopupWindow()
}
}
case .leftMouseUp:
mouseUpSubject.send(NSEvent.mouseLocation)
if popupWindow == nil { return }
let currentMouseLocation = NSEvent.mouseLocation
if let presentedPoint = presentedPoint {
if !presentedPoint.equalTo(currentMouseLocation) {
removePopupWindow()
} else { return }
} else {
removePopupWindow()
}
mouseUpSubject.send(currentMouseLocation)
default: break
}
}
Expand All @@ -103,42 +165,71 @@ class _PopupButtonTriggerView<S>: NSView where S: StringProtocol {
}
}

private let menuPadding: CGFloat = 16
private let menuInnerPadding: CGFloat = 4
private let menuItemHeight: CGFloat = 28

override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)

if isPresented { return }

let window = NSWindow(contentViewController: NSHostingController(rootView: _PopupList(contents: popupListContent, selection: $selection, mouseUpEventPublisher: mouseUpSubject.eraseToAnyPublisher())))
let window = NSWindow(contentViewController: NSHostingController(rootView: _PopupList(contents: popupListContent, selection: _selection, mouseUpEventPublisher: mouseUpSubject.eraseToAnyPublisher())))
window.isReleasedWhenClosed = false
window.styleMask = .borderless
window.hasShadow = false
window.backgroundColor = .clear
self.window?.addChildWindow(window, ordered: .above)
window.contentView?.layout()
let contentSize = window.contentView?.fittingSize ?? .zero
let location = NSEvent.mouseLocation
window.setFrame(.init(origin: location, size: .init(width: max(contentSize.width, bounds.width + 20), height: contentSize.height)), display: true)

updateWindow(window)

popupWindow = window
}

override func mouseUp(with event: NSEvent) {
super.mouseUp(with: event)

// Save the location of the click that triggered the menu popup.
presentedPoint = NSEvent.mouseLocation
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(280)) { [weak self] in
self?.presentedPoint = nil
}
}

private func removePopupWindow() {
if let window = popupWindow {
window.ignoresMouseEvents = true
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.4
window.animator().alphaValue = 0
popupWindow = nil
} completionHandler: {
window.close()
}
}
}

func reloadDataSource(_ dataSource: [S]) {
self.popupListContent = dataSource
private func updateWindow(_ window: NSWindow) {
window.contentView?.layout()
let contentSize = window.contentView?.fittingSize ?? .zero

if let frameFromWindow = self.window?.convertToScreen(convert(frame, to: nil)) {
window.setFrame(.init(x: frameFromWindow.minX - menuPadding - menuInnerPadding,
y: frameFromWindow.minY + frameFromWindow.height / 2 - menuPadding - menuInnerPadding - menuItemHeight * (max(popupListContent.count, 1) - (currentSelectedIndex ?? 0) - 0.5),
width: max(contentSize.width, bounds.width + (menuPadding + menuInnerPadding) * 2),
height: contentSize.height),
display: true)
}
}

private func reloadContent() {
guard let window = popupWindow else {
return
}
if let controller = window.contentViewController as? NSHostingController<_PopupList<S>> {
controller.rootView = .init(
contents: popupListContent,
selection: _selection,
mouseUpEventPublisher: mouseUpSubject.eraseToAnyPublisher()
)
updateWindow(window)
}
}

}
29 changes: 20 additions & 9 deletions Sources/CyanUI/PopupButton/PopupList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,25 @@ struct _PopupList<Content>: View where Content: StringProtocol {
@Binding var selection: Content?

@State private var highlighted: Content?
@State private var isAnimatedIn = false

private let mouseUpEventPublisher: AnyPublisher<NSPoint, Never>

init(contents: [Content], selection: Binding<Content?>, mouseUpEventPublisher: AnyPublisher<NSPoint, Never>) {
_selection = selection
self.mouseUpEventPublisher = mouseUpEventPublisher
self.contents = contents.enumerated().filter { index, element in
contents.firstIndex(of: element) == index
}.map { $0.element }
_selection = selection
self.mouseUpEventPublisher = mouseUpEventPublisher
}

var body: some View {
VStack(spacing: 0) {
if contents.isEmpty {
Rectangle()
.foregroundColor(.clear)
.frame(height: 28)
}
ForEach(contents, id: \.self) { item in
ZStack {
Color(nsColor: .labelColor)
Expand Down Expand Up @@ -62,7 +68,14 @@ struct _PopupList<Content>: View where Content: StringProtocol {
}
.cornerRadius(12)
.shadow(color: .black.opacity(0.12), radius: 10)
.padding()
.padding(16)
.scaleEffect(isAnimatedIn ? 1 : 0.5, anchor: .center)
.opacity(isAnimatedIn ? 1 : 0)
.onAppear {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
isAnimatedIn = true
}
}
}

}
Expand Down Expand Up @@ -134,13 +147,11 @@ final class _PopupItemView<S>: NSView where S: StringProtocol {
}
}

/// Updates the selected item of the menu according to the position of the mouse click.
/// - Parameter mousePoint: The position where the mouse event is triggered.
func updateSelection(with mousePoint: NSPoint) {
if let windowContentBounds = window?.contentView?.bounds {
var itemFrame = convert(frame, to: window?.contentView)
itemFrame.origin.y = windowContentBounds.height - itemFrame.minY - itemFrame.height
if window?.convertToScreen(itemFrame).contains(mousePoint) == true {
selection = itemTag
}
if window?.convertToScreen(convert(frame, to: nil)).contains(mousePoint) == true {
selection = itemTag
}
}

Expand Down

0 comments on commit 396604a

Please sign in to comment.