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

public import SignalServiceKit

public protocol StickerPackCollectionViewDelegate: StickerPickerDelegate {
    func stickerPreviewHostView() -> UIView?
    func stickerPreviewHasOverlay() -> Bool
}

public class StickerPackCollectionView: UICollectionView {

    private typealias StorySticker = EditorSticker.StorySticker

    private var stickerPackDataSource: StickerPackDataSource? {
        didSet {
            stickerPackDataSource?.add(delegate: self)

            reloadStickers()

            // Scroll to the top.
            contentOffset.y = -contentInset.top
        }
    }

    private var stickerInfos = [StickerInfo]()

    public var stickerCount: Int {
        return stickerInfos.count
    }

    public weak var stickerDelegate: StickerPackCollectionViewDelegate?

    private var shouldShowStoryStickers: Bool {
        if case .showWithDelegate = storyStickerConfiguration {
            // Story sticker configuration must be `showWithDelegate`
            // while also being a "Recents" page.
            return stickerPackDataSource is RecentStickerPackDataSource
        }

        return false
    }

    override public var bounds: CGRect {
        didSet {
            // This is necessary in case view width changes but safe areas don't.
            if bounds.width != oldValue.width {
                updateLayout()
            }
        }
    }

    override public var contentInset: UIEdgeInsets {
        didSet {
            // Content insets affect width available for content.
            if contentInset.totalWidth != oldValue.totalWidth {
                updateLayout()
            }
            if let contentUnavailableViewConstraints {
                contentUnavailableViewConstraints.update(with: contentInset)
            }
        }
    }

