Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Emoji/EmojiPickerSectionToolbar.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

protocol EmojiPickerSectionToolbarDelegate: AnyObject {
    func emojiPickerSectionToolbar(_ sectionToolbar: EmojiPickerSectionToolbar, didSelectSection: Int)
    func emojiPickerSectionToolbarShouldShowRecentsSection(_ sectionToolbar: EmojiPickerSectionToolbar) -> Bool
}

class EmojiPickerSectionToolbar: UIView, UICollectionViewDelegate {
    private var buttons = [UIButton]()

    private weak var delegate: EmojiPickerSectionToolbarDelegate?

    private enum Section {
        case main
    }

    private var dataSource: UICollectionViewDiffableDataSource<Section, ThemeIcon>!
    private var collectionView: UICollectionView!

    private static let collectionViewSectionMargin: CGFloat = 4

    private struct EmojiSectionCellContentViewConfiguration: UIContentConfiguration {
        let emojiSectionIcon: ThemeIcon
        var displayBackgroundView: Bool = false

        func makeContentView() -> UIView & UIContentView {
            return EmojiSectionCellContentView(configuration: self)
        }

        func updated(for state: any UIConfigurationState) -> EmojiSectionCellContentViewConfiguration {
            guard let cellState = state as? UICellConfigurationState else {
                return self
            }
            var configuration = self
            configuration.displayBackgroundView = cellState.isSelected
            return configuration
        }
    }

    private class EmojiSectionCellContentView: UIView, UIContentView {
        var configuration: UIContentConfiguration {
            didSet {
                configure()
            }
        }

        private let backgroundView = UIView()
        private let imageView = UIImageView()
        private static let imageSize: CGFloat = 22
        static let viewSize: CGFloat = 40

        init(configuration: EmojiSectionCellContentViewConfiguration) {
            self.configuration = configuration

            super.init(frame: .zero)

            // Use simple opaque UIView as part of the content view because
            // UIBackgroundConfiguration wasn't updating reliably.
            // Background will be using the color of images in unselected cells.
            addSubview(backgroundView)
            backgroundView.backgroundColor = UIColor.Signal.secondaryFill

            addSubview(imageView)
            imageView.contentMode = .scaleAspectFill
            imageView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                imageView.widthAnchor.constraint(equalToConstant: EmojiSectionCellContentView.imageSize),
                imageView.heightAnchor.constraint(equalToConstant: EmojiSectionCellContentView.imageSize),
                imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
                imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            ])

            configure()
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        override func layoutSubviews() {
            super.layoutSubviews()

            let circleSize = min(bounds.width, bounds.height)
            backgroundView.center = bounds.center
            backgroundView.bounds = CGRect(x: 0, y: 0, width: circleSize, height: circleSize)
            backgroundView.layer.cornerRadius = 0.5 * circleSize
        }

