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
}
}