    override public func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        // Update layout since we use `safeAreaLayoutGuide` to calculate layout attrs.
        updateLayout()
    }

    private let cellReuseIdentifier = "cellReuseIdentifier"
    private let headerReuseIdentifier = StickerPickerHeaderView.reuseIdentifier
    private let placeholderColor: UIColor

    private let storyStickerConfiguration: StoryStickerConfiguration

    public init(
        placeholderColor: UIColor = .ows_gray45,
        storyStickerConfiguration: StoryStickerConfiguration = .hide,
    ) {
        self.placeholderColor = placeholderColor
        self.storyStickerConfiguration = storyStickerConfiguration

        super.init(frame: .zero, collectionViewLayout: StickerPackCollectionView.buildLayout())

        backgroundColor = .clear

        delegate = self
        dataSource = self

        register(UICollectionViewCell.self, forCellWithReuseIdentifier: cellReuseIdentifier)
        register(StickerPickerHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: headerReuseIdentifier)

        addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)))
    }

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

    // MARK: Modes

    public func showInstalledPack(stickerPack: StickerPackRecord) {
        stickerPackDataSource = InstalledStickerPackDataSource(stickerPackInfo: stickerPack.info)
    }

    public func showUninstalledPack(stickerPack: StickerPackRecord) {
        stickerPackDataSource = TransientStickerPackDataSource(
            stickerPackInfo: stickerPack.info,
            shouldDownloadAllStickers: true,
        )
    }

    public func showRecents() {
        stickerPackDataSource = RecentStickerPackDataSource()
    }

    public func showInstalledPackOrRecents(stickerPack: StickerPackRecord?) {
        if let stickerPack {
            showInstalledPack(stickerPack: stickerPack)
        } else {
            showRecents()
        }
    }

    public func show(dataSource: StickerPackDataSource) {
        stickerPackDataSource = dataSource
    }

    // MARK: Empty Content view

    private struct EdgeConstraints {
        let top: NSLayoutConstraint
        let leading: NSLayoutConstraint
        let bottom: NSLayoutConstraint
        let trailing: NSLayoutConstraint

        var constraints: [NSLayoutConstraint] {
            [top, leading, bottom, trailing]
        }

        func update(with insets: UIEdgeInsets) {
            top.constant = insets.top
            leading.constant = insets.leading
            bottom.constant = -insets.bottom
            trailing.constant = -insets.trailing
        }
    }

    private var contentUnavailableView: UIView?

    private var contentUnavailableViewConstraints: EdgeConstraints?

    private func createContentUnavailableView() -> UIView {
        let view = UIView()
        view.directionalLayoutMargins = .init(margin: 20)
        let titleLabel = UILabel.explanationTextLabel(text: OWSLocalizedString(
            "STICKER_CATEGORY_RECENTS_EMPTY_TITLE",
            comment: "Title of the helper text displayed when Recent stickers are empty.",
        ))
        titleLabel.adjustsFontForContentSizeCategory = true
        titleLabel.font = .dynamicTypeHeadline // slightly larger than subtitle
        let subtitleLabel = UILabel.explanationTextLabel(text: OWSLocalizedString(
            "STICKER_CATEGORY_RECENTS_EMPTY_SUBTITLE",
            comment: "Subtitle of the helper text displayed when Recent stickers are empty.",
        ))
        subtitleLabel.adjustsFontForContentSizeCategory = true
        let vStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
        vStack.axis = .vertical
        vStack.spacing = 2
        vStack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(vStack)
        NSLayoutConstraint.activate([
            vStack.topAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.topAnchor),
            vStack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            vStack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            vStack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
        ])
        return view
    }

    private func updateEmptyState() {
        let isEmpty = stickerInfos.isEmpty

        // "Content Unavailable" view is created on demand here.
        if isEmpty, contentUnavailableView == nil {
            let view = createContentUnavailableView()
            view.translatesAutoresizingMaskIntoConstraints = false
            addSubview(view)
            let constraints = EdgeConstraints(
                top: view.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
                leading: view.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
                bottom: view.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor),
                trailing: view.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
            )
            constraints.update(with: contentInset)
            NSLayoutConstraint.activate(constraints.constraints)

            contentUnavailableView = view
            contentUnavailableViewConstraints = constraints
        }

        if isEmpty, let contentUnavailableView {
            bringSubviewToFront(contentUnavailableView)
            contentUnavailableView.isHidden = false
        } else {
            contentUnavailableView?.isHidden = true
        }
    }

    // MARK: Events

    private func reloadStickers() {
        AssertIsOnMainThread()

        defer { reloadData() }

        guard let stickerPackDataSource else {
            stickerInfos = []
            return
        }

        let installedStickerInfos = stickerPackDataSource.installedStickerInfos

        if stickerPackDataSource is TransientStickerPackDataSource {
            guard let allStickerInfos = stickerPackDataSource.getStickerPack()?.stickerInfos() else {
                stickerInfos = []
                owsAssertDebug(installedStickerInfos.isEmpty)
                return
            }

            stickerInfos = allStickerInfos
            owsAssertDebug(stickerInfos.count >= installedStickerInfos.count)
        } else {
            stickerInfos = installedStickerInfos
        }
    }

    override public func reloadData() {
        super.reloadData()

        updateEmptyState()
    }

    // MARK: Sticker Preview

    @objc
    private func handleLongPress(sender: UIGestureRecognizer) {
        switch sender.state {
        case .began, .changed:
            break
        case .possible, .ended, .cancelled, .failed:
            fallthrough
        @unknown default:
            hidePreview()
            return
        }

        // Do nothing if we're not currently pressing on a pack, we'll hide it when we release
        // or update it when the user moves their touch over another pack. This prevents "flashing"
        // as the user moves their finger between packs.
        guard
            let indexPath = self.indexPathForItem(at: sender.location(in: self)),
            !isStoryStickerSection(sectionIndex: indexPath.section) else { return }
        guard let stickerInfo = stickerInfos[safe: indexPath.row] else {
            owsFailDebug("Invalid index path: \(indexPath)")
            return
        }

        ensurePreview(stickerInfo: stickerInfo)
    }

    private var previewView: UIView?

    private var previewStickerInfo: StickerInfo?

    private func hidePreview() {
        previewView?.removeFromSuperview()
        previewView = nil
        previewStickerInfo = nil
    }

    private func ensurePreview(stickerInfo: StickerInfo) {
        if previewView != nil, let previewStickerInfo, previewStickerInfo == stickerInfo {
            // Already showing a preview for this sticker.
            return
        }

        hidePreview()

        guard let stickerView = imageView(forStickerInfo: stickerInfo) else {
            Logger.warn("Couldn't load sticker for display")
            return
        }
        guard let stickerDelegate else {
            owsFailDebug("Missing stickerDelegate")
            return
        }
        guard let hostView = stickerDelegate.stickerPreviewHostView() else {
            owsFailDebug("Missing host view.")
            return
        }

        if stickerDelegate.stickerPreviewHasOverlay() {
            let overlayView = UIView()
            overlayView.backgroundColor = Theme.backgroundColor.withAlphaComponent(0.5)
            hostView.addSubview(overlayView)
            overlayView.autoPinEdgesToSuperviewEdges()
            overlayView.setContentHuggingLow()
            overlayView.setCompressionResistanceLow()
            overlayView.addSubview(stickerView)
            previewView = overlayView
        } else {
            hostView.addSubview(stickerView)
            previewView = stickerView
        }

        previewStickerInfo = stickerInfo

        stickerView.autoPinToSquareAspectRatio()
        stickerView.autoCenterInSuperview()
        let vMargin: CGFloat = 40
        let hMargin: CGFloat = 60
        stickerView.autoSetDimension(.width, toSize: hostView.height - vMargin * 2, relation: .lessThanOrEqual)
        stickerView.autoPinEdge(toSuperviewEdge: .top, withInset: vMargin, relation: .greaterThanOrEqual)
        stickerView.autoPinEdge(toSuperviewEdge: .bottom, withInset: vMargin, relation: .greaterThanOrEqual)
        stickerView.autoPinEdge(toSuperviewEdge: .leading, withInset: hMargin, relation: .greaterThanOrEqual)
        stickerView.autoPinEdge(toSuperviewEdge: .trailing, withInset: hMargin, relation: .greaterThanOrEqual)
    }

    private func imageView(forStickerInfo stickerInfo: StickerInfo) -> UIView? {
        guard let stickerPackDataSource else {
            owsFailDebug("Missing stickerPackDataSource.")
            return nil
        }
        return StickerView.stickerView(forStickerInfo: stickerInfo, dataSource: stickerPackDataSource)
    }

    private let reusableStickerViewCache = StickerViewCache(maxSize: 32)

    private func reusableStickerView(forStickerInfo stickerInfo: StickerInfo) -> StickerReusableView {
        let view: StickerReusableView = {
            if let view = reusableStickerViewCache.object(forKey: stickerInfo) { return view }
            let view = StickerReusableView()
            reusableStickerViewCache.setObject(view, forKey: stickerInfo)
            return view
        }()

        guard !view.hasStickerView else { return view }

        guard let imageView = imageView(forStickerInfo: stickerInfo) else {
            view.showPlaceholder(color: placeholderColor)
            return view
        }

        view.configure(with: imageView)

        return view
    }
}

