// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import SessionUIKit import SessionUtilitiesKit import SignalCoreKit protocol EmojiPickerCollectionViewDelegate: AnyObject { func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones) func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView) } class EmojiPickerCollectionView: UICollectionView { let layout: UICollectionViewFlowLayout weak var pickerDelegate: EmojiPickerCollectionViewDelegate? private var recentEmoji: [EmojiWithSkinTones] = [] var hasRecentEmoji: Bool { !recentEmoji.isEmpty } private var allSendableEmojiByCategory: [Emoji.Category: [EmojiWithSkinTones]] = [:] private lazy var allSendableEmoji: [EmojiWithSkinTones] = { return Array(allSendableEmojiByCategory.values).flatMap({$0}) }() static let emojiWidth: CGFloat = 38 static let margins: CGFloat = 16 static let minimumSpacing: CGFloat = 10 public var searchText: String? { didSet { searchWithText(searchText) } } private var emojiSearchResults: [EmojiWithSkinTones] = [] public var isSearching: Bool { if let searchText = searchText, searchText.count != 0 { return true } return false } lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker)) // MARK: - Initialization init() { layout = UICollectionViewFlowLayout() layout.itemSize = CGSize(width: Self.emojiWidth, height: Self.emojiWidth) layout.minimumInteritemSpacing = EmojiPickerCollectionView.minimumSpacing layout.sectionInset = UIEdgeInsets(top: 0, leading: EmojiPickerCollectionView.margins, bottom: 0, trailing: EmojiPickerCollectionView.margins) super.init(frame: .zero, collectionViewLayout: layout) delegate = self dataSource = self register(EmojiCell.self, forCellWithReuseIdentifier: EmojiCell.reuseIdentifier) register( EmojiSectionHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: EmojiSectionHeader.reuseIdentifier ) themeBackgroundColor = .clear let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) panGestureRecognizer.require(toFail: longPressGesture) addGestureRecognizer(longPressGesture) addGestureRecognizer(tapGestureRecognizer) tapGestureRecognizer.delegate = self // Fetch the emoji data from the database let maybeEmojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]])? = Storage.shared.read { db in // Some emoji have two different code points but identical appearances. Let's remove them! // If we normalize to a different emoji than the one currently in our array, we want to drop // the non-normalized variant if the normalized variant already exists. Otherwise, map to the // normalized variant. let recentEmoji: [EmojiWithSkinTones] = try Emoji.getRecent(db, withDefaultEmoji: false) .compactMap { EmojiWithSkinTones(rawValue: $0) } .reduce(into: [EmojiWithSkinTones]()) { result, emoji in guard !emoji.isNormalized else { result.append(emoji) return } guard !result.contains(emoji.normalized) else { return } result.append(emoji.normalized) } let allSendableEmojiByCategory: [Emoji.Category: [EmojiWithSkinTones]] = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(db) return (recentEmoji, allSendableEmojiByCategory) } if let emojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]]) = maybeEmojiData { self.recentEmoji = emojiData.recent self.allSendableEmojiByCategory = emojiData.allGrouped } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // This is not an exact calculation, but is simple and works for our purposes. var numberOfColumns: Int { Int((self.width()) / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing)) } // At max, we show 3 rows of recent emoji private var maxRecentEmoji: Int { numberOfColumns * 3 } private var categoryIndexOffset: Int { hasRecentEmoji ? 1 : 0} func emojiForSection(_ section: Int) -> [EmojiWithSkinTones] { guard section > 0 || !hasRecentEmoji else { return Array(recentEmoji[0.. EmojiWithSkinTones? { return isSearching ? emojiSearchResults[safe: indexPath.row] : emojiForSection(indexPath.section)[safe: indexPath.row] } func nameForSection(_ section: Int) -> String? { guard section > 0 || !hasRecentEmoji else { return NSLocalizedString("EMOJI_CATEGORY_RECENTS_NAME", comment: "The name for the emoji category 'Recents'") } guard let category = Emoji.Category.allCases[safe: section - categoryIndexOffset] else { owsFailDebug("Unexpectedly missing category for section \(section)") return nil } return category.localizedName } // MARK: - Search func searchWithText(_ searchText: String?) { if let searchText = searchText { emojiSearchResults = allSendableEmoji.filter { emoji in return emoji.baseEmoji?.name.range(of: searchText, options: [.caseInsensitive]) != nil } } else { emojiSearchResults = [] } reloadData() } var scrollingToSection: Int? func scrollToSectionHeader(_ section: Int, animated: Bool) { guard let attributes = layoutAttributesForSupplementaryElement( ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: section) ) else { return } scrollingToSection = section setContentOffset(CGPoint(x: 0, y: (attributes.frame.minY - contentInset.top)), animated: animated) } private weak var currentSkinTonePicker: EmojiSkinTonePicker? @objc func handleLongPress(sender: UILongPressGestureRecognizer) { switch sender.state { case .began: let point = sender.location(in: self) guard let indexPath = indexPathForItem(at: point) else { return } guard let emoji = emojiForIndexPath(indexPath) else { return } guard let cell = cellForItem(at: indexPath) else { return } currentSkinTonePicker?.dismiss() currentSkinTonePicker = EmojiSkinTonePicker.present(referenceView: cell, emoji: emoji) { [weak self] emoji in if let emoji: EmojiWithSkinTones = emoji { Storage.shared.writeAsync { db in emoji.baseEmoji?.setPreferredSkinTones( db, preferredSkinTonePermutation: emoji.skinTones ) } self?.pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji) } self?.currentSkinTonePicker?.dismiss() self?.currentSkinTonePicker = nil } case .changed: currentSkinTonePicker?.didChangeLongPress(sender) case .ended: currentSkinTonePicker?.didEndLongPress(sender) default: break } } @objc func dismissSkinTonePicker() { currentSkinTonePicker?.dismiss() currentSkinTonePicker = nil } } extension EmojiPickerCollectionView: UIGestureRecognizerDelegate { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer == tapGestureRecognizer { return currentSkinTonePicker != nil } return true } } extension EmojiPickerCollectionView: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let emoji = emojiForIndexPath(indexPath) else { return owsFailDebug("Missing emoji for indexPath \(indexPath)") } pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji) } } extension EmojiPickerCollectionView: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return isSearching ? emojiSearchResults.count : emojiForSection(section).count } func numberOfSections(in collectionView: UICollectionView) -> Int { return isSearching ? 1 : Emoji.Category.allCases.count + categoryIndexOffset } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = dequeueReusableCell(withReuseIdentifier: EmojiCell.reuseIdentifier, for: indexPath) guard let emojiCell = cell as? EmojiCell else { owsFailDebug("unexpected cell type") return cell } guard let emoji = emojiForIndexPath(indexPath) else { owsFailDebug("unexpected indexPath") return cell } emojiCell.configure(emoji: emoji) return cell } func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let supplementaryView = dequeueReusableSupplementaryView( ofKind: kind, withReuseIdentifier: EmojiSectionHeader.reuseIdentifier, for: indexPath ) guard let sectionHeader = supplementaryView as? EmojiSectionHeader else { owsFailDebug("unexpected supplementary view type") return supplementaryView } sectionHeader.label.text = nameForSection(indexPath.section) return sectionHeader } } extension EmojiPickerCollectionView: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { guard !isSearching else { return CGSize.zero } let measureCell = EmojiSectionHeader() measureCell.label.text = nameForSection(section) return measureCell.sizeThatFits(CGSize(width: self.width(), height: .greatestFiniteMagnitude)) } } private class EmojiCell: UICollectionViewCell { static let reuseIdentifier = "EmojiCell" let emojiLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) themeBackgroundColor = .clear emojiLabel.font = .boldSystemFont(ofSize: 32) contentView.addSubview(emojiLabel) emojiLabel.autoPinEdgesToSuperviewEdges() // For whatever reason, some emoji glyphs occasionally have different typographic widths on certain devices // e.g. 👩‍🦰: 36x38.19, 👱‍♀️: 40x38. (See: commit message for more info) // To workaround this, we can clip the label instead of truncating. It appears to only clip the additional // typographic space. In either case, it's better than truncating and seeing an ellipsis. emojiLabel.lineBreakMode = .byClipping } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(emoji: EmojiWithSkinTones) { emojiLabel.text = emoji.rawValue } } private class EmojiSectionHeader: UICollectionReusableView { static let reuseIdentifier = "EmojiSectionHeader" let label = UILabel() override init(frame: CGRect) { super.init(frame: frame) layoutMargins = UIEdgeInsets( top: 16, leading: EmojiPickerCollectionView.margins, bottom: 6, trailing: EmojiPickerCollectionView.margins ) label.font = .systemFont(ofSize: Values.smallFontSize) label.themeTextColor = .textPrimary addSubview(label) label.autoPinEdgesToSuperviewMargins() label.setCompressionResistanceHigh() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func sizeThatFits(_ size: CGSize) -> CGSize { var labelSize = label.sizeThatFits(size) labelSize.width += layoutMargins.left + layoutMargins.right labelSize.height += layoutMargins.top + layoutMargins.bottom return labelSize } }