287 lines
9.9 KiB
Swift
287 lines
9.9 KiB
Swift
import Library
|
|
import SwiftUI
|
|
#if os(iOS) || os(tvOS)
|
|
import UIKit
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// A custom view that looks like a navigation link but opens a dropdown menu
|
|
struct ProfileSelectorButton: View {
|
|
let items: [ProfilePreview]
|
|
let selectedItem: ProfilePreview?
|
|
let onSelect: (Int64) -> Void
|
|
|
|
init(
|
|
items: [ProfilePreview],
|
|
selectedItem: ProfilePreview?,
|
|
onSelect: @escaping (Int64) -> Void
|
|
) {
|
|
self.items = items
|
|
self.selectedItem = selectedItem
|
|
self.onSelect = onSelect
|
|
}
|
|
|
|
var body: some View {
|
|
#if os(iOS) || os(tvOS)
|
|
ProfileSelectorUIButton(
|
|
items: items,
|
|
selectedItem: selectedItem,
|
|
onSelect: onSelect
|
|
)
|
|
.frame(height: 44)
|
|
.selectorBackground()
|
|
#elseif os(macOS)
|
|
ProfileSelectorNSButton(
|
|
items: items,
|
|
selectedItem: selectedItem,
|
|
onSelect: onSelect
|
|
)
|
|
.frame(height: 32)
|
|
.selectorBackground()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - UIKit Implementation (iOS/tvOS)
|
|
|
|
#if os(iOS) || os(tvOS)
|
|
/// Custom UIButton subclass that allows customizing menu attachment point
|
|
private class MenuAttachmentButton: UIButton {
|
|
override func menuAttachmentPoint(for _: UIContextMenuConfiguration) -> CGPoint {
|
|
// Attach menu to bottom-leading corner of the button
|
|
CGPoint(x: 0, y: bounds.height)
|
|
}
|
|
}
|
|
|
|
/// UIViewRepresentable wrapper for UIButton with UIMenu
|
|
private struct ProfileSelectorUIButton: UIViewRepresentable {
|
|
let items: [ProfilePreview]
|
|
let selectedItem: ProfilePreview?
|
|
let onSelect: (Int64) -> Void
|
|
|
|
func makeUIView(context _: Context) -> MenuAttachmentButton {
|
|
let button = MenuAttachmentButton(type: .system)
|
|
button.showsMenuAsPrimaryAction = true
|
|
button.contentHorizontalAlignment = .fill
|
|
|
|
// Configure button appearance
|
|
var config = UIButton.Configuration.plain()
|
|
config.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)
|
|
button.configuration = config
|
|
|
|
updateButtonContent(button)
|
|
updateMenu(button)
|
|
|
|
return button
|
|
}
|
|
|
|
func updateUIView(_ button: MenuAttachmentButton, context _: Context) {
|
|
updateButtonContent(button)
|
|
updateMenu(button)
|
|
}
|
|
|
|
private func updateButtonContent(_ button: MenuAttachmentButton) {
|
|
// Remove existing subviews
|
|
for subview in button.subviews where subview is UIStackView {
|
|
subview.removeFromSuperview()
|
|
}
|
|
|
|
// Create content stack
|
|
let stackView = UIStackView()
|
|
stackView.axis = .horizontal
|
|
stackView.alignment = .center
|
|
stackView.distribution = .fill
|
|
stackView.spacing = 8
|
|
stackView.isUserInteractionEnabled = false
|
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
// Title label
|
|
let titleLabel = UILabel()
|
|
titleLabel.text = selectedItem?.name ?? "Select Profile"
|
|
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
|
titleLabel.textColor = .label
|
|
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
// Chevron image
|
|
let chevronConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
let chevronImage = UIImage(systemName: "chevron.up.chevron.down", withConfiguration: chevronConfig)
|
|
let chevronView = UIImageView(image: chevronImage)
|
|
chevronView.tintColor = .secondaryLabel
|
|
chevronView.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
stackView.addArrangedSubview(titleLabel)
|
|
stackView.addArrangedSubview(chevronView)
|
|
|
|
button.addSubview(stackView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
|
|
stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -14),
|
|
stackView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
|
])
|
|
}
|
|
|
|
private func updateMenu(_ button: MenuAttachmentButton) {
|
|
let actions = items.map { item in
|
|
UIAction(
|
|
title: item.name,
|
|
image: UIImage(systemName: item.type.iconName)
|
|
) { [item] _ in
|
|
onSelect(item.id)
|
|
}
|
|
}
|
|
|
|
button.menu = UIMenu(children: actions)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - AppKit Implementation (macOS)
|
|
|
|
#if os(macOS)
|
|
/// NSViewRepresentable wrapper for a clickable view with NSMenu
|
|
private struct ProfileSelectorNSButton: NSViewRepresentable {
|
|
let items: [ProfilePreview]
|
|
let selectedItem: ProfilePreview?
|
|
let onSelect: (Int64) -> Void
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let containerView = MenuContainerView()
|
|
containerView.coordinator = context.coordinator
|
|
updateContent(containerView)
|
|
context.coordinator.updateMenu(items: items, onSelect: onSelect)
|
|
return containerView
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {
|
|
guard let containerView = nsView as? MenuContainerView else { return }
|
|
updateContent(containerView)
|
|
context.coordinator.updateMenu(items: items, onSelect: onSelect)
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
private func updateContent(_ containerView: MenuContainerView) {
|
|
containerView.titleText = selectedItem?.name ?? "Select Profile"
|
|
}
|
|
|
|
private class MenuContainerView: NSView {
|
|
weak var coordinator: Coordinator?
|
|
private let titleLabel = NSTextField(labelWithString: "")
|
|
private let chevronView = NSImageView()
|
|
|
|
var titleText: String = "" {
|
|
didSet {
|
|
titleLabel.stringValue = titleText
|
|
}
|
|
}
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
setupViews()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
setupViews()
|
|
}
|
|
|
|
private func setupViews() {
|
|
titleLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
|
titleLabel.textColor = .labelColor
|
|
titleLabel.alignment = .left
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(titleLabel)
|
|
|
|
let chevronConfig = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
|
|
chevronView.image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
|
|
.withSymbolConfiguration(chevronConfig)
|
|
chevronView.contentTintColor = .secondaryLabelColor
|
|
chevronView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(chevronView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
|
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
|
chevronView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -8),
|
|
])
|
|
}
|
|
|
|
override func mouseDown(with _: NSEvent) {
|
|
coordinator?.showMenu(in: self)
|
|
}
|
|
|
|
override func resetCursorRects() {
|
|
addCursorRect(bounds, cursor: .pointingHand)
|
|
}
|
|
}
|
|
|
|
class Coordinator: NSObject {
|
|
private var menu = NSMenu()
|
|
private var onSelectCallback: ((Int64) -> Void)?
|
|
|
|
func updateMenu(items: [ProfilePreview], onSelect: @escaping (Int64) -> Void) {
|
|
menu.removeAllItems()
|
|
onSelectCallback = onSelect
|
|
|
|
for item in items {
|
|
let menuItem = NSMenuItem(
|
|
title: item.name,
|
|
action: #selector(menuItemSelected(_:)),
|
|
keyEquivalent: ""
|
|
)
|
|
menuItem.target = self
|
|
menuItem.tag = Int(item.id)
|
|
menuItem.image = NSImage(systemSymbolName: item.type.iconName, accessibilityDescription: nil)
|
|
menu.addItem(menuItem)
|
|
}
|
|
}
|
|
|
|
@objc private func menuItemSelected(_ sender: NSMenuItem) {
|
|
onSelectCallback?(Int64(sender.tag))
|
|
}
|
|
|
|
func showMenu(in view: NSView) {
|
|
let location = NSPoint(x: 0, y: view.bounds.height + 4)
|
|
menu.popUp(positioning: nil, at: location, in: view)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - ProfileType Extension
|
|
|
|
private extension ProfileType {
|
|
var iconName: String {
|
|
switch self {
|
|
case .local:
|
|
"doc.fill"
|
|
case .icloud:
|
|
"icloud.fill"
|
|
case .remote:
|
|
"cloud.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - View Extension
|
|
|
|
private extension View {
|
|
@ViewBuilder
|
|
func selectorBackground() -> some View {
|
|
if #available(iOS 26.0, macOS 26.0, tvOS 26.0, *) {
|
|
self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12))
|
|
} else {
|
|
background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color.secondary.opacity(0.1))
|
|
)
|
|
}
|
|
}
|
|
}
|