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

import SignalServiceKit

protocol StickerPickerViewDelegate: StickerPickerDelegate {

    func presentManageStickersView(for: StickerPickerView)
}

class StickerPickerView: UIView {

    weak var delegate: StickerPickerViewDelegate?
    private let storyStickerConfigation: StoryStickerConfiguration

    var stickerPackCollectionViewPages: [UICollectionView] {
        stickerPageView.stickerPackCollectionViews
    }

    init(
        delegate: StickerPickerViewDelegate,
        storyStickerConfiguration: StoryStickerConfiguration = .hide,
    ) {
        self.delegate = delegate
        self.storyStickerConfigation = storyStickerConfiguration

        super.init(frame: .zero)

        addSubview(stickerPageView)
        stickerPageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            stickerPageView.topAnchor.constraint(equalTo: topAnchor),
            stickerPageView.leadingAnchor.constraint(equalTo: leadingAnchor),
            stickerPageView.trailingAnchor.constraint(equalTo: trailingAnchor),
            stickerPageView.bottomAnchor.constraint(equalTo: bottomAnchor),
        ])

        addSubview(toolbar)
        toolbar.preservesSuperviewLayoutMargins = true
        toolbar.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            toolbar.leadingAnchor.constraint(equalTo: leadingAnchor),
            toolbar.trailingAnchor.constraint(equalTo: trailingAnchor),
            toolbar.bottomAnchor.constraint(equalTo: bottomAnchor),
        ])

        if #available(iOS 26, *) {
            let interaction = UIScrollEdgeElementContainerInteraction()
            interaction.edge = .bottom
            interaction.scrollView = stickerPageView.scrollViewForScrollEdgeElementContainerInteraction
            toolbar.addInteraction(interaction)
        }

        updateStickerPageViewContentInsets()
    }

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

    // MARK: Presentation

    func willBePresented() {
        stickerPageView.willBePresented()
    }

    func wasPresented() {
        stickerPageView.wasPresented()
    }

    // MARK: Layout

    private lazy var toolbar = StickerPacksToolbar(delegate: self)
    private lazy var stickerPageView = StickerPickerPageView(
        delegate: self,
        storyStickerConfiguration: storyStickerConfigation,
    )

    override func layoutMarginsDidChange() {
        super.layoutMarginsDidChange()
        updateStickerPageViewContentInsets()
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // Necessary to update bottom inset after footer has its final position and size.
        DispatchQueue.main.async {
            self.updateStickerPageViewBottomContentInset()
        }
    }

    // Leading, top and trailing insets are derived from view's layout margins.
    private func updateStickerPageViewContentInsets() {
        var contentInset = stickerPageView.stickerPageContentInset
        contentInset.top = layoutMargins.top - safeAreaInsets.top
        contentInset.leading = layoutMargins.leading - safeAreaInsets.leading
        contentInset.trailing = layoutMargins.trailing - safeAreaInsets.trailing
        stickerPageView.stickerPageContentInset = contentInset
    }

    // Update bottom inset separately - it depends on size and position of the toolbar.
    private func updateStickerPageViewBottomContentInset() {
        guard toolbar.frame.height > 0 else { return }
        let bottomInset = safeAreaLayoutGuide.layoutFrame.maxY - toolbar.frame.minY
        stickerPageView.stickerPageContentInset.bottom = bottomInset
    }
}

extension StickerPickerView: StickerPacksToolbarDelegate {

    fileprivate func presentManageStickersView(for toolbar: StickerPacksToolbar) {
        delegate?.presentManageStickersView(for: self)
    }
}

extension StickerPickerView: StickerPickerPageViewDelegate {

    func setItems(_ items: [any StickerHorizontalListViewItem]) {
        toolbar.packsCollectionView.items = items
    }

    func updateSelections(scrollToSelectedItem: Bool) {
        toolbar.packsCollectionView.updateSelections(scrollToSelectedItem: scrollToSelectedItem)
    }

    func didSelectSticker(_ stickerInfo: StickerInfo) {
        delegate?.didSelectSticker(stickerInfo)
    }
}

