1563 lines
66 KiB
Swift
1563 lines
66 KiB
Swift
//
|
|
// ToolboxViewController.swift
|
|
// VoidLink
|
|
//
|
|
// Created by True砖家 on 2024/7/23.
|
|
// Copyright © 2024 True砖家 on Bilibili. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
@objc protocol ToolboxSpecialEntryDelegate: NSObjectProtocol {
|
|
@objc optional func openWidgetLayoutTool()
|
|
@objc optional func openWidgetProfileTable(pickProfile: Bool)
|
|
@objc optional func bringUpSoftKeyboard()
|
|
@objc optional func enterPip()
|
|
@objc optional func toggleStatsOverlay()
|
|
@objc optional func disconnectAndQuitApp()
|
|
}
|
|
|
|
@objc public class ToolboxViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate, WidgetPickerViewControllerDelegate {
|
|
|
|
private enum ToolboxEntry {
|
|
case special(id: String, title: String)
|
|
case command(offset: Int, command: RemoteCommand)
|
|
|
|
var isSpecial: Bool {
|
|
if case .special = self { return true }
|
|
return false
|
|
}
|
|
|
|
var orderIdentifier: String {
|
|
switch self {
|
|
case .special(let id, _):
|
|
return "special:\(id)"
|
|
case .command(_, let command):
|
|
return "command:\(command.identifier)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum Metrics {
|
|
static let contentCornerRadius: CGFloat = 22
|
|
static let cardCornerRadius: CGFloat = 16
|
|
static let buttonCornerRadius: CGFloat = 13
|
|
}
|
|
|
|
private static let entryOrderDefaultsKey = "ToolboxViewController.entryOrder"
|
|
private static let controllerNavigationHighlightDefaultsKey = "ToolboxViewController.controllerNavigationHighlightedEntry"
|
|
|
|
@objc weak var specialEntryDelegate: ToolboxSpecialEntryDelegate?
|
|
|
|
// Kept for Objective-C/source compatibility with older callers. The UI is now collectionView.
|
|
public let tableView = UITableView(frame: .zero)
|
|
public private(set) var collectionView: UICollectionView!
|
|
|
|
private let addButton = UIButton(type: .system)
|
|
private let deleteButton = UIButton(type: .system)
|
|
private let editButton = UIButton(type: .system)
|
|
private let pinButton = UIButton(type: .system)
|
|
private let titleLabel = UILabel()
|
|
private let subtitleLabel = UILabel()
|
|
private let toolbarView = UIView()
|
|
private let toolbarStackView = UIStackView()
|
|
private var contentView = UIView()
|
|
private var pinButtonWidthConstraint: NSLayoutConstraint?
|
|
private var layoutConstraints: [NSLayoutConstraint] = []
|
|
private var toolbarButtonConstraints: [NSLayoutConstraint] = []
|
|
private var pendingTransitionSize: CGSize?
|
|
|
|
@objc public var specialEntries: NSMutableArray = ["widgetSwitchTool", "widgetLayoutTool", "bringUpSoftKeyboard", "enterPip", "toggleStatsOverlay", "disconnectAndQuitApp"]
|
|
private let specialEntryAliasDic: [String: String] = [
|
|
"widgetSwitchTool": "=toolboxSwitchProfile",
|
|
"widgetLayoutTool": "=toolboxWidgetLayout",
|
|
"bringUpSoftKeyboard": "=toolboxSoftKeyboard",
|
|
"enterPip": "=enterPiP",
|
|
"toggleStatsOverlay": "=toolboxStatsOverlay",
|
|
"disconnectAndQuitApp": "=toolboxDisconnectQuit"
|
|
]
|
|
|
|
private var entries: [ToolboxEntry] = []
|
|
private var viewPinned = false
|
|
private var selectedCommandOffsets = Set<Int>()
|
|
private var lastSelectedCommandOffset: Int?
|
|
private var controllerNavigationReorderActive = false
|
|
private var controllerNavigationReorderUpdateInFlight = false
|
|
private var pendingControllerNavigationReorderMove: (horizontal: Int, vertical: Int)?
|
|
private var controllerNavigationReorderSnapshotView: UIView?
|
|
private var controllerNavigationReorderHiddenIndexPath: IndexPath?
|
|
private var controllerNavigationReorderPressBeganAt: CFTimeInterval?
|
|
private var controllerNavigationPersistOrderWhenReorderSettles = false
|
|
private var controllerNavigationDeleteDoublePressWindowOpen = false
|
|
private var controllerNavigationDeleteDoublePressToken = 0
|
|
private var controllerNavigationExecuteDismissWindowOpen = false
|
|
private var controllerNavigationExecuteDismissToken = 0
|
|
private var controllerNavigationExecuteSuppressNextRelease = false
|
|
private var isEditingMode = false {
|
|
didSet {
|
|
if !isEditingMode {
|
|
selectedCommandOffsets.removeAll()
|
|
}
|
|
updateEditingMode()
|
|
collectionView?.reloadData()
|
|
}
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
setupViews()
|
|
setupConstraints()
|
|
reloadTableView()
|
|
|
|
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissSelf))
|
|
tap.delegate = self
|
|
view.addGestureRecognizer(tap)
|
|
|
|
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleCollectionLongPress(_:)))
|
|
longPress.minimumPressDuration = 0.25
|
|
collectionView.addGestureRecognizer(longPress)
|
|
|
|
CommandManager.shared.viewController = self
|
|
}
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
if #available(iOS 13.0, *), ControllerUtil.primaryGCController != nil {
|
|
ControllerNavigator.setUINavigationDelegate(self)
|
|
ControllerNavigator.restoreUINavigationHighlight()
|
|
}
|
|
}
|
|
|
|
public override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
|
|
if #available(iOS 13.0, *) {
|
|
if controllerNavigationReorderActive {
|
|
controllerNavigationReorderActive = false
|
|
pendingControllerNavigationReorderMove = nil
|
|
}
|
|
controllerNavigationReorderUpdateInFlight = false
|
|
controllerNavigationPersistOrderWhenReorderSettles = false
|
|
closeControllerNavigationDeleteDoublePressWindow()
|
|
closeControllerNavigationExecuteDismissWindow()
|
|
controllerNavigationExecuteSuppressNextRelease = false
|
|
controllerNavigationReorderSnapshotView?.removeFromSuperview()
|
|
controllerNavigationReorderSnapshotView = nil
|
|
controllerNavigationReorderHiddenIndexPath = nil
|
|
updateVisibleReorderHiddenCells()
|
|
persistControllerNavigationHighlight()
|
|
ControllerNavigator.restorePreviousUINavigationDelegate(ifCurrentDelegateIs: self)
|
|
}
|
|
}
|
|
|
|
public override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
collectionView.collectionViewLayout.invalidateLayout()
|
|
}
|
|
|
|
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
|
|
pendingTransitionSize = size
|
|
setupConstraints()
|
|
coordinator.animate(alongsideTransition: { _ in
|
|
self.updatePinVisibility()
|
|
self.collectionView.collectionViewLayout.invalidateLayout()
|
|
self.view.layoutIfNeeded()
|
|
}, completion: { _ in
|
|
self.pendingTransitionSize = nil
|
|
self.setupConstraints()
|
|
self.updatePinVisibility()
|
|
self.collectionView.collectionViewLayout.invalidateLayout()
|
|
self.view.layoutIfNeeded()
|
|
})
|
|
}
|
|
|
|
private func setupViews() {
|
|
view.backgroundColor = .clear
|
|
|
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.layer.cornerRadius = Metrics.contentCornerRadius
|
|
contentView.layer.masksToBounds = true
|
|
view.addSubview(contentView)
|
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.text = "Toolbox".localized
|
|
titleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 18 : 24, weight: .bold)
|
|
titleLabel.textAlignment = .center
|
|
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
subtitleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 11 : 13, weight: .medium)
|
|
subtitleLabel.textAlignment = .center
|
|
subtitleLabel.numberOfLines = 1
|
|
subtitleLabel.adjustsFontSizeToFitWidth = true
|
|
subtitleLabel.minimumScaleFactor = 0.75
|
|
|
|
let layout = UICollectionViewFlowLayout()
|
|
layout.scrollDirection = .vertical
|
|
layout.minimumInteritemSpacing = gridSpacing()
|
|
layout.minimumLineSpacing = gridSpacing()
|
|
|
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
|
collectionView.backgroundColor = collectionSurfaceColor()
|
|
collectionView.layer.cornerRadius = PublicUtils.isIPhone ? 14 : 18
|
|
collectionView.layer.masksToBounds = true
|
|
collectionView.alwaysBounceVertical = true
|
|
collectionView.dataSource = self
|
|
collectionView.delegate = self
|
|
collectionView.allowsSelection = true
|
|
collectionView.allowsMultipleSelection = false
|
|
collectionView.contentInsetAdjustmentBehavior = .never
|
|
collectionView.register(ToolboxCardCell.self, forCellWithReuseIdentifier: ToolboxCardCell.reuseIdentifier)
|
|
|
|
toolbarView.translatesAutoresizingMaskIntoConstraints = false
|
|
toolbarView.layer.cornerRadius = Metrics.buttonCornerRadius
|
|
toolbarView.layer.masksToBounds = true
|
|
|
|
toolbarStackView.translatesAutoresizingMaskIntoConstraints = false
|
|
toolbarStackView.axis = .horizontal
|
|
toolbarStackView.alignment = .fill
|
|
toolbarStackView.distribution = .fill
|
|
toolbarStackView.spacing = PublicUtils.isIPhone ? 6 : 8
|
|
|
|
configureToolbarButton(pinButton, title: "", symbolName: "pin")
|
|
configureToolbarButton(deleteButton, title: "Delete".localized, symbolName: "minus.circle")
|
|
configureToolbarButton(addButton, title: "Add".localized, symbolName: "plus.circle")
|
|
configureToolbarButton(editButton, title: "Edit".localized, symbolName: "pencil")
|
|
|
|
editButton.addTarget(self, action: #selector(editButtonTapped), for: .touchUpInside)
|
|
deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside)
|
|
addButton.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside)
|
|
pinButton.addTarget(self, action: #selector(pinButtonTapped), for: .touchUpInside)
|
|
|
|
contentView.addSubview(collectionView)
|
|
contentView.addSubview(toolbarView)
|
|
toolbarView.addSubview(toolbarStackView)
|
|
[pinButton, deleteButton, addButton, editButton].forEach(toolbarStackView.addArrangedSubview)
|
|
|
|
updateTheme()
|
|
updatePinVisibility()
|
|
updateEditingMode()
|
|
}
|
|
|
|
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
updateTheme()
|
|
collectionView.visibleCells.forEach { cell in
|
|
(cell as? ToolboxCardCell)?.applyTheme()
|
|
}
|
|
}
|
|
|
|
private func updateTheme() {
|
|
let darkMode = traitCollection.userInterfaceStyle == .dark
|
|
contentView.backgroundColor = darkMode
|
|
? UIColor(white: 0.09, alpha: 0.88)
|
|
: UIColor(white: 0.94, alpha: 0.8)
|
|
toolbarView.backgroundColor = darkMode
|
|
? UIColor(white: 1.0, alpha: 0.08)
|
|
: UIColor(white: 1.0, alpha: 0.41)
|
|
titleLabel.textColor = darkMode ? .white : UIColor(white: 0.08, alpha: 1)
|
|
subtitleLabel.textColor = darkMode ? UIColor(white: 1, alpha: 0.54) : UIColor(white: 0, alpha: 0.46)
|
|
collectionView?.backgroundColor = collectionSurfaceColor()
|
|
updateToolbarColors()
|
|
}
|
|
|
|
private func updateToolbarColors() {
|
|
let darkMode = traitCollection.userInterfaceStyle == .dark
|
|
let normalTint = darkMode ? UIColor(white: 1, alpha: 0.86) : UIColor(white: 0.10, alpha: 0.86)
|
|
let disabledTint = darkMode ? UIColor(white: 1, alpha: 0.25) : UIColor(white: 0, alpha: 0.24)
|
|
|
|
[addButton, deleteButton, editButton, pinButton].forEach { button in
|
|
button.tintColor = button.isEnabled ? normalTint : disabledTint
|
|
button.setTitleColor(button.isEnabled ? normalTint : disabledTint, for: .normal)
|
|
button.backgroundColor = .clear
|
|
}
|
|
|
|
if viewPinned {
|
|
pinButton.tintColor = UIColor.systemOrange
|
|
pinButton.backgroundColor = UIColor.systemOrange.withAlphaComponent(darkMode ? 0.24 : 0.18)
|
|
}
|
|
}
|
|
|
|
@objc public func setupConstraints() {
|
|
NSLayoutConstraint.deactivate(layoutConstraints)
|
|
layoutConstraints.removeAll()
|
|
|
|
let topInset: CGFloat = PublicUtils.isIPhone ? 10 : 18
|
|
let sideInset: CGFloat = PublicUtils.isIPhone ? 10 : 18
|
|
let toolbarVerticalInset: CGFloat = PublicUtils.isIPhone ? 5 : 11
|
|
let toolbarHeight: CGFloat = PublicUtils.isIPhone ? 36 : 54
|
|
|
|
layoutConstraints = [
|
|
contentView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: contentWidthMultiplier()),
|
|
contentView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: PublicUtils.isIPhone ? 0.93 : 0.93),
|
|
contentView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
// contentView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
|
|
|
|
contentView.topAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
|
|
contentView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: PublicUtils.isIPhone ? 3 : -5),
|
|
|
|
toolbarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sideInset),
|
|
toolbarView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sideInset),
|
|
toolbarView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -toolbarVerticalInset),
|
|
toolbarView.heightAnchor.constraint(equalToConstant: toolbarHeight),
|
|
|
|
toolbarStackView.leadingAnchor.constraint(equalTo: toolbarView.leadingAnchor, constant: PublicUtils.isIPhone ? 6 : 8),
|
|
toolbarStackView.trailingAnchor.constraint(equalTo: toolbarView.trailingAnchor, constant: PublicUtils.isIPhone ? -6 : -8),
|
|
toolbarStackView.topAnchor.constraint(equalTo: toolbarView.topAnchor, constant: PublicUtils.isIPhone ? 3 : 8),
|
|
toolbarStackView.bottomAnchor.constraint(equalTo: toolbarView.bottomAnchor, constant: PublicUtils.isIPhone ? -3 : -8),
|
|
|
|
collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sideInset),
|
|
collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sideInset),
|
|
collectionView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: topInset),
|
|
collectionView.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: -toolbarVerticalInset),
|
|
]
|
|
NSLayoutConstraint.activate(layoutConstraints)
|
|
|
|
setupToolbarConstraints()
|
|
}
|
|
|
|
private func setupToolbarConstraints() {
|
|
NSLayoutConstraint.deactivate(toolbarButtonConstraints)
|
|
toolbarButtonConstraints.removeAll()
|
|
|
|
[editButton, deleteButton, pinButton, addButton].forEach {
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
}
|
|
|
|
let pinWidth: CGFloat = PublicUtils.isIPhone ? 44 : 52
|
|
|
|
pinButtonWidthConstraint = pinButton.widthAnchor.constraint(equalToConstant: pinWidth)
|
|
|
|
toolbarButtonConstraints = [
|
|
deleteButton.widthAnchor.constraint(equalTo: editButton.widthAnchor),
|
|
pinButtonWidthConstraint!,
|
|
addButton.widthAnchor.constraint(equalTo: editButton.widthAnchor),
|
|
]
|
|
NSLayoutConstraint.activate(toolbarButtonConstraints)
|
|
}
|
|
|
|
private func contentWidthMultiplier() -> CGFloat {
|
|
if PublicUtils.isIPhone {
|
|
return isLayoutLandscape() ? 0.62 : 0.88
|
|
}
|
|
return isLayoutLandscape() ? 0.595 : 0.86
|
|
}
|
|
|
|
private func isLayoutLandscape() -> Bool {
|
|
if let pendingTransitionSize {
|
|
return pendingTransitionSize.width > pendingTransitionSize.height
|
|
}
|
|
return PublicUtils.viewIsLandscape(view)
|
|
}
|
|
|
|
private func gridSpacing() -> CGFloat {
|
|
return PublicUtils.isIPhone ? 8 : 12
|
|
}
|
|
|
|
private func gridInsets() -> UIEdgeInsets {
|
|
let inset = PublicUtils.isIPhone ? 2.0 : 4.0
|
|
return UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
|
}
|
|
|
|
private func collectionSurfaceColor() -> UIColor {
|
|
if traitCollection.userInterfaceStyle == .dark {
|
|
return UIColor(white: 1.0, alpha: 0.035)
|
|
}
|
|
return UIColor(white: 1.0, alpha: 0.18)
|
|
}
|
|
|
|
private func columnCount(for width: CGFloat) -> Int {
|
|
#if os(tvOS)
|
|
return width >= 780 ? 4 : 3
|
|
#else
|
|
if PublicUtils.isIPhone {
|
|
return isLayoutLandscape() ? 3 : 2
|
|
}
|
|
return width >= 760 ? 4 : 3
|
|
#endif
|
|
}
|
|
|
|
private func makeEntries() -> [ToolboxEntry] {
|
|
let specialEntries = specialEntries.compactMap { value -> ToolboxEntry? in
|
|
guard let id = value as? String else { return nil }
|
|
let title = specialEntryAliasDic[id] ?? id
|
|
return .special(id: id, title: title)
|
|
}
|
|
|
|
let commandEntries = CommandManager.shared.getAllCommands().enumerated().map {
|
|
ToolboxEntry.command(offset: $0.offset, command: $0.element)
|
|
}
|
|
|
|
let defaultEntries = specialEntries + commandEntries
|
|
guard let persistedOrder = UserDefaults.standard.stringArray(forKey: Self.entryOrderDefaultsKey),
|
|
!persistedOrder.isEmpty else {
|
|
return defaultEntries
|
|
}
|
|
|
|
let entriesByID = Dictionary(uniqueKeysWithValues: defaultEntries.map { ($0.orderIdentifier, $0) })
|
|
var usedIDs = Set<String>()
|
|
var orderedEntries: [ToolboxEntry] = []
|
|
|
|
for identifier in persistedOrder {
|
|
guard let entry = entriesByID[identifier],
|
|
!usedIDs.contains(identifier) else {
|
|
continue
|
|
}
|
|
orderedEntries.append(entry)
|
|
usedIDs.insert(identifier)
|
|
}
|
|
|
|
for entry in defaultEntries where !usedIDs.contains(entry.orderIdentifier) {
|
|
orderedEntries.append(entry)
|
|
}
|
|
|
|
return orderedEntries
|
|
}
|
|
|
|
private func entriesPreservingCurrentOrder(includeMissingEntries: Bool) -> [ToolboxEntry] {
|
|
let currentOrder = entries.map(\.orderIdentifier)
|
|
let specialEntries = specialEntries.compactMap { value -> ToolboxEntry? in
|
|
guard let id = value as? String else { return nil }
|
|
let title = specialEntryAliasDic[id] ?? id
|
|
return .special(id: id, title: title)
|
|
}
|
|
let commandEntries = CommandManager.shared.getAllCommands().enumerated().map {
|
|
ToolboxEntry.command(offset: $0.offset, command: $0.element)
|
|
}
|
|
let latestEntries = specialEntries + commandEntries
|
|
let latestEntriesByID = Dictionary(uniqueKeysWithValues: latestEntries.map { ($0.orderIdentifier, $0) })
|
|
|
|
var usedIDs = Set<String>()
|
|
var reconciledEntries: [ToolboxEntry] = []
|
|
|
|
for identifier in currentOrder {
|
|
guard let entry = latestEntriesByID[identifier],
|
|
!usedIDs.contains(identifier) else {
|
|
continue
|
|
}
|
|
reconciledEntries.append(entry)
|
|
usedIDs.insert(identifier)
|
|
}
|
|
|
|
if includeMissingEntries {
|
|
for entry in latestEntries where !usedIDs.contains(entry.orderIdentifier) {
|
|
reconciledEntries.append(entry)
|
|
}
|
|
}
|
|
|
|
return reconciledEntries
|
|
}
|
|
|
|
private func configureToolbarButton(_ button: UIButton, title: String, symbolName: String) {
|
|
button.layer.cornerRadius = Metrics.buttonCornerRadius
|
|
button.layer.masksToBounds = true
|
|
button.titleLabel?.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 11 : 15, weight: .semibold)
|
|
button.setTitle(title, for: .normal)
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: PublicUtils.isIPhone ? 8 : 11, bottom: 0, right: PublicUtils.isIPhone ? 8 : 12)
|
|
|
|
let toolbarImage: UIImage?
|
|
|
|
if #available(iOS 13.0, tvOS 13.0, *) {
|
|
let config = UIImage.SymbolConfiguration(pointSize: PublicUtils.isIPhone ? 9 : 11, weight: .semibold)
|
|
toolbarImage = UIImage(systemName: symbolName, withConfiguration: config) ?? UIImage(named: symbolName)
|
|
} else {
|
|
toolbarImage = UIImage(named: symbolName)
|
|
}
|
|
|
|
if let image = toolbarImage {
|
|
button.setImage(image, for: .normal)
|
|
button.imageView?.contentMode = .scaleAspectFit
|
|
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -2, bottom: 0, right: title.isEmpty ? -2 : 6)
|
|
}
|
|
}
|
|
|
|
private func updateEditingMode() {
|
|
collectionView?.allowsMultipleSelection = isEditingMode
|
|
addButton.isEnabled = isEditingMode
|
|
deleteButton.isEnabled = isEditingMode && !selectedCommandOffsets.isEmpty
|
|
editButton.setTitle(isEditingMode ? "Done".localized : "Edit".localized, for: .normal)
|
|
subtitleLabel.text = isEditingMode
|
|
? "Select shortcuts to delete".localized
|
|
: "Tap a card to send a command".localized
|
|
updateToolbarColors()
|
|
}
|
|
|
|
private func updatePinVisibility() {
|
|
let shouldHide = PublicUtils.isIPhone && !isLayoutLandscape()
|
|
pinButton.isHidden = shouldHide
|
|
pinButtonWidthConstraint?.constant = shouldHide ? 0 : (PublicUtils.isIPhone ? 44 : 52)
|
|
toolbarStackView.setCustomSpacing(shouldHide ? 0 : toolbarStackView.spacing, after: deleteButton)
|
|
toolbarStackView.setCustomSpacing(shouldHide ? 0 : toolbarStackView.spacing, after: pinButton)
|
|
}
|
|
|
|
@objc private func pinButtonTapped() {
|
|
viewPinned.toggle()
|
|
updateToolbarColors()
|
|
}
|
|
|
|
@available(iOS 13.0, *)
|
|
public func widgetPickerViewController(_ controller: WidgetPickerViewController, didCreateWidget payload: NSDictionary) {
|
|
createEntry(cmdString: payload["cmdString"] as? String, alias: payload["buttonLabel"] as? String)
|
|
}
|
|
|
|
private func createEntry(cmdString: String?, alias: String?) {
|
|
let cmdString = cmdString ?? ""
|
|
let alias = alias ?? cmdString
|
|
let previousOffset = lastSelectedCommandOffset
|
|
let shouldPreserveEditingOrder = isEditingMode
|
|
|
|
let newCommand = RemoteCommand(cmdString: cmdString, alias: alias)
|
|
let addCommandSucceeded = CommandManager.shared.addCommand(newCommand)
|
|
|
|
if addCommandSucceeded {
|
|
if shouldPreserveEditingOrder {
|
|
entries = entriesPreservingCurrentOrder(includeMissingEntries: true)
|
|
collectionView.reloadData()
|
|
}
|
|
let newOffset = max(CommandManager.shared.getAllCommands().count - 1, 0)
|
|
if isEditingMode {
|
|
scrollToCommand(offset: newOffset)
|
|
} else {
|
|
selectCommand(offset: newOffset, scrollPosition: .centeredVertically)
|
|
}
|
|
} else if let previousOffset {
|
|
if isEditingMode {
|
|
scrollToCommand(offset: previousOffset)
|
|
} else {
|
|
selectCommand(offset: previousOffset, scrollPosition: .centeredVertically)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func addButtonTapped() {
|
|
if #available(iOS 13.0, *) {
|
|
let pickerViewController = WidgetPickerViewController()
|
|
pickerViewController.delegate = self
|
|
pickerViewController.keyboardPickerMode = .shortcutPicker
|
|
pickerViewController.shortcutPickerNeedAlias = true
|
|
pickerViewController.tabIdentifiers = ["keyboard", "shortcuts"]
|
|
pickerViewController.initialTabIdentifier = "keyboard"
|
|
pickerViewController.presentOverFullScreen(from: self)
|
|
return
|
|
}
|
|
|
|
let alert = UIAlertController(title: "New Command".localized, message: "Enter a new command and alias".localized, preferredStyle: .alert)
|
|
alert.addTextField { $0.placeholder = "Command".localized }
|
|
alert.addTextField { $0.placeholder = "Alias (optional)".localized }
|
|
alert.textFields?[0].keyboardType = .asciiCapable
|
|
alert.textFields?[0].autocorrectionType = .no
|
|
alert.textFields?[0].spellCheckingType = .no
|
|
alert.textFields?[1].keyboardType = .default
|
|
alert.textFields?[1].autocorrectionType = .no
|
|
alert.textFields?[1].spellCheckingType = .no
|
|
|
|
let submitAction = UIAlertAction(title: "Add".localized, style: .default) { [unowned alert] _ in
|
|
self.createEntry(cmdString: alert.textFields?[0].text ?? "", alias: alert.textFields?[1].text)
|
|
}
|
|
|
|
alert.addAction(submitAction)
|
|
alert.addAction(UIAlertAction(title: "Cancel".localized, style: .cancel))
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
@objc private func deleteButtonTapped() {
|
|
guard !selectedCommandOffsets.isEmpty else { return }
|
|
|
|
let sortedOffsets = selectedCommandOffsets.sorted(by: >)
|
|
sortedOffsets.forEach { CommandManager.shared.deleteCommand(at: $0) }
|
|
|
|
let nextOffset = max((sortedOffsets.min() ?? 0) - 1, 0)
|
|
selectedCommandOffsets.removeAll()
|
|
entries = entriesPreservingCurrentOrder(includeMissingEntries: false)
|
|
collectionView.reloadData()
|
|
updateEditingMode()
|
|
|
|
if CommandManager.shared.getAllCommands().indices.contains(nextOffset) {
|
|
scrollToCommand(offset: nextOffset)
|
|
}
|
|
}
|
|
|
|
@objc private func editButtonTapped() {
|
|
if isEditingMode {
|
|
persistEntryOrder()
|
|
}
|
|
isEditingMode.toggle()
|
|
}
|
|
|
|
@objc public func reloadTableView() {
|
|
entries = makeEntries()
|
|
collectionView?.reloadData()
|
|
updateEditingMode()
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
return entries.count
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ToolboxCardCell.reuseIdentifier, for: indexPath)
|
|
guard let cardCell = cell as? ToolboxCardCell else { return cell }
|
|
|
|
configure(cardCell, at: indexPath)
|
|
cardCell.isHidden = indexPath == controllerNavigationReorderHiddenIndexPath
|
|
return cardCell
|
|
}
|
|
|
|
private func configure(_ cardCell: ToolboxCardCell, at indexPath: IndexPath) {
|
|
let entry = entries[indexPath.item]
|
|
switch entry {
|
|
case .special(let id, let title):
|
|
cardCell.configure(
|
|
title: title.localized,
|
|
subtitle: specialSubtitle(for: id),
|
|
symbolName: specialSymbolName(for: id),
|
|
kind: .special,
|
|
selectedForEditing: false,
|
|
editing: isEditingMode
|
|
)
|
|
case .command(let offset, let command):
|
|
cardCell.configure(
|
|
title: command.alias.localized,
|
|
subtitle: command.cmdString,
|
|
symbolName: "keyboard",
|
|
kind: .command,
|
|
selectedForEditing: selectedCommandOffsets.contains(offset),
|
|
editing: isEditingMode
|
|
)
|
|
}
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
activateEntry(at: indexPath)
|
|
}
|
|
|
|
private func activateEntry(at indexPath: IndexPath, dismissAfterAction: Bool = true) {
|
|
guard entries.indices.contains(indexPath.item) else { return }
|
|
|
|
switch entries[indexPath.item] {
|
|
case .special(let id, _):
|
|
collectionView.deselectItem(at: indexPath, animated: false)
|
|
if !isEditingMode {
|
|
flashCell(at: indexPath)
|
|
handleSpecialEntry(id: id)
|
|
}
|
|
|
|
case .command(let offset, let command):
|
|
lastSelectedCommandOffset = offset
|
|
if isEditingMode {
|
|
if selectedCommandOffsets.contains(offset) {
|
|
selectedCommandOffsets.remove(offset)
|
|
collectionView.deselectItem(at: indexPath, animated: true)
|
|
} else {
|
|
selectedCommandOffsets.insert(offset)
|
|
}
|
|
updateEditingMode()
|
|
if let cell = collectionView.cellForItem(at: indexPath) as? ToolboxCardCell {
|
|
configure(cell, at: indexPath)
|
|
}
|
|
} else {
|
|
flashCell(at: indexPath)
|
|
sendKeyboardCommand(command)
|
|
if dismissAfterAction && !viewPinned {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
self.dismiss(animated: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
|
|
guard entries.indices.contains(indexPath.item) else { return }
|
|
guard case .command(let offset, _) = entries[indexPath.item] else { return }
|
|
|
|
selectedCommandOffsets.remove(offset)
|
|
updateEditingMode()
|
|
if let cell = collectionView.cellForItem(at: indexPath) as? ToolboxCardCell {
|
|
configure(cell, at: indexPath)
|
|
}
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
|
guard entries.indices.contains(indexPath.item) else { return false }
|
|
return !isEditingMode || !entries[indexPath.item].isSpecial
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
|
|
return isEditingMode && entries.indices.contains(indexPath.item)
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
|
|
guard isEditingMode,
|
|
entries.indices.contains(sourceIndexPath.item),
|
|
destinationIndexPath.item >= 0,
|
|
destinationIndexPath.item <= entries.count else {
|
|
return
|
|
}
|
|
|
|
let movedEntry = entries.remove(at: sourceIndexPath.item)
|
|
let destination = min(destinationIndexPath.item, entries.count)
|
|
entries.insert(movedEntry, at: destination)
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
|
return gridInsets()
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
|
|
return gridSpacing()
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
|
|
return gridSpacing()
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
|
let insets = gridInsets()
|
|
let columns = columnCount(for: collectionView.bounds.width)
|
|
let totalSpacing = CGFloat(columns - 1) * gridSpacing() + insets.left + insets.right
|
|
let width = floor((collectionView.bounds.width - totalSpacing) / CGFloat(columns))
|
|
let height: CGFloat
|
|
|
|
#if os(tvOS)
|
|
height = max(118, width * 0.62)
|
|
#else
|
|
if PublicUtils.isIPhone {
|
|
height = max(56, min(112, width * 0.59))
|
|
} else {
|
|
height = max(108, min(140, width * 0.68))
|
|
}
|
|
#endif
|
|
|
|
return CGSize(width: width, height: height)
|
|
}
|
|
|
|
@objc private func handleCollectionLongPress(_ gesture: UILongPressGestureRecognizer) {
|
|
guard isEditingMode else { return }
|
|
|
|
let location = gesture.location(in: collectionView)
|
|
switch gesture.state {
|
|
case .began:
|
|
guard let indexPath = collectionView.indexPathForItem(at: location),
|
|
entries.indices.contains(indexPath.item) else {
|
|
return
|
|
}
|
|
collectionView.beginInteractiveMovementForItem(at: indexPath)
|
|
|
|
case .changed:
|
|
collectionView.updateInteractiveMovementTargetPosition(location)
|
|
|
|
case .ended:
|
|
collectionView.endInteractiveMovement()
|
|
|
|
default:
|
|
collectionView.cancelInteractiveMovement()
|
|
}
|
|
}
|
|
|
|
private func persistEntryOrder() {
|
|
let orderedSpecialIDs = entries.compactMap { entry -> String? in
|
|
if case .special(let id, _) = entry { return id }
|
|
return nil
|
|
}
|
|
specialEntries = NSMutableArray(array: orderedSpecialIDs)
|
|
|
|
let orderedCommandIDs = entries.compactMap { entry -> String? in
|
|
if case .command(_, let command) = entry { return command.identifier }
|
|
return nil
|
|
}
|
|
CommandManager.shared.reorderCommands(withIdentifiers: orderedCommandIDs)
|
|
|
|
UserDefaults.standard.set(entries.map(\.orderIdentifier), forKey: Self.entryOrderDefaultsKey)
|
|
entries = makeEntries()
|
|
}
|
|
|
|
private func selectCommand(offset: Int, scrollPosition: UICollectionView.ScrollPosition) {
|
|
guard let item = entries.firstIndex(where: {
|
|
if case .command(let entryOffset, _) = $0 { return entryOffset == offset }
|
|
return false
|
|
}) else { return }
|
|
|
|
let indexPath = IndexPath(item: item, section: 0)
|
|
lastSelectedCommandOffset = offset
|
|
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: scrollPosition)
|
|
}
|
|
|
|
private func scrollToCommand(offset: Int) {
|
|
guard let item = entries.firstIndex(where: {
|
|
if case .command(let entryOffset, _) = $0 { return entryOffset == offset }
|
|
return false
|
|
}) else { return }
|
|
|
|
let indexPath = IndexPath(item: item, section: 0)
|
|
lastSelectedCommandOffset = offset
|
|
collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: true)
|
|
}
|
|
|
|
private func flashCell(at indexPath: IndexPath) {
|
|
guard let cell = collectionView.cellForItem(at: indexPath) as? ToolboxCardCell else { return }
|
|
cell.flash()
|
|
}
|
|
|
|
private func specialSubtitle(for id: String) -> String {
|
|
switch id {
|
|
case "widgetSwitchTool":
|
|
return "Profiles".localized
|
|
case "widgetLayoutTool":
|
|
return "Layout".localized
|
|
case "bringUpSoftKeyboard":
|
|
return "Keyboard".localized
|
|
case "enterPip":
|
|
return "PiP".localized
|
|
case "toggleStatsOverlay":
|
|
return "Stats".localized
|
|
case "disconnectAndQuitApp":
|
|
return "Session".localized
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
private func specialSymbolName(for id: String) -> String {
|
|
switch id {
|
|
case "widgetSwitchTool":
|
|
return "square.grid.2x2"
|
|
case "widgetLayoutTool":
|
|
return "rectangle.and.pencil.and.ellipsis"
|
|
case "bringUpSoftKeyboard":
|
|
return "keyboard"
|
|
case "enterPip":
|
|
return "pip.enter"
|
|
case "toggleStatsOverlay":
|
|
return "chart.bar"
|
|
case "disconnectAndQuitApp":
|
|
return "power"
|
|
default:
|
|
return "square"
|
|
}
|
|
}
|
|
|
|
private func handleSpecialEntry(id: String) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
self.dismiss(animated: false)
|
|
|
|
switch id {
|
|
case "widgetLayoutTool":
|
|
self.specialEntryDelegate?.openWidgetLayoutTool?()
|
|
case "widgetSwitchTool":
|
|
self.specialEntryDelegate?.openWidgetProfileTable?(pickProfile: false)
|
|
case "bringUpSoftKeyboard":
|
|
self.specialEntryDelegate?.bringUpSoftKeyboard?()
|
|
case "enterPip":
|
|
self.specialEntryDelegate?.enterPip?()
|
|
case "toggleStatsOverlay":
|
|
self.specialEntryDelegate?.toggleStatsOverlay?()
|
|
case "disconnectAndQuitApp":
|
|
self.specialEntryDelegate?.disconnectAndQuitApp?()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendKeyboardCommand(_ cmd: RemoteCommand) {
|
|
guard let keyboardCmdStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: cmd.cmdString) else { return }
|
|
CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: keyboardCmdStrings)
|
|
}
|
|
|
|
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
return touch.view == view
|
|
}
|
|
|
|
@objc private func dismissSelf() {
|
|
dismiss(animated: false)
|
|
}
|
|
}
|
|
|
|
@available(iOS 13.0, *)
|
|
extension ToolboxViewController: ControllerCollectionNavigationDelegate {
|
|
var controllerNavigationCollectionView: UICollectionView {
|
|
collectionView
|
|
}
|
|
|
|
@objc func persistControllerNavigationHighlight() {
|
|
guard !controllerNavigationReorderActive else { return }
|
|
guard let indexPath = controllerNavigationCurrentIndexPathForControllerNavigator(),
|
|
entries.indices.contains(indexPath.item) else {
|
|
return
|
|
}
|
|
|
|
UserDefaults.standard.set(entries[indexPath.item].orderIdentifier, forKey: Self.controllerNavigationHighlightDefaultsKey)
|
|
}
|
|
|
|
@objc func restoreControllerNavigationHighlight() {
|
|
guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else { return }
|
|
|
|
PublicUtils.runOnMain { [weak self] in
|
|
guard let self, !self.entries.isEmpty else {
|
|
self?.clearCollectionControllerNavigationHighlightForControllerNavigator()
|
|
return
|
|
}
|
|
|
|
let savedIdentifier = UserDefaults.standard.string(forKey: Self.controllerNavigationHighlightDefaultsKey)
|
|
let item = savedIdentifier.flatMap { identifier in
|
|
self.entries.firstIndex { $0.orderIdentifier == identifier }
|
|
} ?? 0
|
|
|
|
self.collectionView.layoutIfNeeded()
|
|
self.controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: item, section: 0))
|
|
}
|
|
}
|
|
|
|
@objc func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {
|
|
}
|
|
|
|
@objc func navigateByController(forward: Bool) {
|
|
if controllerNavigationReorderActive {
|
|
moveControllerNavigationHighlightedEntry(horizontalOffset: forward ? 1 : -1)
|
|
return
|
|
}
|
|
|
|
controllerNavigationNavigateCollection(forward: forward)
|
|
}
|
|
|
|
@objc func navigateByController(downward: Bool) {
|
|
if controllerNavigationReorderActive {
|
|
moveControllerNavigationHighlightedEntry(verticalOffset: downward ? 1 : -1)
|
|
return
|
|
}
|
|
|
|
controllerNavigationNavigateCollection(downward: downward)
|
|
}
|
|
|
|
@objc func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {
|
|
}
|
|
|
|
@objc func getNavigationElements() -> [ControllerNavigationElement] {
|
|
var elements: [ControllerNavigationElement] = []
|
|
elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .rightStick : .leftStick, action: "focusNavigation"))
|
|
elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "execute"))
|
|
elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "doublePressPinExecute"))
|
|
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .y, action: "holdToReorder"))
|
|
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .y, action: "doublePressToDelete"))
|
|
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadLeft : .x, action: "addEntry"))
|
|
elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadDown : .b, action: "exit"))
|
|
return elements
|
|
}
|
|
|
|
@objc func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) {
|
|
if navigation.action == "holdToReorder" {
|
|
handleControllerNavigationReorderHold(pressed: pressed)
|
|
return
|
|
}
|
|
|
|
if navigation.action == "execute" {
|
|
handleControllerNavigationExecute(pressed: pressed)
|
|
return
|
|
}
|
|
|
|
guard pressed else { return }
|
|
|
|
if navigation.action == "addEntry" {
|
|
DispatchQueue.main.async{ [weak self] in
|
|
self?.addButtonTapped()
|
|
}
|
|
}
|
|
|
|
if navigation.action == "exit" {
|
|
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
|
self.dismiss(animated: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleControllerNavigationExecute(pressed: Bool) {
|
|
if pressed {
|
|
if controllerNavigationExecuteDismissWindowOpen {
|
|
closeControllerNavigationExecuteDismissWindow()
|
|
controllerNavigationExecuteSuppressNextRelease = true
|
|
return
|
|
}
|
|
|
|
PublicUtils.runOnMain { [weak self] in
|
|
guard let self,
|
|
let indexPath = self.controllerNavigationCurrentIndexPathForControllerNavigator() else {
|
|
return
|
|
}
|
|
|
|
self.activateEntry(at: indexPath, dismissAfterAction: false)
|
|
}
|
|
return
|
|
}
|
|
|
|
guard !controllerNavigationExecuteSuppressNextRelease else {
|
|
controllerNavigationExecuteSuppressNextRelease = false
|
|
return
|
|
}
|
|
|
|
guard !viewPinned else { return }
|
|
openControllerNavigationExecuteDismissWindow()
|
|
}
|
|
|
|
private func openControllerNavigationExecuteDismissWindow() {
|
|
controllerNavigationExecuteDismissWindowOpen = true
|
|
controllerNavigationExecuteDismissToken += 1
|
|
let token = controllerNavigationExecuteDismissToken
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
guard let self,
|
|
self.controllerNavigationExecuteDismissToken == token,
|
|
self.controllerNavigationExecuteDismissWindowOpen else {
|
|
return
|
|
}
|
|
|
|
self.closeControllerNavigationExecuteDismissWindow()
|
|
self.dismiss(animated: false)
|
|
}
|
|
}
|
|
|
|
private func closeControllerNavigationExecuteDismissWindow() {
|
|
controllerNavigationExecuteDismissWindowOpen = false
|
|
controllerNavigationExecuteDismissToken += 1
|
|
}
|
|
|
|
private func handleControllerNavigationReorderHold(pressed: Bool) {
|
|
if pressed {
|
|
if controllerNavigationDeleteDoublePressWindowOpen {
|
|
controllerNavigationDeleteDoublePressWindowOpen = false
|
|
controllerNavigationDeleteDoublePressToken += 1
|
|
deleteControllerNavigationHighlightedCommand()
|
|
return
|
|
}
|
|
|
|
guard let currentIndexPath = controllerNavigationCurrentIndexPathForControllerNavigator() else { return }
|
|
controllerNavigationReorderPressBeganAt = CACurrentMediaTime()
|
|
controllerNavigationReorderActive = true
|
|
beginControllerNavigationReorderSnapshot(at: currentIndexPath)
|
|
controllerNavigationSelectedIndexPath = currentIndexPath
|
|
return
|
|
}
|
|
|
|
let pressDuration = controllerNavigationReorderPressBeganAt.map { CACurrentMediaTime() - $0 } ?? .greatestFiniteMagnitude
|
|
controllerNavigationReorderPressBeganAt = nil
|
|
|
|
guard controllerNavigationReorderActive else { return }
|
|
controllerNavigationReorderActive = false
|
|
pendingControllerNavigationReorderMove = nil
|
|
controllerNavigationPersistOrderWhenReorderSettles = true
|
|
if pressDuration <= 0.2 {
|
|
openControllerNavigationDeleteDoublePressWindow()
|
|
} else {
|
|
closeControllerNavigationDeleteDoublePressWindow()
|
|
}
|
|
guard !controllerNavigationReorderUpdateInFlight else { return }
|
|
persistEntryOrder()
|
|
controllerNavigationPersistOrderWhenReorderSettles = false
|
|
endControllerNavigationReorderSnapshot()
|
|
restoreControllerNavigationHighlight()
|
|
persistControllerNavigationHighlight()
|
|
}
|
|
|
|
private func openControllerNavigationDeleteDoublePressWindow() {
|
|
controllerNavigationDeleteDoublePressWindowOpen = true
|
|
controllerNavigationDeleteDoublePressToken += 1
|
|
let token = controllerNavigationDeleteDoublePressToken
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
guard let self,
|
|
self.controllerNavigationDeleteDoublePressToken == token else {
|
|
return
|
|
}
|
|
|
|
self.controllerNavigationDeleteDoublePressWindowOpen = false
|
|
}
|
|
}
|
|
|
|
private func closeControllerNavigationDeleteDoublePressWindow() {
|
|
controllerNavigationDeleteDoublePressWindowOpen = false
|
|
controllerNavigationDeleteDoublePressToken += 1
|
|
}
|
|
|
|
private func deleteControllerNavigationHighlightedCommand() {
|
|
guard let indexPath = controllerNavigationCurrentIndexPathForControllerNavigator(),
|
|
entries.indices.contains(indexPath.item),
|
|
case .command(let offset, _) = entries[indexPath.item] else {
|
|
return
|
|
}
|
|
|
|
closeControllerNavigationDeleteDoublePressWindow()
|
|
controllerNavigationReorderActive = false
|
|
pendingControllerNavigationReorderMove = nil
|
|
controllerNavigationPersistOrderWhenReorderSettles = false
|
|
endControllerNavigationReorderSnapshot()
|
|
clearAllControllerNavigationHighlightsForReorder()
|
|
|
|
CommandManager.shared.deleteCommand(at: offset)
|
|
selectedCommandOffsets.remove(offset)
|
|
entries = entriesPreservingCurrentOrder(includeMissingEntries: false)
|
|
persistEntryOrder()
|
|
collectionView.reloadData()
|
|
updateEditingMode()
|
|
|
|
guard !entries.isEmpty else { return }
|
|
|
|
let targetItem = min(indexPath.item, entries.count - 1)
|
|
let targetIndexPath = IndexPath(item: targetItem, section: 0)
|
|
collectionView.layoutIfNeeded()
|
|
controllerNavigationHighlightItemForControllerNavigator(at: targetIndexPath)
|
|
persistControllerNavigationHighlight()
|
|
}
|
|
|
|
private func moveControllerNavigationHighlightedEntry(horizontalOffset: Int) {
|
|
guard !controllerNavigationReorderUpdateInFlight else {
|
|
pendingControllerNavigationReorderMove = (horizontalOffset, 0)
|
|
return
|
|
}
|
|
|
|
guard let currentIndexPath = controllerNavigationCurrentIndexPathForControllerNavigator() else { return }
|
|
let itemCount = collectionView.numberOfItems(inSection: 0)
|
|
guard itemCount > 1 else { return }
|
|
|
|
let nextItem = min(max(currentIndexPath.item + horizontalOffset, 0), itemCount - 1)
|
|
guard nextItem != currentIndexPath.item else { return }
|
|
|
|
moveControllerNavigationHighlightedEntry(from: currentIndexPath, to: IndexPath(item: nextItem, section: currentIndexPath.section))
|
|
}
|
|
|
|
private func moveControllerNavigationHighlightedEntry(verticalOffset: Int) {
|
|
guard !controllerNavigationReorderUpdateInFlight else {
|
|
pendingControllerNavigationReorderMove = (0, verticalOffset)
|
|
return
|
|
}
|
|
|
|
guard let currentIndexPath = controllerNavigationCurrentIndexPathForControllerNavigator() else { return }
|
|
guard let destinationIndexPath = controllerNavigationReorderDestination(from: currentIndexPath, verticalOffset: verticalOffset) else { return }
|
|
|
|
moveControllerNavigationHighlightedEntry(from: currentIndexPath, to: destinationIndexPath)
|
|
}
|
|
|
|
private func controllerNavigationReorderDestination(from currentIndexPath: IndexPath, verticalOffset: Int) -> IndexPath? {
|
|
let itemCount = collectionView.numberOfItems(inSection: 0)
|
|
guard itemCount > 1,
|
|
let currentAttributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: currentIndexPath) else {
|
|
return nil
|
|
}
|
|
|
|
let currentCenter = currentAttributes.center
|
|
let candidates = (0..<itemCount).compactMap { item -> (indexPath: IndexPath, attributes: UICollectionViewLayoutAttributes)? in
|
|
let indexPath = IndexPath(item: item, section: 0)
|
|
guard indexPath != currentIndexPath,
|
|
let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) else {
|
|
return nil
|
|
}
|
|
|
|
let isTargetDirection = verticalOffset > 0
|
|
? attributes.center.y > currentCenter.y + 1
|
|
: attributes.center.y < currentCenter.y - 1
|
|
return isTargetDirection ? (indexPath, attributes) : nil
|
|
}
|
|
|
|
return candidates.min { lhs, rhs in
|
|
let lhsVerticalDistance = abs(lhs.attributes.center.y - currentCenter.y)
|
|
let rhsVerticalDistance = abs(rhs.attributes.center.y - currentCenter.y)
|
|
if abs(lhsVerticalDistance - rhsVerticalDistance) > 0.5 {
|
|
return lhsVerticalDistance < rhsVerticalDistance
|
|
}
|
|
|
|
let lhsHorizontalDistance = abs(lhs.attributes.center.x - currentCenter.x)
|
|
let rhsHorizontalDistance = abs(rhs.attributes.center.x - currentCenter.x)
|
|
if abs(lhsHorizontalDistance - rhsHorizontalDistance) > 0.5 {
|
|
return lhsHorizontalDistance < rhsHorizontalDistance
|
|
}
|
|
|
|
return verticalOffset > 0
|
|
? lhs.indexPath.item < rhs.indexPath.item
|
|
: lhs.indexPath.item > rhs.indexPath.item
|
|
}?.indexPath
|
|
}
|
|
|
|
private func moveControllerNavigationHighlightedEntry(from sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
|
|
guard sourceIndexPath.section == 0,
|
|
destinationIndexPath.section == 0,
|
|
entries.indices.contains(sourceIndexPath.item),
|
|
entries.indices.contains(destinationIndexPath.item) else {
|
|
return
|
|
}
|
|
|
|
controllerNavigationReorderUpdateInFlight = true
|
|
pendingControllerNavigationReorderMove = nil
|
|
clearAllControllerNavigationHighlightsForReorder()
|
|
controllerNavigationReorderHiddenIndexPath = sourceIndexPath
|
|
updateVisibleReorderHiddenCells()
|
|
|
|
let movedEntry = entries.remove(at: sourceIndexPath.item)
|
|
entries.insert(movedEntry, at: destinationIndexPath.item)
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.clearAllControllerNavigationHighlightsForReorder()
|
|
self.scrollToControllerNavigationReorderItemIfNeeded(at: destinationIndexPath, animated: true)
|
|
self.updateControllerNavigationReorderSnapshotFrame(to: destinationIndexPath, animated: true)
|
|
CATransaction.begin()
|
|
CATransaction.setAnimationDuration(0.07)
|
|
self.collectionView.performBatchUpdates {
|
|
self.collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath)
|
|
} completion: { [weak self] _ in
|
|
guard let self else { return }
|
|
self.clearAllControllerNavigationHighlightsForReorder()
|
|
self.scrollToControllerNavigationReorderItemIfNeeded(at: destinationIndexPath, animated: false)
|
|
self.updateControllerNavigationReorderSnapshotFrame(to: destinationIndexPath, animated: false)
|
|
self.controllerNavigationReorderHiddenIndexPath = destinationIndexPath
|
|
self.updateVisibleReorderHiddenCells()
|
|
self.controllerNavigationSelectedIndexPath = destinationIndexPath
|
|
self.controllerNavigationReorderUpdateInFlight = false
|
|
|
|
if self.consumePendingControllerNavigationReorderMoveIfNeeded() {
|
|
return
|
|
}
|
|
|
|
if self.controllerNavigationPersistOrderWhenReorderSettles {
|
|
self.persistEntryOrder()
|
|
self.controllerNavigationPersistOrderWhenReorderSettles = false
|
|
}
|
|
|
|
guard !self.controllerNavigationReorderActive else { return }
|
|
self.endControllerNavigationReorderSnapshot()
|
|
self.controllerNavigationHighlightItemForControllerNavigator(at: destinationIndexPath)
|
|
self.persistControllerNavigationHighlight()
|
|
}
|
|
CATransaction.commit()
|
|
}
|
|
}
|
|
|
|
private func beginControllerNavigationReorderSnapshot(at indexPath: IndexPath) {
|
|
clearAllControllerNavigationHighlightsForReorder()
|
|
controllerNavigationReorderSnapshotView?.removeFromSuperview()
|
|
|
|
collectionView.layoutIfNeeded()
|
|
guard let cell = collectionView.cellForItem(at: indexPath),
|
|
let snapshot = cell.snapshotView(afterScreenUpdates: true) else {
|
|
return
|
|
}
|
|
|
|
snapshot.frame = cell.frame
|
|
snapshot.layer.cornerRadius = Metrics.cardCornerRadius
|
|
snapshot.layer.masksToBounds = true
|
|
snapshot.layer.borderWidth = 3
|
|
snapshot.layer.borderColor = UIColor.systemOrange.withAlphaComponent(ThemeManager.userInterfaceStyle() == .dark ? 0.8 : 0.97).cgColor
|
|
snapshot.alpha = 1
|
|
snapshot.transform = .identity
|
|
snapshot.layer.zPosition = 1000
|
|
|
|
collectionView.addSubview(snapshot)
|
|
controllerNavigationReorderSnapshotView = snapshot
|
|
controllerNavigationReorderHiddenIndexPath = indexPath
|
|
updateVisibleReorderHiddenCells()
|
|
}
|
|
|
|
private func scrollToControllerNavigationReorderItemIfNeeded(at indexPath: IndexPath, animated: Bool) {
|
|
guard indexPath.section < collectionView.numberOfSections,
|
|
indexPath.item >= 0,
|
|
indexPath.item < collectionView.numberOfItems(inSection: indexPath.section) else {
|
|
return
|
|
}
|
|
|
|
collectionView.scrollToItem(at: indexPath, at: [.centeredHorizontally, .centeredVertically], animated: animated)
|
|
collectionView.layoutIfNeeded()
|
|
}
|
|
|
|
private func updateControllerNavigationReorderSnapshotFrame(to indexPath: IndexPath, animated: Bool) {
|
|
guard let snapshot = controllerNavigationReorderSnapshotView else { return }
|
|
|
|
collectionView.layoutIfNeeded()
|
|
guard let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) else { return }
|
|
|
|
let updates = {
|
|
snapshot.center = attributes.center
|
|
snapshot.bounds.size = attributes.bounds.size
|
|
}
|
|
|
|
if animated {
|
|
UIView.animate(withDuration: 0.07, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut], animations: updates)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
|
|
self?.updateControllerNavigationReorderSnapshotFrame(to: indexPath, animated: false)
|
|
}
|
|
} else {
|
|
updates()
|
|
}
|
|
}
|
|
|
|
private func endControllerNavigationReorderSnapshot() {
|
|
guard let snapshot = controllerNavigationReorderSnapshotView else { return }
|
|
controllerNavigationReorderSnapshotView = nil
|
|
controllerNavigationReorderHiddenIndexPath = nil
|
|
updateVisibleReorderHiddenCells()
|
|
UIView.animate(withDuration: 0.12, animations: {
|
|
snapshot.alpha = 0
|
|
snapshot.transform = .identity
|
|
}) { _ in
|
|
snapshot.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
private func clearAllControllerNavigationHighlightsForReorder() {
|
|
invalidateCollectionControllerNavigationHighlightForControllerNavigator()
|
|
collectionView.visibleCells.forEach { cell in
|
|
(cell as? ToolboxCardCell)?.clearControllerNavigationHighlight()
|
|
}
|
|
}
|
|
|
|
private func updateVisibleReorderHiddenCells() {
|
|
collectionView.visibleCells.forEach { cell in
|
|
guard let indexPath = collectionView.indexPath(for: cell) else {
|
|
cell.isHidden = false
|
|
return
|
|
}
|
|
|
|
cell.isHidden = indexPath == controllerNavigationReorderHiddenIndexPath
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func consumePendingControllerNavigationReorderMoveIfNeeded() -> Bool {
|
|
guard controllerNavigationReorderActive,
|
|
!controllerNavigationReorderUpdateInFlight,
|
|
let pendingMove = pendingControllerNavigationReorderMove else {
|
|
pendingControllerNavigationReorderMove = nil
|
|
return false
|
|
}
|
|
|
|
pendingControllerNavigationReorderMove = nil
|
|
|
|
if pendingMove.horizontal != 0 {
|
|
moveControllerNavigationHighlightedEntry(horizontalOffset: pendingMove.horizontal)
|
|
return true
|
|
}
|
|
|
|
if pendingMove.vertical != 0 {
|
|
moveControllerNavigationHighlightedEntry(verticalOffset: pendingMove.vertical)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
private final class ToolboxCardCell: UICollectionViewCell, ControllerNavigationHighlightTargetProviding {
|
|
enum Kind {
|
|
case special
|
|
case command
|
|
}
|
|
|
|
static let reuseIdentifier = "ToolboxCardCell"
|
|
|
|
private let symbolView = UIImageView()
|
|
private let titleLabel = UILabel()
|
|
private let subtitleLabel = UILabel()
|
|
private let checkmarkView = UIImageView()
|
|
private let disabledOverlay = UIView()
|
|
private let highlightOverlayView = UIView()
|
|
private var kind: Kind = .command
|
|
private var selectedForEditing = false
|
|
private var editing = false
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setupViews()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
setupViews()
|
|
}
|
|
|
|
override var canBecomeFocused: Bool {
|
|
return true
|
|
}
|
|
|
|
var controllerNavigationHighlightTargetView: UIView {
|
|
highlightOverlayView
|
|
}
|
|
|
|
override func prepareForReuse() {
|
|
super.prepareForReuse()
|
|
isHidden = false
|
|
selectedForEditing = false
|
|
editing = false
|
|
disabledOverlay.isHidden = true
|
|
checkmarkView.isHidden = true
|
|
clearControllerNavigationHighlight()
|
|
contentView.transform = .identity
|
|
}
|
|
|
|
func configure(title: String, subtitle: String, symbolName: String, kind: Kind, selectedForEditing: Bool, editing: Bool) {
|
|
self.kind = kind
|
|
self.selectedForEditing = selectedForEditing
|
|
self.editing = editing
|
|
|
|
titleLabel.text = title
|
|
subtitleLabel.text = subtitle
|
|
if #available(iOS 13.0, tvOS 13.0, *) {
|
|
symbolView.image = UIImage(systemName: symbolName) ?? UIImage(named: symbolName)
|
|
} else {
|
|
symbolView.image = UIImage(named: symbolName)
|
|
}
|
|
disabledOverlay.isHidden = !(editing && kind == .special)
|
|
checkmarkView.isHidden = !(editing && kind == .command)
|
|
checkmarkView.alpha = selectedForEditing ? 1 : 0.28
|
|
if #available(iOS 13.0, tvOS 13.0, *) {
|
|
checkmarkView.image = UIImage(systemName: selectedForEditing ? "checkmark.circle.fill" : "circle")
|
|
} else {
|
|
checkmarkView.image = nil
|
|
}
|
|
|
|
applyTheme()
|
|
}
|
|
|
|
func applyTheme() {
|
|
let darkMode = traitCollection.userInterfaceStyle == .dark
|
|
let special = kind == .special
|
|
|
|
if selectedForEditing {
|
|
contentView.backgroundColor = UIColor.systemOrange.withAlphaComponent(darkMode ? 0.34 : 0.24)
|
|
contentView.layer.borderColor = UIColor.systemOrange.withAlphaComponent(0.90).cgColor
|
|
contentView.layer.borderWidth = 1.5
|
|
} else {
|
|
contentView.backgroundColor = special
|
|
? (darkMode ? UIColor(white: 1, alpha: 0.12) : UIColor(white: 1, alpha: 0.72))
|
|
// : (darkMode ? UIColor(red: 0.27, green: 0.23, blue: 0.13, alpha: 0.90) : UIColor(red: 1.00, green: 0.94, blue: 0.62, alpha: 0.88))
|
|
: .clear
|
|
contentView.layer.borderColor = special
|
|
? (darkMode ? UIColor(white: 1, alpha: 0.18).cgColor : UIColor(white: 1, alpha: 0.88).cgColor)
|
|
: UIColor.systemOrange.withAlphaComponent(darkMode ? 0.45 : 0.6).cgColor
|
|
contentView.layer.borderWidth = special ? 1.0 : 1.2
|
|
}
|
|
|
|
titleLabel.textColor = darkMode ? .white : UIColor(white: 0.10, alpha: 1)
|
|
subtitleLabel.textColor = darkMode ? UIColor(white: 1, alpha: 0.56) : UIColor(white: 0, alpha: 0.50)
|
|
symbolView.tintColor = special
|
|
? (darkMode ? UIColor(white: 1, alpha: 0.82) : UIColor(white: 0.12, alpha: 0.72))
|
|
: UIColor.systemOrange
|
|
checkmarkView.tintColor = UIColor.systemOrange
|
|
disabledOverlay.backgroundColor = darkMode ? UIColor(white: 0, alpha: 0.36) : UIColor(white: 1, alpha: 0.48)
|
|
}
|
|
|
|
func controllerNavigationHighlightDidClear() {
|
|
applyTheme()
|
|
}
|
|
|
|
func controllerNavigationHighlightDidApply() {
|
|
guard kind == .special else { return }
|
|
contentView.layer.borderWidth = 0
|
|
contentView.layer.borderColor = nil
|
|
}
|
|
|
|
func clearControllerNavigationHighlight() {
|
|
highlightOverlayView.layer.borderWidth = 0
|
|
highlightOverlayView.layer.borderColor = nil
|
|
applyTheme()
|
|
}
|
|
|
|
func flash() {
|
|
UIView.animate(withDuration: 0.08, animations: {
|
|
self.contentView.transform = CGAffineTransform(scaleX: 0.97, y: 0.97)
|
|
}) { _ in
|
|
UIView.animate(withDuration: 0.16) {
|
|
self.contentView.transform = .identity
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupViews() {
|
|
contentView.layer.cornerRadius = 16
|
|
contentView.layer.masksToBounds = true
|
|
|
|
symbolView.translatesAutoresizingMaskIntoConstraints = false
|
|
symbolView.contentMode = .scaleAspectFit
|
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 13 : 15, weight: .semibold)
|
|
titleLabel.numberOfLines = 2
|
|
titleLabel.adjustsFontSizeToFitWidth = true
|
|
titleLabel.minimumScaleFactor = 0.66
|
|
titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
if #available(iOS 13.0, tvOS 13.0, *) {
|
|
subtitleLabel.font = UIFont.monospacedSystemFont(ofSize: PublicUtils.isIPhone ? 10 : 12, weight: .medium)
|
|
} else {
|
|
subtitleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 10 : 12, weight: .medium)
|
|
}
|
|
subtitleLabel.numberOfLines = 1
|
|
subtitleLabel.adjustsFontSizeToFitWidth = true
|
|
subtitleLabel.minimumScaleFactor = 0.6
|
|
|
|
checkmarkView.translatesAutoresizingMaskIntoConstraints = false
|
|
disabledOverlay.translatesAutoresizingMaskIntoConstraints = false
|
|
disabledOverlay.isUserInteractionEnabled = false
|
|
highlightOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
|
highlightOverlayView.isUserInteractionEnabled = false
|
|
highlightOverlayView.backgroundColor = .clear
|
|
highlightOverlayView.layer.cornerRadius = 16
|
|
highlightOverlayView.layer.masksToBounds = true
|
|
|
|
contentView.addSubview(symbolView)
|
|
contentView.addSubview(titleLabel)
|
|
contentView.addSubview(subtitleLabel)
|
|
contentView.addSubview(checkmarkView)
|
|
contentView.addSubview(disabledOverlay)
|
|
contentView.addSubview(highlightOverlayView)
|
|
|
|
let iconSize: CGFloat = PublicUtils.isIPhone ? 24 : 30
|
|
let sidePadding: CGFloat = PublicUtils.isIPhone ? 10 : 14
|
|
let topPadding: CGFloat = PublicUtils.isIPhone ? 9 : 12
|
|
|
|
NSLayoutConstraint.activate([
|
|
symbolView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sidePadding),
|
|
symbolView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: topPadding),
|
|
symbolView.widthAnchor.constraint(equalToConstant: iconSize),
|
|
symbolView.heightAnchor.constraint(equalToConstant: iconSize),
|
|
|
|
checkmarkView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sidePadding),
|
|
checkmarkView.centerYAnchor.constraint(equalTo: symbolView.centerYAnchor),
|
|
checkmarkView.widthAnchor.constraint(equalToConstant: iconSize),
|
|
checkmarkView.heightAnchor.constraint(equalToConstant: iconSize),
|
|
|
|
titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sidePadding),
|
|
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sidePadding),
|
|
titleLabel.topAnchor.constraint(equalTo: symbolView.bottomAnchor, constant: PublicUtils.isIPhone ? 6 : 10),
|
|
|
|
subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
|
subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
|
|
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
|
|
subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -topPadding),
|
|
|
|
disabledOverlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
disabledOverlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
disabledOverlay.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
disabledOverlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
|
|
highlightOverlayView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
highlightOverlayView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
highlightOverlayView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
highlightOverlayView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
])
|
|
|
|
applyTheme()
|
|
}
|
|
|
|
#if os(tvOS)
|
|
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
|
|
super.didUpdateFocus(in: context, with: coordinator)
|
|
coordinator.addCoordinatedAnimations {
|
|
self.contentView.transform = self.isFocused ? CGAffineTransform(scaleX: 1.06, y: 1.06) : .identity
|
|
}
|
|
}
|
|
#endif
|
|
}
|