From 3e72ec7e323f871a9e66c1bdf05e8ce2d9c1cde5 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Fri, 5 Feb 2021 16:30:57 -0800 Subject: [PATCH] Improve emoji picker --- .../EmojiPickerViewController.swift | 370 +++++++++--------- .../NewStatusViewController.swift | 88 ++--- 2 files changed, 222 insertions(+), 236 deletions(-) diff --git a/View Controllers/EmojiPickerViewController.swift b/View Controllers/EmojiPickerViewController.swift index b3842f2..490bb34 100644 --- a/View Controllers/EmojiPickerViewController.swift +++ b/View Controllers/EmojiPickerViewController.swift @@ -4,16 +4,180 @@ import Combine import UIKit import ViewModels -final class EmojiPickerViewController: UIViewController { +final class EmojiPickerViewController: UICollectionViewController { let searchBar = UISearchBar() private let viewModel: EmojiPickerViewModel - private let selectionAction: (PickerEmoji) -> Void - private let dismissAction: () -> Void - private let skinToneButton = UIButton() + private let selectionAction: (EmojiPickerViewController, PickerEmoji) -> Void + private let deletionAction: (EmojiPickerViewController) -> Void + private let searchPresentationAction: (EmojiPickerViewController, UINavigationController) -> Void + private let skinToneButton = UIBarButtonItem() + private let deleteButton = UIBarButtonItem() + private let closeButton = UIBarButtonItem(systemItem: .close) + private let presentSearchButton = UIButton() private var cancellables = Set() - private lazy var collectionView: UICollectionView = { + private lazy var dataSource: UICollectionViewDiffableDataSource = { + let cellRegistration = UICollectionView.CellRegistration + { [weak self] in + $0.emoji = self?.applyingDefaultSkinTone(emoji: $2) ?? $2 + } + + let headerRegistration = UICollectionView.SupplementaryRegistration + (elementKind: "Header") { [weak self] in + $0.label.text = self?.dataSource.snapshot().sectionIdentifiers[$2.section].displayName + } + + let dataSource = UICollectionViewDiffableDataSource + (collectionView: collectionView) { + $0.dequeueConfiguredReusableCell(using: cellRegistration, for: $1, item: $2) + } + + dataSource.supplementaryViewProvider = { + $0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2) + } + + return dataSource + }() + + private lazy var defaultSkinToneSelectionMenu: UIMenu = { + let clearSkinToneAction = UIAction(title: SystemEmoji.SkinTone.noneExample) { [weak self] _ in + self?.skinToneButton.title = SystemEmoji.SkinTone.noneExample + self?.viewModel.identityContext.appPreferences.defaultEmojiSkinTone = nil + self?.reloadVisibleItems() + } + + let setSkinToneActions = SystemEmoji.SkinTone.allCases.map { [weak self] skinTone in + UIAction(title: skinTone.example) { _ in + self?.skinToneButton.title = skinTone.example + self?.viewModel.identityContext.appPreferences.defaultEmojiSkinTone = skinTone + self?.reloadVisibleItems() + } + } + + return UIMenu( + title: NSLocalizedString("emoji.default-skin-tone", comment: ""), + children: [clearSkinToneAction] + setSkinToneActions) + }() + + init(viewModel: EmojiPickerViewModel, + selectionAction: @escaping (EmojiPickerViewController, PickerEmoji) -> Void, + deletionAction: @escaping (EmojiPickerViewController) -> Void, + searchPresentationAction: @escaping (EmojiPickerViewController, UINavigationController) -> Void) { + self.viewModel = viewModel + self.selectionAction = selectionAction + self.deletionAction = deletionAction + self.searchPresentationAction = searchPresentationAction + + super.init(collectionViewLayout: Self.layout()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // swiftlint:disable:next function_body_length + override func viewDidLoad() { + super.viewDidLoad() + + searchBar.translatesAutoresizingMaskIntoConstraints = false + searchBar.searchBarStyle = .minimal + searchBar.placeholder = NSLocalizedString("emoji.search", comment: "") + searchBar.searchTextField.addAction( + UIAction { [weak self] _ in + self?.viewModel.query = self?.searchBar.text ?? "" + self?.collectionView.setContentOffset(.zero, animated: false) + }, + for: .editingChanged) + navigationItem.titleView = searchBar + + searchBar.addSubview(presentSearchButton) + presentSearchButton.translatesAutoresizingMaskIntoConstraints = false + presentSearchButton.accessibilityLabel = NSLocalizedString("emoji.search", comment: "") + presentSearchButton.addAction(UIAction { [weak self] _ in self?.presentSearch() }, for: .touchUpInside) + + skinToneButton.accessibilityLabel = + NSLocalizedString("emoji.default-skin-tone-button.accessibility-label", comment: "") + + skinToneButton.title = viewModel.identityContext.appPreferences.defaultEmojiSkinTone?.example + ?? SystemEmoji.SkinTone.noneExample + skinToneButton.accessibilityLabel = + NSLocalizedString("emoji.default-skin-tone-button.accessibility-label", comment: "") + skinToneButton.menu = defaultSkinToneSelectionMenu + + deleteButton.primaryAction = UIAction(image: UIImage(systemName: "delete.left")) { [weak self] _ in + guard let self = self else { return } + + self.deletionAction(self) + } + deleteButton.tintColor = .label + + navigationItem.rightBarButtonItems = [deleteButton, skinToneButton] + + closeButton.primaryAction = UIAction { [weak self] _ in + self?.presentingViewController?.dismiss(animated: true) + } + + collectionView.backgroundColor = .clear + collectionView.dataSource = dataSource + collectionView.isAccessibilityElement = false + collectionView.shouldGroupAccessibilityChildren = true + + NSLayoutConstraint.activate([ + presentSearchButton.leadingAnchor.constraint(equalTo: searchBar.leadingAnchor), + presentSearchButton.topAnchor.constraint(equalTo: searchBar.topAnchor), + presentSearchButton.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor), + presentSearchButton.bottomAnchor.constraint(equalTo: searchBar.bottomAnchor) + ]) + + viewModel.$emoji + .sink { [weak self] in self?.dataSource.apply( + $0.snapshot(), + animatingDifferences: !UIAccessibility.isReduceMotionEnabled) } + .store(in: &cancellables) + + if let currentKeyboardLanguageIdentifier = searchBar.textInputMode?.primaryLanguage { + viewModel.locale = Locale(identifier: currentKeyboardLanguageIdentifier) + } + + NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification) + .compactMap { [weak self] _ in self?.searchBar.textInputMode?.primaryLanguage } + .compactMap(Locale.init(identifier:)) + .assign(to: \.locale, on: viewModel) + .store(in: &cancellables) + } + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + + select(emoji: applyingDefaultSkinTone(emoji: item)) + viewModel.updateUse(emoji: item) + } + + override func collectionView(_ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint) -> UIContextMenuConfiguration? { + guard let item = dataSource.itemIdentifier(for: indexPath), + case let .system(emoji, inFrequentlyUsed) = item, + !emoji.skinToneVariations.isEmpty + else { return nil } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + UIMenu(children: ([emoji] + emoji.skinToneVariations).map { skinToneVariation in + UIAction(title: skinToneVariation.emoji) { [weak self] _ in + self?.select(emoji: .system(skinToneVariation, inFrequentlyUsed: inFrequentlyUsed)) + self?.viewModel.updateUse(emoji: item) + } + }) + } + } +} + +private extension EmojiPickerViewController { + static let headerElementKind = "com.metabolist.metatext.emoji-picker.header" + + static func layout() -> UICollectionViewLayout { let itemSize = NSCollectionLayoutSize( widthDimension: .absolute(.minimumButtonDimension), heightDimension: .absolute(.minimumButtonDimension)) @@ -44,197 +208,25 @@ final class EmojiPickerViewController: UIViewController { section.boundarySupplementaryItems = [header] - let layout = UICollectionViewCompositionalLayout(section: section) - - return UICollectionView(frame: .zero, collectionViewLayout: layout) - }() - - private lazy var dataSource: UICollectionViewDiffableDataSource = { - let cellRegistration = UICollectionView.CellRegistration - { [weak self] in - $0.emoji = self?.applyingDefaultSkinTone(emoji: $2) ?? $2 - } - - let headerRegistration = UICollectionView.SupplementaryRegistration - (elementKind: "Header") { [weak self] in - $0.label.text = self?.dataSource.snapshot().sectionIdentifiers[$2.section].displayName - } - - let dataSource = UICollectionViewDiffableDataSource - (collectionView: collectionView) { - $0.dequeueConfiguredReusableCell(using: cellRegistration, for: $1, item: $2) - } - - dataSource.supplementaryViewProvider = { - $0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2) - } - - return dataSource - }() - - private lazy var defaultSkinToneSelectionMenu: UIMenu = { - let clearSkinToneAction = UIAction(title: SystemEmoji.SkinTone.noneExample) { [weak self] _ in - self?.skinToneButton.setTitle(SystemEmoji.SkinTone.noneExample, for: .normal) - self?.viewModel.identityContext.appPreferences.defaultEmojiSkinTone = nil - self?.reloadVisibleItems() - } - - let setSkinToneActions = SystemEmoji.SkinTone.allCases.map { [weak self] skinTone in - UIAction(title: skinTone.example) { _ in - self?.skinToneButton.setTitle(skinTone.example, for: .normal) - self?.viewModel.identityContext.appPreferences.defaultEmojiSkinTone = skinTone - self?.reloadVisibleItems() - } - } - - return UIMenu( - title: NSLocalizedString("emoji.default-skin-tone", comment: ""), - children: [clearSkinToneAction] + setSkinToneActions) - }() - - init(viewModel: EmojiPickerViewModel, - selectionAction: @escaping (PickerEmoji) -> Void, - dismissAction: @escaping () -> Void) { - self.viewModel = viewModel - self.selectionAction = selectionAction - self.dismissAction = dismissAction - - super.init(nibName: nil, bundle: nil) + return UICollectionViewCompositionalLayout(section: section) } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // swiftlint:disable:next function_body_length - override func viewDidLoad() { - super.viewDidLoad() - - view.addSubview(searchBar) - searchBar.translatesAutoresizingMaskIntoConstraints = false - searchBar.searchBarStyle = .minimal - searchBar.placeholder = NSLocalizedString("emoji.search", comment: "") - searchBar.searchTextField.addAction( - UIAction { [weak self] _ in - self?.viewModel.query = self?.searchBar.text ?? "" - self?.collectionView.setContentOffset(.zero, animated: false) - }, - for: .editingChanged) - - view.addSubview(skinToneButton) - skinToneButton.translatesAutoresizingMaskIntoConstraints = false - skinToneButton.titleLabel?.adjustsFontSizeToFitWidth = true - skinToneButton.setTitle( - viewModel.identityContext.appPreferences.defaultEmojiSkinTone?.example ?? SystemEmoji.SkinTone.noneExample, - for: .normal) - skinToneButton.showsMenuAsPrimaryAction = true - skinToneButton.menu = defaultSkinToneSelectionMenu - skinToneButton.accessibilityLabel = - NSLocalizedString("emoji.default-skin-tone-button.accessibility-label", comment: "") - - view.addSubview(collectionView) - collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.backgroundColor = .clear - collectionView.dataSource = dataSource - collectionView.delegate = self - collectionView.isAccessibilityElement = false - collectionView.shouldGroupAccessibilityChildren = true - - NSLayoutConstraint.activate([ - searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - searchBar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - skinToneButton.leadingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: .defaultSpacing), - skinToneButton.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - skinToneButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), - skinToneButton.bottomAnchor.constraint(equalTo: searchBar.bottomAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.topAnchor.constraint(equalTo: searchBar.bottomAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - - viewModel.$emoji - .sink { [weak self] in self?.dataSource.apply( - $0.snapshot(), - animatingDifferences: !UIAccessibility.isReduceMotionEnabled) } - .store(in: &cancellables) - - if let currentKeyboardLanguageIdentifier = searchBar.textInputMode?.primaryLanguage { - viewModel.locale = Locale(identifier: currentKeyboardLanguageIdentifier) - } - - NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification) - .compactMap { [weak self] _ in self?.searchBar.textInputMode?.primaryLanguage } - .compactMap(Locale.init(identifier:)) - .assign(to: \.locale, on: viewModel) - .store(in: &cancellables) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - guard let containerView = popoverPresentationController?.containerView else { return } - - // gets the popover presentation controller's built-in visual effect view to actually show - func setClear(view: UIView) { - view.backgroundColor = .clear - - if view == self.view { - return - } - - for view in view.subviews { - setClear(view: view) - } - } - - setClear(view: containerView) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - dismissAction() - } -} - -extension EmojiPickerViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let item = dataSource.itemIdentifier(for: indexPath) else { return } - - select(emoji: applyingDefaultSkinTone(emoji: item)) - viewModel.updateUse(emoji: item) - } - - func collectionView(_ collectionView: UICollectionView, - contextMenuConfigurationForItemAt indexPath: IndexPath, - point: CGPoint) -> UIContextMenuConfiguration? { - guard let item = dataSource.itemIdentifier(for: indexPath), - case let .system(emoji, inFrequentlyUsed) = item, - !emoji.skinToneVariations.isEmpty - else { return nil } - - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in - UIMenu(children: ([emoji] + emoji.skinToneVariations).map { skinToneVariation in - UIAction(title: skinToneVariation.emoji) { [weak self] _ in - self?.select(emoji: .system(skinToneVariation, inFrequentlyUsed: inFrequentlyUsed)) - self?.viewModel.updateUse(emoji: item) - } - }) - } - } -} - -private extension EmojiPickerViewController { - static let headerElementKind = "com.metabolist.metatext.emoji-picker.header" - func select(emoji: PickerEmoji) { - selectionAction(emoji) + selectionAction(self, emoji) UISelectionFeedbackGenerator().selectionChanged() } + func presentSearch() { + guard let navigationController = self.navigationController else { return } + + presentSearchButton.isHidden = true + navigationItem.leftBarButtonItem = closeButton + navigationItem.rightBarButtonItems = [self.skinToneButton] + collectionView.backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) + searchPresentationAction(self, navigationController) + } + func reloadVisibleItems() { var snapshot = dataSource.snapshot() let visibleItems = collectionView.indexPathsForVisibleItems.compactMap(dataSource.itemIdentifier(for:)) diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index 87db8d4..fba5ef7 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -263,8 +263,14 @@ private extension NewStatusViewController { viewModel.$alertItem .compactMap { $0 } .sink { [weak self] alertItem in - self?.dismissEmojiPickerIfPresented { - self?.present(alertItem: alertItem) + guard let self = self else { return } + + if self.presentedViewController != nil { + self.dismiss(animated: true) { + self.present(alertItem: alertItem) + } + } else { + self.present(alertItem: alertItem) } } .store(in: &cancellables) @@ -290,9 +296,7 @@ private extension NewStatusViewController { picker.modalPresentationStyle = .overFullScreen picker.delegate = self - dismissEmojiPickerIfPresented { - self.present(picker, animated: true) - } + present(picker, animated: true) } #if !IS_SHARE_EXTENSION @@ -343,9 +347,7 @@ private extension NewStatusViewController { picker.mediaTypes = [UTType.image.description] } - dismissEmojiPickerIfPresented { - self.present(picker, animated: true) - } + present(picker, animated: true) } #endif @@ -367,57 +369,51 @@ private extension NewStatusViewController { documentPickerController.delegate = self documentPickerController.allowsMultipleSelection = false documentPickerController.modalPresentationStyle = .overFullScreen - dismissEmojiPickerIfPresented { - self.present(documentPickerController, animated: true) - } + present(documentPickerController, animated: true) } func presentEmojiPicker(tag: Int) { - if dismissEmojiPickerIfPresented() { - return - } - guard let fromView = view.viewWithTag(tag) else { return } - let emojiPickerViewModel = EmojiPickerViewModel(identityContext: viewModel.identityContext) + if fromView.inputView == nil { + let emojiPickerViewModel = EmojiPickerViewModel(identityContext: viewModel.identityContext) - emojiPickerViewModel.$alertItem.assign(to: \.alertItem, on: viewModel).store(in: &cancellables) + emojiPickerViewModel.$alertItem.assign(to: \.alertItem, on: viewModel).store(in: &cancellables) - let emojiPickerController = EmojiPickerViewController(viewModel: emojiPickerViewModel) { - guard let textInput = fromView as? UITextInput else { return } + let emojiPickerController = + EmojiPickerViewController(viewModel: emojiPickerViewModel) { [weak self] picker, emoji in + guard let textInput = fromView as? UITextInput, + let selectedTextRange = textInput.selectedTextRange + else { return } - if let selectedTextRange = textInput.selectedTextRange { - textInput.replace(selectedTextRange, withText: $0.escaped.appending(" ")) - } - } dismissAction: { - fromView.becomeFirstResponder() - } + textInput.replace(selectedTextRange, withText: emoji.escaped.appending(" ")) - emojiPickerController.searchBar.inputAccessoryView = fromView.inputAccessoryView - emojiPickerController.preferredContentSize = .init( - width: view.readableContentGuide.layoutFrame.width, - height: view.frame.height) - emojiPickerController.modalPresentationStyle = .popover - emojiPickerController.popoverPresentationController?.delegate = self - emojiPickerController.popoverPresentationController?.sourceView = fromView - emojiPickerController.popoverPresentationController?.sourceRect = - .init(origin: .init(x: fromView.bounds.midX, y: fromView.bounds.maxY), size: .zero) - emojiPickerController.popoverPresentationController?.backgroundColor = .clear + if (self?.presentedViewController as? UINavigationController)?.viewControllers.first === picker { + self?.dismiss(animated: true) + } + } deletionAction: { _ in + (fromView as? UITextInput)?.deleteBackward() + } searchPresentationAction: { [weak self] picker, navigation in + (fromView as? UITextView)?.inputView = nil + (fromView as? UITextField)?.inputView = nil + fromView.reloadInputViews() - present(emojiPickerController, animated: true) - } + navigation.removeFromParent() + navigation.preferredContentSize = CGSize(width: 100, height: 100) + picker.searchBar.becomeFirstResponder() + self?.present(navigation, animated: true) + } - @discardableResult - func dismissEmojiPickerIfPresented(completion: (() -> Void)? = nil) -> Bool { - let emojiPickerPresented = presentedViewController is EmojiPickerViewController + let pickerNavigation = UINavigationController(rootViewController: emojiPickerController) - if emojiPickerPresented { - dismiss(animated: true, completion: completion) + (fromView as? UITextView)?.inputView = pickerNavigation.view + (fromView as? UITextField)?.inputView = pickerNavigation.view } else { - completion?() + (fromView as? UITextView)?.inputView = nil + (fromView as? UITextField)?.inputView = nil } - return emojiPickerPresented + fromView.reloadInputViews() } func presentAttachmentEditor(attachmentViewModel: AttachmentViewModel, compositionViewModel: CompositionViewModel) { @@ -426,9 +422,7 @@ private extension NewStatusViewController { let navigationController = UINavigationController(rootViewController: editAttachmentViewController) navigationController.modalPresentationStyle = .overFullScreen - dismissEmojiPickerIfPresented { - self.present(navigationController, animated: true) - } + present(navigationController, animated: true) } func changeIdentity(_ identity: Identity) {