        private func configure() {
            guard let configuration = configuration as? EmojiSectionCellContentViewConfiguration else {
                return
            }
            backgroundView.isHidden = !configuration.displayBackgroundView
            imageView.image = Theme.iconImage(configuration.emojiSectionIcon)
            if #available(iOS 26, *) {
                imageView.tintColor = UIColor.Signal.label
            } else {
                imageView.tintColor = UIColor.Signal.secondaryLabel
            }
        }
    }

    init(
        delegate: EmojiPickerSectionToolbarDelegate,
    ) {
        self.delegate = delegate

        super.init(frame: .zero)

        // Prepare icons.
        var emojiSectionIcons: [ThemeIcon] = [
            .emojiSmiley,
            .emojiAnimal,
            .emojiFood,
            .emojiActivity,
            .emojiTravel,
            .emojiObject,
            .emojiSymbol,
            .emojiFlag,
        ]
        if delegate.emojiPickerSectionToolbarShouldShowRecentsSection(self) == true {
            emojiSectionIcons.insert(.emojiRecent, at: 0)
        }

        // Create and configure collection view.
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: Self.buildCollectionViewLayout(numberOfItems: emojiSectionIcons.count))
        collectionView.delegate = self
        collectionView.backgroundColor = .clear
        collectionView.allowsMultipleSelection = false
        collectionView.alwaysBounceVertical = false
        collectionView.insetsLayoutMarginsFromSafeArea = true
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        let collectionViewHeight = EmojiSectionCellContentView.viewSize + 2 * Self.collectionViewSectionMargin
        collectionView.addConstraint(collectionView.heightAnchor.constraint(equalToConstant: collectionViewHeight))

        // Prepare background.
        var backgroundConfigured = false
        if #available(iOS 26, *) {
            // Floating glass panel that encapsulates emoji category strip.
            // Insets are carefully configured for best on-screen appearance.
            let glassEffectView = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
            glassEffectView.translatesAutoresizingMaskIntoConstraints = false
            glassEffectView.cornerConfiguration = .capsule()
            addSubview(glassEffectView)
            NSLayoutConstraint.activate([
                glassEffectView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor, constant: -1),
                glassEffectView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor, constant: 1),
                glassEffectView.topAnchor.constraint(equalTo: topAnchor, constant: -2),
                glassEffectView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: 0),
            ])

            glassEffectView.clipsToBounds = true
            glassEffectView.contentView.addSubview(collectionView)
            NSLayoutConstraint.activate([
                collectionView.leadingAnchor.constraint(equalTo: glassEffectView.leadingAnchor),
                collectionView.trailingAnchor.constraint(equalTo: glassEffectView.trailingAnchor),
                collectionView.topAnchor.constraint(equalTo: glassEffectView.topAnchor),
                collectionView.bottomAnchor.constraint(equalTo: glassEffectView.bottomAnchor),
            ])
            backgroundConfigured = true
        }
        if !backgroundConfigured {
            // Background stretches 500 dp below bottom edge of the screen so that there's no gap where bottom safe area is.
            if UIAccessibility.isReduceTransparencyEnabled {
                let backgroundView = UIView()
                backgroundView.backgroundColor = UIColor.Signal.background
                addSubview(backgroundView)
                backgroundView.translatesAutoresizingMaskIntoConstraints = false
                NSLayoutConstraint.activate([
                    backgroundView.topAnchor.constraint(equalTo: topAnchor),
                    backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
                    backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 500),
                    backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
                ])

                addSubview(collectionView)
            } else {
                let blurEffect = UIBlurEffect(style: .regular)
                let blurEffectView = UIVisualEffectView(effect: blurEffect)
                addSubview(blurEffectView)
                blurEffectView.translatesAutoresizingMaskIntoConstraints = false
                NSLayoutConstraint.activate([
                    blurEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
                    blurEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
                    blurEffectView.topAnchor.constraint(equalTo: topAnchor),
                    blurEffectView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 500),
                ])
                blurEffectView.contentView.addSubview(collectionView)
            }

            NSLayoutConstraint.activate([
                collectionView.topAnchor.constraint(equalTo: topAnchor),
                collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
                collectionView.bottomAnchor.constraint(equalTo: bottomAnchor),
                collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
            ])
        }

        // Configure data source.
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        dataSource = UICollectionViewDiffableDataSource<Section, ThemeIcon>(
            collectionView: collectionView,
        ) { collectionView, indexPath, itemIdentifier -> UICollectionViewCell? in

            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
            cell.automaticallyUpdatesContentConfiguration = true
            cell.contentConfiguration = EmojiSectionCellContentViewConfiguration(emojiSectionIcon: itemIdentifier)

            return cell
        }

        // Populate collection view with data.
        var snapshot = NSDiffableDataSourceSnapshot<Section, ThemeIcon>()
        snapshot.appendSections([.main])
        snapshot.appendItems(emojiSectionIcons)
        dataSource.apply(snapshot, animatingDifferences: false)

        setSelectedSection(0)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: Collection View

    private class func buildCollectionViewLayout(numberOfItems: Int) -> UICollectionViewLayout {
        let cellSize = EmojiSectionCellContentView.viewSize

        let layout = UICollectionViewCompositionalLayout { _, environment in
            let itemSize = NSCollectionLayoutSize(
                widthDimension: .absolute(cellSize),
                heightDimension: .absolute(cellSize),
            )
            let item = NSCollectionLayoutItem(layoutSize: itemSize)

            let availableWidth = environment.container.effectiveContentSize.width - (collectionViewSectionMargin * 2)

            let totalSpacing = collectionViewSectionMargin * CGFloat(numberOfItems - 1)
            let minimumWidth = CGFloat(numberOfItems) * cellSize + totalSpacing

            if minimumWidth <= availableWidth {
                let groupSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1),
                    heightDimension: .absolute(cellSize),
                )
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: numberOfItems)
                group.interItemSpacing = .fixed(collectionViewSectionMargin)

                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .none
                section.contentInsets = NSDirectionalEdgeInsets(margin: collectionViewSectionMargin)
                return section
            } else {
                let groupSize = NSCollectionLayoutSize(
                    widthDimension: .absolute(cellSize),
                    heightDimension: .absolute(cellSize),
                )
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .continuous
                section.interGroupSpacing = collectionViewSectionMargin
                section.contentInsets = .init(
                    hMargin: collectionViewSectionMargin * 2,
                    vMargin: collectionViewSectionMargin,
                )
                return section
            }
        }

        return layout
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
        delegate?.emojiPickerSectionToolbar(self, didSelectSection: indexPath.item)
    }

    // MARK: Selection

    func setSelectedSection(_ section: Int) {
        collectionView.selectItem(
            at: IndexPath(item: section, section: 0),
            animated: true,
            scrollPosition: .centeredHorizontally,
        )
    }
}