// MARK: - StickerPacksToolbar

private protocol StickerPacksToolbarDelegate: AnyObject {

    func presentManageStickersView(for: StickerPacksToolbar)
}

/// Designed to be pinned to the bottom edge of the screen, stretched to leading and trailing edges of the view.
/// Toolbar will inherit superview's leading and trailing margins and will use them for content layout.
private class StickerPacksToolbar: UIView {

    weak var delegate: StickerPacksToolbarDelegate? {
        didSet {
            configureManageButton()
        }
    }

    init(delegate: StickerPacksToolbarDelegate) {
        self.delegate = delegate

        super.init(frame: .zero)

        directionalLayoutMargins = .zero

        //
        // Content layout is different on iOS 26 vs previous versions.
        // See below for layout explanation.
        //
        if #available(iOS 26, *) {
            // Glass capsule-shaped panel on iOS 26+.
            let glassEffect = UIGlassEffect(style: .regular)
            glassEffect.tintColor = .Signal.glassBackgroundTint
            let glassEffectView = UIVisualEffectView(effect: glassEffect)
            glassEffectView.clipsToBounds = true
            glassEffectView.cornerConfiguration = .capsule()

            glassEffectView.contentView.addSubview(stackView)
            addSubview(glassEffectView)

            visualEffectView = glassEffectView
        }
        // Blur on earlier iOS versions, but only if "Reduce Transparency" is disabled.
        else if UIAccessibility.isReduceTransparencyEnabled.negated {
            let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))

            blurEffectView.contentView.addSubview(stackView)
            addSubview(blurEffectView)

            visualEffectView = blurEffectView
        } else {
            // Basically the same layout as above, but with no blur effect view.

            backgroundColor = .Signal.background

            addSubview(stackView)
        }

        configureManageButton()
    }

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

    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        invalidateIntrinsicContentSize()
    }

    // MARK: - Layout

    override func invalidateIntrinsicContentSize() {
        super.invalidateIntrinsicContentSize()
        cachedHeight = 0
    }

    override var intrinsicContentSize: CGSize {
        calculateHeightIfNeeded()
        return CGSize(width: UIView.noIntrinsicMetric, height: cachedHeight)
    }

    private var cachedHeight: CGFloat = 0

    private func calculateHeightIfNeeded() {
        guard cachedHeight == 0 else { return }

        // Collection view height is the base.
        var height: CGFloat = Metrics.collectionViewHeight

        if #available(iOS 26, *) {
            // Vertical padding to glass container's vertical edges.
            height += 2 * Metrics.listVMargin

            // Bottom padding
            height += bottomContentMargin
        } else {
            // Padding above the sticker list.
            height += Metrics.listVMargin

            // Bottom padding
            height += bottomContentMargin
        }

        cachedHeight = height
    }

    private var bottomContentMargin: CGFloat {
        // Use 8 dp padding on devices with the home button that doesn't have bottom safe area inset.
        // Use fixed 28 dp padding on devices with a bottom safe area inset. The intent is to
        // make the toolbar wider than it would have been if we used default safeAreaInsets.bottom (30+ dp) insets.
        // The width is increased because side margins are made the same as this bottom margin.
        safeAreaInsets.bottom == 0 ? Metrics.minimumBottomMargin : 28
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        if #available(iOS 26, *) {
            layoutSubviewsForGlassBackground()
        } else {
            layoutSubviewsForBlurBackground()
        }
    }

    @available(iOS 26, *)
    private func layoutSubviewsForGlassBackground() {
        guard let visualEffectView else {
            owsFailBeta("No glass view")
            return
        }

        var sideMargin: CGFloat = 0
        var glassPanelWidth: CGFloat = 0
        if safeAreaInsets.totalWidth == 0 {
            // No left/right safe areas insets - use same amount as bottom padding.
            sideMargin = bottomContentMargin
            glassPanelWidth = bounds.width - 2 * sideMargin
        } else {
            // Non-zero left/right safe area margins - constrain width to safe area.
            sideMargin = safeAreaInsets.left
            glassPanelWidth = bounds.width - safeAreaInsets.totalWidth
        }
        visualEffectView.frame = CGRect(
            x: sideMargin,
            y: 0,
            width: glassPanelWidth,
            height: Metrics.collectionViewHeight + 2 * Metrics.listVMargin,
        )

        // Content is inset from glass panel's edges by the same amount on all sides.
        stackView.frame = visualEffectView.contentView.bounds.inset(by: .init(margin: Metrics.listVMargin))
    }

    @available(iOS, deprecated: 26)
    private func layoutSubviewsForBlurBackground() {
        // Blur, if present, covers the whole view.
        if let visualEffectView {
            visualEffectView.frame = bounds
        }

        // Use left/right layout margins as side margins (they include safe area insets).
        stackView.frame = CGRect(
            x: layoutMargins.left,
            y: Metrics.listVMargin,
            width: bounds.width - layoutMargins.totalWidth,
            height: Metrics.collectionViewHeight,
        )
    }

    private enum Metrics {
        static let listItemCellSize: CGFloat = 40 // side of each collection view cell.
        static let listItemContentInset: CGFloat = 6 // how much cell's content is inset from cell's edges.
        static let listItemSpacing: CGFloat = 4 // between cells

        static let listVMargin: CGFloat = 4 // spacing above and below collection view
        static let minimumBottomMargin: CGFloat = 8 // for devices with no bottom safe area

        static var collectionViewHeight: CGFloat { listItemCellSize }
    }

    // Glass on iOS 26+, blur or nothing on iOS 15-18.
    private var visualEffectView: UIVisualEffectView?

    // [scrollable list ][manage button]
    private lazy var stackView: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [packsCollectionView, buttonManageStickers])
        stackView.axis = .horizontal
        stackView.spacing = Metrics.listItemSpacing
        return stackView
    }()

    lazy var packsCollectionView: StickerHorizontalListView = {
        StickerHorizontalListView(
            cellSize: Metrics.listItemCellSize,
            cellContentInset: Metrics.listItemContentInset,
            spacing: Metrics.listItemSpacing,
        )
    }()

    private lazy var buttonManageStickers: UIButton = {
        let button = UIButton(
            configuration: .plain(),
            primaryAction: UIAction { [weak self] _ in
                guard let self else { return }
                self.delegate?.presentManageStickersView(for: self)
            },
        )
        if #available(iOS 26, *) {
            button.configuration?.cornerStyle = .capsule
        } else {
            button.configuration?.cornerStyle = .fixed
        }
        button.tintColor = .Signal.label
        button.configuration?.image = UIImage(named: "plus") // 24 dp
        button.configuration?.contentInsets = .init(margin: 8) // makes 40 dp button
        button.setContentHuggingHigh()
        button.setCompressionResistanceHigh()
        button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "manageButton")
        return button
    }()

    private func configureManageButton() {
        buttonManageStickers.isHidden = (delegate == nil)
    }
}