// MARK: - UICollectionViewDelegate

extension StickerPackCollectionView: UICollectionViewDelegate {

    private func isStoryStickerSection(sectionIndex: Int) -> Bool {
        return shouldShowStoryStickers && sectionIndex == 0
    }

    public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        Logger.debug("")

        if isStoryStickerSection(sectionIndex: indexPath.section) {
            guard let storySticker = StorySticker.pickerStickers[safe: indexPath.item] else {
                owsFailDebug("Invalid index path: \(indexPath)")
                return
            }
            guard case .showWithDelegate(let storyStickerPickerDelegate) = storyStickerConfiguration else {
                owsFailDebug("Unexpectedly found hidden story stickers.")
                return
            }

            storyStickerPickerDelegate.didSelect(storySticker: storySticker)
            return
        }

        guard let stickerInfo = stickerInfos[safe: indexPath.row] else {
            owsFailDebug("Invalid index path: \(indexPath)")
            return
        }

        self.stickerDelegate?.didSelectSticker(stickerInfo)
    }
}

// MARK: - UICollectionViewDataSource

extension StickerPackCollectionView: UICollectionViewDataSource {

    public func numberOfSections(in collectionView: UICollectionView) -> Int {
        return shouldShowStoryStickers ? 2 : 1
    }

    public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int {
        if isStoryStickerSection(sectionIndex: sectionIdx) {
            return StorySticker.pickerStickers.count
        }
        return stickerInfos.count
    }

