// // GeneralSettingsViewController.swift // GeneralSettingsViewController // // Created by James Magahern on 6/25/21. // import UIKit struct LabelContentConfiguration : UIContentConfiguration { var text: String = "" var insets: UIEdgeInsets = .zero var textAlignment: NSTextAlignment = .natural func makeContentView() -> UIView & UIContentView { let label = UILabel(frame: .zero) let contentView = GenericContentView(configuration: self, view: label) { config, label in label.text = config.text label.textAlignment = config.textAlignment } contentView.insets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) return contentView } func updated(for state: UIConfigurationState) -> Self { self } } struct TextFieldContentConfiguration : UIContentConfiguration { var text: String = "" var placeholderText: String? = nil var textChanged: ((String) -> Void)? = nil var pressedReturn: ((UITextField) -> Void)? = nil var keyboardType: UIKeyboardType = .default var returnKeyType: UIReturnKeyType = .default func makeContentView() -> UIView & UIContentView { let textField = UITextField(frame: .zero) textField.borderStyle = .roundedRect textField.autocorrectionType = .no textField.autocapitalizationType = .none textField.keyboardType = keyboardType textField.returnKeyType = returnKeyType return GenericContentView(configuration: self, view: textField) { config, textField in textField.text = config.text textField.placeholder = config.placeholderText if let textChanged = config.textChanged { textField.addAction(UIAction { _ in textChanged(textField.text ?? "") }, for: .editingChanged) } if let pressedReturn = config.pressedReturn { class ReturnDelegate : NSObject, UITextFieldDelegate { var pressedReturn: ((UITextField) -> Void) public init(pressedReturn: @escaping ((UITextField) -> Void)) { self.pressedReturn = pressedReturn } public func textFieldShouldReturn(_ textField: UITextField) -> Bool { pressedReturn(textField) return false } } let delegate = ReturnDelegate(pressedReturn: pressedReturn) objc_setAssociatedObject(textField, "returnDelegate", delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) textField.delegate = delegate } } } func updated(for state: UIConfigurationState) -> TextFieldContentConfiguration { self } } struct ButtonContentConfiguration : UIContentConfiguration { var menu: UIMenu func makeContentView() -> UIView & UIContentView { let button = UIButton(primaryAction: nil) return GenericContentView(configuration: self, view: button) { config, button in button.menu = menu button.showsMenuAsPrimaryAction = true if #available(macCatalyst 15.0, iOS 15.0, *) { var buttonConfiguration = UIButton.Configuration.plain() buttonConfiguration.titleAlignment = .trailing button.configuration = buttonConfiguration button.changesSelectionAsPrimaryAction = true } } } func updated(for state: UIConfigurationState) -> ButtonContentConfiguration { self } } class GeneralSettingsViewController: UIViewController { enum Section: String, CaseIterable { case searchEngine = "Search Engine" case syncServer = "Sync Server" } typealias Item = String static let SearchProviderPopupItem = "searchProvider.popup" static let SearchEngineNameFieldItem = "searchEngine.add.name" static let SearchEngineURLFieldItem = "searchEngine.add.url" static let SyncServerItem = "syncServer.field" let dataSource: UICollectionViewDiffableDataSource let collectionView: UICollectionView private var pendingEngineName: String = "" private var pendingEngineURL: String = "" static func createLayout(forIdiom idiom: UIUserInterfaceIdiom) -> UICollectionViewLayout { #if targetEnvironment(macCatalyst) let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.60), heightDimension: .absolute(44)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1) group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(1.0), top: nil, trailing: nil, bottom: nil) let insets = NSDirectionalEdgeInsets(top: 24.0, leading: 64.0, bottom: 24.0, trailing: 64.0) let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.40), heightDimension: .estimated(44.0)) let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading, absoluteOffset: CGPoint(x: insets.leading, y: insets.top + 5.0) ) sectionHeader.extendsBoundary = false sectionHeader.contentInsets = insets let section = NSCollectionLayoutSection(group: group) // section.interGroupSpacing = spacing section.contentInsets = insets section.supplementariesFollowContentInsets = true section.boundarySupplementaryItems = [ sectionHeader ] let layout = UICollectionViewCompositionalLayout(section: section) return layout #else var listConfiguration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) listConfiguration.headerMode = .supplementary return UICollectionViewCompositionalLayout.list(using: listConfiguration) #endif } static func sectionHeaderConfiguration(forIdiom idiom: UIUserInterfaceIdiom, sectionName: String) -> UIContentConfiguration { if idiom == .mac { return LabelContentConfiguration( text: sectionName + ": ", insets: UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0, right: 10.0), textAlignment: .right ) } else { var config = UIListContentConfiguration.plainHeader() config.text = sectionName return config } } #if targetEnvironment(macCatalyst) static let staticIdiom = UIUserInterfaceIdiom.mac #else static let staticIdiom = UIUserInterfaceIdiom.pad #endif init() { let actionHandler = { (action: UIAction) in let engineName = action.title Settings.shared.defaultSearchEngineName = engineName } let itemCellRegistry = UICollectionView.CellRegistration { cell, indexPath, identifier in if identifier == Self.SearchProviderPopupItem { let names = Settings.shared.searchEngines.keys.sorted() let menu = UIMenu(children: names.map { name in let action = UIAction(title: name, handler: actionHandler) action.state = (Settings.shared.defaultSearchEngineName == name) ? .on : .off return action }) cell.contentConfiguration = ButtonContentConfiguration(menu: menu) } else if identifier == Self.SearchEngineNameFieldItem { cell.contentConfiguration = TextFieldContentConfiguration( text: self.pendingEngineName, placeholderText: "Name (e.g., Startpage)", textChanged: { [weak self] newString in self?.pendingEngineName = newString }, pressedReturn: { $0.resignFirstResponder() }, keyboardType: .default, returnKeyType: .next ) } else if identifier == Self.SearchEngineURLFieldItem { cell.contentConfiguration = TextFieldContentConfiguration( text: self.pendingEngineURL, placeholderText: "URL template (use %q or %s)", textChanged: { [weak self] newString in self?.pendingEngineURL = newString }, pressedReturn: { [weak self] textField in guard let self = self else { return } self.tryAddPendingSearchEngine() textField.resignFirstResponder() }, keyboardType: .URL, returnKeyType: .done ) } else if identifier == Self.SyncServerItem { cell.contentConfiguration = TextFieldContentConfiguration( text: Settings.shared.syncServer ?? "", placeholderText: "https://sync.server.com", textChanged: { newString in Settings.shared.syncServer = newString }, pressedReturn: { $0.resignFirstResponder() }, keyboardType: .URL, returnKeyType: .done ) } #if !targetEnvironment(macCatalyst) cell.backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell() #endif } let sectionHeaderRegistry = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader, handler: { (cell, string, indexPath) in let sectionName = Section.allCases[indexPath.section].rawValue cell.contentConfiguration = Self.sectionHeaderConfiguration(forIdiom: Self.staticIdiom, sectionName: sectionName) }) let layout = Self.createLayout(forIdiom: Self.staticIdiom) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.backgroundColor = .systemGroupedBackground dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, identifier) in return collectionView.dequeueConfiguredReusableCell(using: itemCellRegistry, for: indexPath, item: identifier) } dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderRegistry, for: indexPath) } super.init(nibName: nil, bundle: nil) collectionView.delegate = self tabBarItem.title = "General" tabBarItem.image = UIImage(systemName: "gear") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { self.view = collectionView } override func viewDidLoad() { super.viewDidLoad() applySnapshot(animatingDifferences: false) } } extension GeneralSettingsViewController : UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { false } } private extension GeneralSettingsViewController { func applySnapshot(animatingDifferences: Bool) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(Section.allCases) snapshot.appendItems([ Self.SearchProviderPopupItem, Self.SearchEngineNameFieldItem, Self.SearchEngineURLFieldItem ], toSection: .searchEngine) snapshot.appendItems([ Self.SyncServerItem ], toSection: .syncServer) dataSource.apply(snapshot, animatingDifferences: animatingDifferences) } func tryAddPendingSearchEngine() { let name = pendingEngineName.trimmingCharacters(in: .whitespacesAndNewlines) let url = pendingEngineURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty, !url.isEmpty else { return } // Require placeholder guard url.contains("%q") || url.contains("%s") else { return } // Save var engines = Settings.shared.searchEngines engines[name] = url Settings.shared.searchEngines = engines // Reset inputs and refresh UI pendingEngineName = "" pendingEngineURL = "" applySnapshot(animatingDifferences: true) } }