// MARK: - StickerPickerPageView

private protocol StickerPickerPageViewDelegate: StickerPickerDelegate {

    func setItems(_ items: [StickerHorizontalListViewItem])

    func updateSelections(scrollToSelectedItem: Bool)
}

private class StickerPickerPageView: UIView {

    private weak var delegate: StickerPickerPageViewDelegate?

    private let storyStickerConfiguration: StoryStickerConfiguration

    private var stickerPacks = [StickerPackRecord]()

    private var selectedStickerPack: StickerPackRecord? {
        didSet {
            selectedPackChanged(oldSelectedPack: oldValue)
        }
    }

    init(
        delegate: StickerPickerPageViewDelegate,
        storyStickerConfiguration: StoryStickerConfiguration = .hide,
    ) {
        self.delegate = delegate
        self.storyStickerConfiguration = storyStickerConfiguration

        super.init(frame: .zero)

        setupPaging()
        reloadStickers()

        // By default, show the "recent" stickers.
        assert(nil == selectedStickerPack)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(stickersOrPacksDidChange),
            name: StickerManager.stickersOrPacksDidChange,
            object: nil,
        )
    }

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

    func willBePresented() {
        // If there are no recents, default to showing the first sticker pack.
        if currentPageCollectionView.stickerCount < 1 {
            updateSelectedStickerPack(stickerPacks.first)
        }
    }

    func wasPresented() {
        updatePageConstraints(ignoreScrollingState: true)
    }

    override var bounds: CGRect {
        didSet {
            guard bounds.width != oldValue.width else { return }
            updatePageConstraints(ignoreScrollingState: true)
        }
    }

    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        updateStickerPageContentInset()
    }

    var stickerPageContentInset: UIEdgeInsets = .zero {
        didSet {
            updateStickerPageContentInset()
        }
    }

    @available(iOS 26, *)
    var scrollViewForScrollEdgeElementContainerInteraction: UIScrollView {
        stickerPagingScrollView
    }

    private func updateStickerPageContentInset() {
        var contentInset = stickerPageContentInset
        // Paging scroll view uses whole screen width - otherwise paging would look broken.
        // But each page must respect left and right safe areas when displaying content.
        contentInset.leading += safeAreaInsets.leading
        contentInset.trailing += safeAreaInsets.trailing
        // On the bottom there's usually a sticker pack toolbar which defines the bottom inset.
        // To make sure content doesn't go too close to the toolbar we increase the bottom margin.
        // However, scroll indicator should go all the way down.
        contentInset.bottom += 8
        for stickerPackCollectionView in stickerPackCollectionViews {
            stickerPackCollectionView.contentInset = contentInset
            stickerPackCollectionView.verticalScrollIndicatorInsets.bottom = stickerPageContentInset.bottom
        }
    }

    private let reusableStickerViewCache = StickerViewCache(maxSize: 32)

    private func reloadStickers() {
        let oldStickerPacks = stickerPacks

        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            self.stickerPacks = StickerManager.installedStickerPacks(transaction: transaction).sorted {
                $0.dateCreated > $1.dateCreated
            }
        }

        // These go (via delegate) as source data to the toolbar.
        // No need to reverse order because toolbar supports mirrored layout for RTL languages.
        var items = [StickerHorizontalListViewItem]()
        items.append(StickerHorizontalListViewItemRecents(
            didSelectBlock: { [weak self] in
                self?.recentsButtonWasTapped()
            },
            isSelectedBlock: { [weak self] in
                self?.selectedStickerPack == nil
            },
        ))
        items += stickerPacks.map { stickerPack in
            StickerHorizontalListViewItemSticker(
                stickerInfo: stickerPack.coverInfo,
                didSelectBlock: { [weak self] in
                    self?.updateSelectedStickerPack(stickerPack)
                },
                isSelectedBlock: { [weak self] in
                    self?.selectedStickerPack?.info == stickerPack.info
                },
                cache: reusableStickerViewCache,
            )
        }
        delegate?.setItems(items)

        guard stickerPacks.count > 0 else {
            _ = resignFirstResponder()
            return
        }

        // Simply reverse sticker packs for RTL languages.
        if traitCollection.layoutDirection == .rightToLeft {
            stickerPacks = stickerPacks.reversed()
        }

        guard oldStickerPacks != stickerPacks else { return }

        // If the selected pack was uninstalled, select the first pack.
        if let selectedStickerPack, !stickerPacks.contains(selectedStickerPack) {
            updateSelectedStickerPack(stickerPacks.first)
        }

        resetStickerPages()
    }

    // MARK: Events

    @objc
    private func stickersOrPacksDidChange() {
        AssertIsOnMainThread()

        reloadStickers()
    }

    private func recentsButtonWasTapped() {
        AssertIsOnMainThread()

        // nil is used for the recents special-case.
        updateSelectedStickerPack(nil)
    }

    private func updateSelectedStickerPack(_ stickerPack: StickerPackRecord?, scrollToSelected: Bool = false) {
        selectedStickerPack = stickerPack
        delegate?.updateSelections(scrollToSelectedItem: scrollToSelected)
    }

    // MARK: Paging

    /// This array always includes three collection views, where the indices represent:
    /// 0 - Previous Page
    /// 1 - Current Page
    /// 2 - Next Page
    lazy var stickerPackCollectionViews: [StickerPackCollectionView] = [
        StickerPackCollectionView(storyStickerConfiguration: storyStickerConfiguration),
        StickerPackCollectionView(storyStickerConfiguration: storyStickerConfiguration),
        StickerPackCollectionView(storyStickerConfiguration: storyStickerConfiguration),
    ]
    private var stickerPackCollectionViewConstraints = [NSLayoutConstraint]()

    private var currentPageCollectionView: StickerPackCollectionView {
        return stickerPackCollectionViews[1]
    }

    private var nextPageCollectionView: StickerPackCollectionView {
        return stickerPackCollectionViews[2]
    }

    private var previousPageCollectionView: StickerPackCollectionView {
        return stickerPackCollectionViews[0]
    }

    private lazy var stickerPagingScrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.isPagingEnabled = true
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.isDirectionalLockEnabled = true
        scrollView.delegate = self
        scrollView.clipsToBounds = false
        scrollView.contentInsetAdjustmentBehavior = .never
        return scrollView
    }()

    private var nextPageStickerPack: StickerPackRecord? {
        // If we don't have a pack defined, the first pack is always up next
        guard let stickerPack = selectedStickerPack else { return stickerPacks.first }

        // If we don't have an index, or we're at the end of the array, recents is up next
        guard let index = stickerPacks.firstIndex(of: stickerPack), index < (stickerPacks.count - 1) else { return nil }

        // Otherwise, use the next pack in the array
        return stickerPacks[index + 1]
    }

    private var previousPageStickerPack: StickerPackRecord? {
        // If we don't have a pack defined, the last pack is always previous
        guard let stickerPack = selectedStickerPack else { return stickerPacks.last }

        // If we don't have an index, or we're at the start of the array, recents is previous
        guard let index = stickerPacks.firstIndex(of: stickerPack), index > 0 else { return nil }

        // Otherwise, use the previous pack in the array
        return stickerPacks[index - 1]
    }

    private var pageWidth: CGFloat { return stickerPagingScrollView.frame.width }

    private var numberOfPages: CGFloat { return CGFloat(stickerPackCollectionViews.count) }

    // These thresholds indicate the offset at which we update the next / previous page.
    // They're not exactly half way through the transition, to avoid us continuously
    // bouncing back and forth between pages.
    private var previousPageThreshold: CGFloat { return pageWidth * 0.45 }

    private var nextPageThreshold: CGFloat { return pageWidth + previousPageThreshold }

    private func setupPaging() {
        // Horizontally scrolling paging scroll view is stretched to view's bounds.
        stickerPagingScrollView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(stickerPagingScrollView)
        NSLayoutConstraint.activate([
            stickerPagingScrollView.topAnchor.constraint(equalTo: topAnchor),
            stickerPagingScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
            stickerPagingScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
            stickerPagingScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
        ])

        // Wide container that has several pages next to each other and is inside of the paging scroll view.
        let stickerPagesContainer = UIView()
        stickerPagesContainer.translatesAutoresizingMaskIntoConstraints = false
        stickerPagingScrollView.addSubview(stickerPagesContainer)
        NSLayoutConstraint.activate([
            // Pin all edges to scroll view's content layout guide.
            stickerPagesContainer.topAnchor.constraint(
                equalTo: stickerPagingScrollView.contentLayoutGuide.topAnchor,
            ),
            stickerPagesContainer.leadingAnchor.constraint(
                equalTo: stickerPagingScrollView.contentLayoutGuide.leadingAnchor,
            ),
            stickerPagesContainer.trailingAnchor.constraint(
                equalTo: stickerPagingScrollView.contentLayoutGuide.trailingAnchor,
            ),
            stickerPagesContainer.bottomAnchor.constraint(
                equalTo: stickerPagingScrollView.contentLayoutGuide.bottomAnchor,
            ),

            // Height must be equal to height of `stickerPagingScrollView`.
            stickerPagesContainer.heightAnchor.constraint(
                equalTo: stickerPagingScrollView.frameLayoutGuide.heightAnchor,
            ),

            // Width is width of `stickerPagingScrollView` * number of pages.
            stickerPagesContainer.widthAnchor.constraint(
                equalTo: stickerPagingScrollView.frameLayoutGuide.widthAnchor,
                multiplier: numberOfPages,
            ),
        ])

        // Place and set up constraints for sticker pages.
        for (index, collectionView) in stickerPackCollectionViews.enumerated() {
            collectionView.isDirectionalLockEnabled = true
            collectionView.stickerDelegate = self

            // We want the current page on top, to prevent weird
            // animations when we initially calculate our frame.
            if collectionView == currentPageCollectionView {
                stickerPagesContainer.addSubview(collectionView)
            } else {
                stickerPagesContainer.insertSubview(collectionView, at: 0)
            }

            collectionView.translatesAutoresizingMaskIntoConstraints = false
            // Calculate X-position for each page. Make sure to use `left` instead of `leading`.
            let xPositionConstraint = collectionView.leftAnchor.constraint(
                equalTo: stickerPagesContainer.leftAnchor,
                constant: CGFloat(index) * pageWidth,
            )
            NSLayoutConstraint.activate([
                // Each page is as wide as the view or `stickerPagingScrollView` is.
                collectionView.widthAnchor.constraint(equalTo: stickerPagingScrollView.frameLayoutGuide.widthAnchor),

                xPositionConstraint,

                // Top and bottom are pinned to `stickerPagesContainer` which has its height
                // fixed to height of `stickerPagingScrollView`.
                collectionView.topAnchor.constraint(equalTo: stickerPagesContainer.topAnchor),
                collectionView.bottomAnchor.constraint(equalTo: stickerPagesContainer.bottomAnchor),
            ])

            stickerPackCollectionViewConstraints.append(xPositionConstraint)
        }
    }

    private var pendingPageChangeUpdates: (() -> Void)?

    private func applyPendingPageChangeUpdates() {
        pendingPageChangeUpdates?()
        pendingPageChangeUpdates = nil
    }

    private func selectedPackChanged(oldSelectedPack: StickerPackRecord?) {
        AssertIsOnMainThread()

        // We're paging backwards!
        if oldSelectedPack == nextPageStickerPack {
            // The previous page becomes the current page and the current page becomes
            // the next page. We have to load the new previous.

            stickerPackCollectionViews.insert(stickerPackCollectionViews.removeLast(), at: 0)
            stickerPackCollectionViewConstraints.insert(stickerPackCollectionViewConstraints.removeLast(), at: 0)

            pendingPageChangeUpdates = {
                self.previousPageCollectionView.showInstalledPackOrRecents(stickerPack: self.previousPageStickerPack)
            }

            // We're paging forwards!
        } else if oldSelectedPack == previousPageStickerPack {
            // The next page becomes the current page and the current page becomes
            // the previous page. We have to load the new next.

            stickerPackCollectionViews.append(stickerPackCollectionViews.removeFirst())
            stickerPackCollectionViewConstraints.append(stickerPackCollectionViewConstraints.removeFirst())

            pendingPageChangeUpdates = {
                self.nextPageCollectionView.showInstalledPackOrRecents(stickerPack: self.nextPageStickerPack)
            }

            // We didn't get here through paging, stuff probably changed. Reload all the things.
        } else {
            currentPageCollectionView.showInstalledPackOrRecents(stickerPack: selectedStickerPack)
            previousPageCollectionView.showInstalledPackOrRecents(stickerPack: previousPageStickerPack)
            nextPageCollectionView.showInstalledPackOrRecents(stickerPack: nextPageStickerPack)

            pendingPageChangeUpdates = nil
        }

        // If we're not currently scrolling, apply the page change updates immediately.
        if !isScrollingChange { applyPendingPageChangeUpdates() }

        updatePageConstraints()
    }

    private func resetStickerPages() {
        currentPageCollectionView.showInstalledPackOrRecents(stickerPack: selectedStickerPack)
        previousPageCollectionView.showInstalledPackOrRecents(stickerPack: previousPageStickerPack)
        nextPageCollectionView.showInstalledPackOrRecents(stickerPack: nextPageStickerPack)

        pendingPageChangeUpdates = nil

        updatePageConstraints()

        delegate?.updateSelections(scrollToSelectedItem: false)
    }

    private func updatePageConstraints(ignoreScrollingState: Bool = false) {
        let pageWidth = pageWidth

        // Do nothing if views have not been laid out yet.
        guard pageWidth > 0 else { return }

        // Setup the collection views in their page positions
        for (index, constraint) in stickerPackCollectionViewConstraints.enumerated() {
            constraint.constant = CGFloat(index) * pageWidth
        }

        // Scrolling backwards
        if !ignoreScrollingState, stickerPagingScrollView.contentOffset.x <= previousPageThreshold {
            stickerPagingScrollView.contentOffset.x += pageWidth

            // Scrolling forward
        } else if !ignoreScrollingState, stickerPagingScrollView.contentOffset.x >= nextPageThreshold {
            stickerPagingScrollView.contentOffset.x -= pageWidth

            // Not moving forward or back, just scroll back to center so we can go forward and back again
        } else {
            stickerPagingScrollView.contentOffset.x = pageWidth
        }
    }

    // MARK: - Scroll state management

    /// Indicates that the user stopped actively scrolling, but
    /// we still haven't reached their final destination.
    private var isWaitingForDeceleration = false

    /// Indicates that the user started scrolling and we've yet
    /// to reach their final destination.
    private var isUserScrolling = false

    /// Indicates that we're currently changing pages due to a
    /// user initiated scroll action.
    private var isScrollingChange = false

    private func userStartedScrolling() {
        isWaitingForDeceleration = false
        isUserScrolling = true
    }

    private func userStoppedScrolling(waitingForDeceleration: Bool = false) {
        guard isUserScrolling else { return }

        if waitingForDeceleration {
            isWaitingForDeceleration = true
        } else {
            isWaitingForDeceleration = false
            isUserScrolling = false
        }
    }

    private func checkForPageChange() {
        // Ignore any page changes unless the user is triggering them.
        guard isUserScrolling else { return }

        isScrollingChange = true

        let offsetX = stickerPagingScrollView.contentOffset.x

        // Scrolled left a page
        if offsetX <= previousPageThreshold {
            updateSelectedStickerPack(previousPageStickerPack, scrollToSelected: true)

            // Scrolled right a page
        } else if offsetX >= nextPageThreshold {
            updateSelectedStickerPack(nextPageStickerPack, scrollToSelected: true)

            // We're about to cross the threshold into a new page, execute any pending updates.
            // We wait to execute these until we're sure we're going to cross over as it
            // can cause some UI jitter that interrupts scrolling.
        } else if offsetX >= pageWidth * 0.95, offsetX <= pageWidth * 1.05 {
            applyPendingPageChangeUpdates()
        }

        isScrollingChange = false
    }
}

// MARK: UIScrollViewDelegate

extension StickerPickerPageView: UIScrollViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        checkForPageChange()
    }

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        userStartedScrolling()
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        userStoppedScrolling(waitingForDeceleration: decelerate)
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        userStoppedScrolling()
    }
}

// MARK: StickerPackCollectionViewDelegate

extension StickerPickerPageView: StickerPackCollectionViewDelegate {

    func didSelectSticker(_ stickerInfo: StickerInfo) {
        delegate?.didSelectSticker(stickerInfo)
    }

    func stickerPreviewHostView() -> UIView? {
        return window
    }

    func stickerPreviewHasOverlay() -> Bool {
        return true
    }
}