metatext/View Controllers/EmojiPickerViewController.swift

242 lines
10 KiB
Swift
Raw Normal View History

2021-01-14 17:49:53 +00:00
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import UIKit
import ViewModels
2021-02-06 00:30:57 +00:00
final class EmojiPickerViewController: UICollectionViewController {
2021-01-14 17:49:53 +00:00
let searchBar = UISearchBar()
private let viewModel: EmojiPickerViewModel
2021-02-06 00:30:57 +00:00
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()
2021-01-14 17:49:53 +00:00
private var cancellables = Set<AnyCancellable>()
2021-01-15 10:13:10 +00:00
private lazy var dataSource: UICollectionViewDiffableDataSource<PickerEmoji.Category, PickerEmoji> = {
let cellRegistration = UICollectionView.CellRegistration
2021-01-15 21:43:46 +00:00
<EmojiCollectionViewCell, PickerEmoji> { [weak self] in
2021-02-15 08:47:30 +00:00
guard let self = self else { return }
$0.viewModel = EmojiViewModel(emoji: $2, identityContext: self.viewModel.identityContext)
2021-01-15 10:13:10 +00:00
}
let headerRegistration = UICollectionView.SupplementaryRegistration
<EmojiCategoryHeaderView>(elementKind: "Header") { [weak self] in
$0.label.text = self?.dataSource.snapshot().sectionIdentifiers[$2.section].displayName
}
let dataSource = UICollectionViewDiffableDataSource
<PickerEmoji.Category, PickerEmoji>(collectionView: collectionView) {
$0.dequeueConfiguredReusableCell(using: cellRegistration, for: $1, item: $2)
}
dataSource.supplementaryViewProvider = {
$0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2)
}
return dataSource
}()
2021-01-15 21:43:46 +00:00
private lazy var defaultSkinToneSelectionMenu: UIMenu = {
let clearSkinToneAction = UIAction(title: SystemEmoji.SkinTone.noneExample) { [weak self] _ in
2021-02-06 00:30:57 +00:00
self?.skinToneButton.title = SystemEmoji.SkinTone.noneExample
2021-01-26 00:06:35 +00:00
self?.viewModel.identityContext.appPreferences.defaultEmojiSkinTone = nil
2021-01-15 21:43:46 +00:00
self?.reloadVisibleItems()
}
let setSkinToneActions = SystemEmoji.SkinTone.allCases.map { [weak self] skinTone in
UIAction(title: skinTone.example) { _ in
2021-02-06 00:30:57 +00:00
self?.skinToneButton.title = skinTone.example
2021-01-26 00:06:35 +00:00
self?.viewModel.identityContext.appPreferences.defaultEmojiSkinTone = skinTone
2021-01-15 21:43:46 +00:00
self?.reloadVisibleItems()
}
}
return UIMenu(
title: NSLocalizedString("emoji.default-skin-tone", comment: ""),
children: [clearSkinToneAction] + setSkinToneActions)
}()
2021-01-15 10:13:10 +00:00
init(viewModel: EmojiPickerViewModel,
2021-02-06 00:30:57 +00:00
selectionAction: @escaping (EmojiPickerViewController, PickerEmoji) -> Void,
deletionAction: @escaping (EmojiPickerViewController) -> Void,
searchPresentationAction: @escaping (EmojiPickerViewController, UINavigationController) -> Void) {
2021-01-14 17:49:53 +00:00
self.viewModel = viewModel
2021-01-15 10:13:10 +00:00
self.selectionAction = selectionAction
2021-02-06 00:30:57 +00:00
self.deletionAction = deletionAction
self.searchPresentationAction = searchPresentationAction
2021-01-14 17:49:53 +00:00
2021-02-06 00:30:57 +00:00
super.init(collectionViewLayout: Self.layout())
2021-01-14 17:49:53 +00:00
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
2021-01-15 21:43:46 +00:00
// swiftlint:disable:next function_body_length
2021-01-14 17:49:53 +00:00
override func viewDidLoad() {
super.viewDidLoad()
searchBar.translatesAutoresizingMaskIntoConstraints = false
searchBar.searchBarStyle = .minimal
2021-01-15 10:13:10 +00:00
searchBar.placeholder = NSLocalizedString("emoji.search", comment: "")
searchBar.searchTextField.addAction(
2021-01-16 02:22:03 +00:00
UIAction { [weak self] _ in
self?.viewModel.query = self?.searchBar.text ?? ""
self?.collectionView.setContentOffset(.zero, animated: false)
},
2021-01-15 10:13:10 +00:00
for: .editingChanged)
2021-02-06 00:30:57 +00:00
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)
2021-01-15 10:13:10 +00:00
2021-02-02 03:47:48 +00:00
skinToneButton.accessibilityLabel =
NSLocalizedString("emoji.default-skin-tone-button.accessibility-label", comment: "")
2021-01-15 21:43:46 +00:00
2021-02-06 00:30:57 +00:00
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)
}
2021-01-15 10:13:10 +00:00
collectionView.backgroundColor = .clear
collectionView.dataSource = dataSource
2021-02-02 03:47:48 +00:00
collectionView.isAccessibilityElement = false
collectionView.shouldGroupAccessibilityChildren = true
2021-01-14 17:49:53 +00:00
NSLayoutConstraint.activate([
2021-02-06 00:30:57 +00:00
presentSearchButton.leadingAnchor.constraint(equalTo: searchBar.leadingAnchor),
presentSearchButton.topAnchor.constraint(equalTo: searchBar.topAnchor),
presentSearchButton.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor),
presentSearchButton.bottomAnchor.constraint(equalTo: searchBar.bottomAnchor)
2021-01-14 17:49:53 +00:00
])
2021-01-15 10:13:10 +00:00
viewModel.$emoji
2021-01-15 21:43:46 +00:00
.sink { [weak self] in self?.dataSource.apply(
$0.snapshot(),
animatingDifferences: !UIAccessibility.isReduceMotionEnabled) }
2021-01-15 10:13:10 +00:00
.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)
2021-01-14 17:49:53 +00:00
}
2021-02-06 00:30:57 +00:00
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
2021-02-15 08:47:30 +00:00
collectionView.deselectItem(at: indexPath, animated: true)
2021-01-15 21:43:46 +00:00
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
2021-01-15 10:13:10 +00:00
2021-02-15 19:17:06 +00:00
select(emoji: item.applyingDefaultSkinTone(identityContext: viewModel.identityContext))
2021-01-16 00:58:10 +00:00
viewModel.updateUse(emoji: item)
2021-01-15 21:43:46 +00:00
}
2021-01-15 10:13:10 +00:00
2021-02-06 00:30:57 +00:00
override func collectionView(_ collectionView: UICollectionView,
contextMenuConfigurationForItemAt indexPath: IndexPath,
point: CGPoint) -> UIContextMenuConfiguration? {
2021-01-15 21:43:46 +00:00
guard let item = dataSource.itemIdentifier(for: indexPath),
2021-01-16 00:58:10 +00:00
case let .system(emoji, inFrequentlyUsed) = item,
2021-01-15 21:43:46 +00:00
!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
2021-01-16 00:58:10 +00:00
self?.select(emoji: .system(skinToneVariation, inFrequentlyUsed: inFrequentlyUsed))
self?.viewModel.updateUse(emoji: item)
2021-01-15 21:43:46 +00:00
}
})
}
2021-01-15 10:13:10 +00:00
}
}
private extension EmojiPickerViewController {
static let headerElementKind = "com.metabolist.metatext.emoji-picker.header"
2021-01-15 21:43:46 +00:00
2021-02-06 00:30:57 +00:00
static func layout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(.minimumButtonDimension),
heightDimension: .absolute(.minimumButtonDimension))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(.minimumButtonDimension))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .flexible(.defaultSpacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = .defaultSpacing
section.contentInsets = NSDirectionalEdgeInsets(
top: .defaultSpacing,
leading: .defaultSpacing,
bottom: .defaultSpacing,
trailing: .defaultSpacing)
let headerSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .estimated(.defaultSpacing))
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerSize,
elementKind: Self.headerElementKind,
alignment: .top)
section.boundarySupplementaryItems = [header]
return UICollectionViewCompositionalLayout(section: section)
}
2021-01-15 21:43:46 +00:00
func select(emoji: PickerEmoji) {
2021-02-06 00:30:57 +00:00
selectionAction(self, emoji)
2021-01-15 21:43:46 +00:00
UISelectionFeedbackGenerator().selectionChanged()
}
2021-02-06 00:30:57 +00:00
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)
}
2021-01-15 21:43:46 +00:00
func reloadVisibleItems() {
var snapshot = dataSource.snapshot()
let visibleItems = collectionView.indexPathsForVisibleItems.compactMap(dataSource.itemIdentifier(for:))
snapshot.reloadItems(visibleItems)
dataSource.apply(snapshot)
}
2021-01-14 17:49:53 +00:00
}