    public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath)
        cell.contentView.removeAllSubviews()

        if isStoryStickerSection(sectionIndex: indexPath.section) {
            guard let storySticker = StorySticker.pickerStickers[safe: indexPath.row] else {
                owsFailDebug("Invalid index path: \(indexPath)")
                return cell
            }
            let stickerView = storySticker.previewView()
            cell.contentView.addSubview(stickerView)
            stickerView.autoPinEdgesToSuperviewEdges()
            return cell
        }

        guard let stickerInfo = stickerInfos[safe: indexPath.row] else {
            owsFailDebug("Invalid index path: \(indexPath)")
            return cell
        }

        let cellView = reusableStickerView(forStickerInfo: stickerInfo)
        cell.contentView.addSubview(cellView)
        cellView.autoPinEdgesToSuperviewEdges()

        return cell
    }

    public func collectionView(
        _ collectionView: UICollectionView,
        viewForSupplementaryElementOfKind kind: String,
        at indexPath: IndexPath,
    ) -> UICollectionReusableView {
        let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: headerReuseIdentifier, for: indexPath)

        guard
            kind == UICollectionView.elementKindSectionHeader,
            let headerLabel = headerView as? StickerPickerHeaderView
        else {
            return headerView
        }

        headerLabel.label.text = self.headerText(for: indexPath.section)

        return headerLabel
    }

    private func headerText(for section: Int) -> String? {
        guard shouldShowStoryStickers else { return nil }
        if section == 0 {
            return OWSLocalizedString(
                "STICKER_CATEGORY_FEATURED_NAME",
                comment: "The name for the sticker category 'Featured'",
            )
        } else {
            return OWSLocalizedString(
                "STICKER_CATEGORY_RECENTS_NAME",
                comment: "The name for the sticker category 'Recents'",
            )
        }
    }
}

extension StickerPackCollectionView: UICollectionViewDelegateFlowLayout {

    public func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        referenceSizeForHeaderInSection section: Int,
    ) -> CGSize {
        guard let headerText = headerText(for: section) else { return .zero }

        let headerView = StickerPickerHeaderView()
        headerView.label.text = headerText
        return headerView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
    }
}

private class StickerPickerHeaderView: UICollectionReusableView {

    static let reuseIdentifier = "StickerPickerHeaderView"

    let label = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)

        layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 0)

        label.font = UIFont.dynamicTypeFootnoteClamped.semibold()
        label.textColor = Theme.darkThemeSecondaryTextAndIconColor
        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
    }
}

// MARK: - Layout

extension StickerPackCollectionView {

    private static let minimumCellSpacing: CGFloat = 8

    private class func buildLayout() -> UICollectionViewFlowLayout {
        let layout = UICollectionViewFlowLayout()
        layout.minimumInteritemSpacing = minimumCellSpacing
        layout.minimumLineSpacing = minimumCellSpacing
        return layout
    }

    func updateLayout() {
        guard let flowLayout = collectionViewLayout as? UICollectionViewFlowLayout else {
            // The layout isn't set while the view is being initialized.
            return
        }

        let contentWidth = safeAreaLayoutGuide.layoutFrame.size.width - contentInset.totalWidth
        let cellSpacing = Self.minimumCellSpacing
        let preferredCellSize: CGFloat = 80
        let columnCount = UInt((contentWidth + cellSpacing) / (preferredCellSize + cellSpacing))
        let cellWidth = (contentWidth - cellSpacing * (CGFloat(columnCount) - 1)) / CGFloat(columnCount)
        let itemSize = CGSize(square: cellWidth)

        if itemSize != flowLayout.itemSize {
            flowLayout.itemSize = itemSize
            flowLayout.invalidateLayout()
        }
    }
}

// MARK: -

extension StickerPackCollectionView: StickerPackDataSourceDelegate {

    public func stickerPackDataDidChange() {
        reloadStickers()
    }
}