549 lines
19 KiB
Swift
549 lines
19 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import SignalServiceKit
|
|
|
|
// MARK: - Delegate Protocols
|
|
|
|
public protocol StickerPacksToolbarDelegate: AnyObject {
|
|
func presentManageStickersView()
|
|
}
|
|
|
|
public protocol StickerPickerPageViewDelegate: StickerPickerDelegate {
|
|
func setItems(_ items: [StickerHorizontalListViewItem])
|
|
func updateSelections(scrollToSelectedItem: Bool)
|
|
}
|
|
|
|
// MARK: - StickerPacksToolbar
|
|
|
|
class StickerPacksToolbar: UIStackView {
|
|
private static let packCoverSize: CGFloat = 32
|
|
private static let packCoverInset: CGFloat = 4
|
|
private static let packCoverSpacing: CGFloat = 4
|
|
private let forceDarkTheme: Bool
|
|
lazy var packsCollectionView: StickerHorizontalListView = {
|
|
let view = StickerHorizontalListView(
|
|
cellSize: StickerPacksToolbar.packCoverSize,
|
|
cellInset: StickerPacksToolbar.packCoverInset,
|
|
spacing: StickerPacksToolbar.packCoverSpacing,
|
|
forceDarkTheme: forceDarkTheme
|
|
)
|
|
|
|
view.contentInset = .zero
|
|
view.autoSetDimension(.height, toSize: StickerPacksToolbar.packCoverSize + view.contentInset.top + view.contentInset.bottom)
|
|
|
|
return view
|
|
}()
|
|
|
|
private lazy var manageButton: OWSButton = {
|
|
let tintColor = forceDarkTheme ? Theme.darkThemeSecondaryTextAndIconColor : Theme.secondaryTextAndIconColor
|
|
let button = OWSButton(imageName: "plus", tintColor: tintColor) { [weak self] in
|
|
self?.delegate?.presentManageStickersView()
|
|
}
|
|
button.setContentHuggingHigh()
|
|
button.setCompressionResistanceHigh()
|
|
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "manageButton")
|
|
return button
|
|
}()
|
|
|
|
public weak var delegate: StickerPacksToolbarDelegate? {
|
|
didSet {
|
|
configureManageButton()
|
|
}
|
|
}
|
|
|
|
init(forceDarkTheme: Bool = false) {
|
|
self.forceDarkTheme = forceDarkTheme
|
|
super.init(frame: .zero)
|
|
populate()
|
|
}
|
|
|
|
required init(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func populate() {
|
|
spacing = Self.packCoverSpacing
|
|
axis = .horizontal
|
|
alignment = .center
|
|
layoutMargins = UIEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)
|
|
isLayoutMarginsRelativeArrangement = true
|
|
|
|
packsCollectionView.backgroundColor = .clear
|
|
addArrangedSubview(packsCollectionView)
|
|
}
|
|
|
|
private func configureManageButton() {
|
|
if delegate != nil {
|
|
// Show manage button
|
|
addArrangedSubview(manageButton)
|
|
} else {
|
|
// Hide manage button
|
|
removeArrangedSubview(manageButton)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - StickerPickerPageView
|
|
|
|
public class StickerPickerPageView: UIView {
|
|
|
|
public private(set) weak var delegate: StickerPickerPageViewDelegate?
|
|
|
|
private let forceDarkTheme: Bool
|
|
|
|
private var stickerPacks = [StickerPack]()
|
|
|
|
private var selectedStickerPack: StickerPack? {
|
|
didSet {
|
|
selectedPackChanged(oldSelectedPack: oldValue)
|
|
}
|
|
}
|
|
|
|
public init(delegate: StickerPickerPageViewDelegate, forceDarkTheme: Bool = false) {
|
|
self.delegate = delegate
|
|
self.forceDarkTheme = forceDarkTheme
|
|
|
|
super.init(frame: .zero)
|
|
|
|
layoutMargins = .zero
|
|
|
|
setupPaging()
|
|
|
|
reloadStickers()
|
|
|
|
// By default, show the "recent" stickers.
|
|
assert(nil == selectedStickerPack)
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(stickersOrPacksDidChange),
|
|
name: StickerManager.stickersOrPacksDidChange,
|
|
object: nil)
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(keyboardFrameDidChange),
|
|
name: UIResponder.keyboardDidChangeFrameNotification,
|
|
object: nil)
|
|
}
|
|
|
|
required public init(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public func wasPresented() {
|
|
// If there are no recents, default to showing the first sticker pack.
|
|
if currentPageCollectionView.stickerCount < 1 {
|
|
updateSelectedStickerPack(stickerPacks.first)
|
|
}
|
|
|
|
updatePageConstraints()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
var items = [StickerHorizontalListViewItem]()
|
|
items.append(StickerHorizontalListViewItemRecents(
|
|
didSelectBlock: { [weak self] in
|
|
self?.recentsButtonWasTapped()
|
|
},
|
|
isSelectedBlock: { [weak self] in
|
|
self?.selectedStickerPack == nil
|
|
},
|
|
forceDarkTheme: forceDarkTheme
|
|
))
|
|
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
|
|
}
|
|
|
|
guard oldStickerPacks != stickerPacks else { return }
|
|
|
|
// If the selected pack was uninstalled, select the first pack.
|
|
if let selectedStickerPack = selectedStickerPack, !stickerPacks.contains(selectedStickerPack) {
|
|
updateSelectedStickerPack(stickerPacks.first)
|
|
}
|
|
|
|
resetStickerPages()
|
|
}
|
|
|
|
// MARK: Events
|
|
|
|
@objc
|
|
func stickersOrPacksDidChange() {
|
|
AssertIsOnMainThread()
|
|
|
|
reloadStickers()
|
|
}
|
|
|
|
@objc
|
|
func keyboardFrameDidChange() {
|
|
updatePageConstraints(ignoreScrollingState: true)
|
|
}
|
|
|
|
private func recentsButtonWasTapped() {
|
|
AssertIsOnMainThread()
|
|
|
|
// nil is used for the recents special-case.
|
|
updateSelectedStickerPack(nil)
|
|
}
|
|
|
|
private func updateSelectedStickerPack(_ stickerPack: StickerPack?, 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
|
|
var stickerPackCollectionViews = [
|
|
StickerPackCollectionView(),
|
|
StickerPackCollectionView(),
|
|
StickerPackCollectionView()
|
|
]
|
|
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
|
|
return scrollView
|
|
}()
|
|
|
|
private var nextPageStickerPack: StickerPack? {
|
|
// 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: StickerPack? {
|
|
// 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) }
|
|
private let pageSpacing: CGFloat = 40
|
|
|
|
// 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() {
|
|
addSubview(stickerPagingScrollView)
|
|
stickerPagingScrollView.autoPinHeightToSuperview()
|
|
stickerPagingScrollView.autoPinEdge(toSuperviewMargin: .leading, withInset: -0.5 * pageSpacing)
|
|
stickerPagingScrollView.autoPinEdge(toSuperviewMargin: .trailing, withInset: -0.5 * pageSpacing)
|
|
|
|
let stickerPagesContainer = UIView()
|
|
stickerPagingScrollView.addSubview(stickerPagesContainer)
|
|
stickerPagesContainer.autoPinEdgesToSuperviewEdges()
|
|
stickerPagesContainer.autoMatch(.height, to: .height, of: stickerPagingScrollView)
|
|
stickerPagesContainer.autoMatch(.width, to: .width, of: stickerPagingScrollView, withMultiplier: numberOfPages)
|
|
|
|
for (index, collectionView) in stickerPackCollectionViews.enumerated() {
|
|
collectionView.backgroundColor = .clear
|
|
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.autoMatch(.width, to: .width, of: stickerPagingScrollView, withOffset: -pageSpacing)
|
|
collectionView.autoMatch(.height, to: .height, of: stickerPagingScrollView)
|
|
|
|
collectionView.autoPinEdge(toSuperviewEdge: .top)
|
|
collectionView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
|
|
stickerPackCollectionViewConstraints.append(
|
|
collectionView.autoPinEdge(toSuperviewEdge: .left, withInset: CGFloat(index) * pageWidth + 0.5 * pageSpacing)
|
|
)
|
|
}
|
|
}
|
|
|
|
private var pendingPageChangeUpdates: (() -> Void)?
|
|
private func applyPendingPageChangeUpdates() {
|
|
pendingPageChangeUpdates?()
|
|
pendingPageChangeUpdates = nil
|
|
}
|
|
|
|
private func selectedPackChanged(oldSelectedPack: StickerPack?) {
|
|
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) {
|
|
// Setup the collection views in their page positions
|
|
for (index, constraint) in stickerPackCollectionViewConstraints.enumerated() {
|
|
constraint.constant = CGFloat(index) * pageWidth + 0.5 * pageSpacing
|
|
}
|
|
|
|
// 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 {
|
|
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
checkForPageChange()
|
|
}
|
|
|
|
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
userStartedScrolling()
|
|
}
|
|
|
|
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
userStoppedScrolling(waitingForDeceleration: decelerate)
|
|
}
|
|
|
|
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
userStoppedScrolling()
|
|
}
|
|
}
|
|
|
|
extension StickerPickerPageView: StickerPackCollectionViewDelegate {
|
|
public func didSelectSticker(stickerInfo: StickerInfo) {
|
|
AssertIsOnMainThread()
|
|
|
|
delegate?.didSelectSticker(stickerInfo: stickerInfo)
|
|
}
|
|
|
|
public var storyStickerConfiguration: StoryStickerConfiguration {
|
|
delegate?.storyStickerConfiguration ?? .hide
|
|
}
|
|
|
|
public func stickerPreviewHostView() -> UIView? {
|
|
AssertIsOnMainThread()
|
|
|
|
return window
|
|
}
|
|
|
|
public func stickerPreviewHasOverlay() -> Bool {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - StickerViewCache
|
|
|
|
public class StickerViewCache {
|
|
|
|
private typealias CacheType = LRUCache<StickerInfo, ThreadSafeCacheHandle<StickerReusableView>>
|
|
private let backingCache: CacheType
|
|
|
|
public init(maxSize: Int) {
|
|
// Always use a nseMaxSize of zero.
|
|
backingCache = LRUCache(maxSize: maxSize,
|
|
nseMaxSize: 0,
|
|
shouldEvacuateInBackground: true)
|
|
}
|
|
|
|
public func get(key: StickerInfo) -> StickerReusableView? {
|
|
self.backingCache.get(key: key)?.value
|
|
}
|
|
|
|
public func set(key: StickerInfo, value: StickerReusableView) {
|
|
self.backingCache.set(key: key, value: ThreadSafeCacheHandle(value))
|
|
}
|
|
|
|
public func remove(key: StickerInfo) {
|
|
self.backingCache.remove(key: key)
|
|
}
|
|
|
|
public func clear() {
|
|
self.backingCache.clear()
|
|
}
|
|
|
|
// MARK: NSCache Compatibility
|
|
|
|
public func setObject(_ value: StickerReusableView, forKey key: StickerInfo) {
|
|
set(key: key, value: value)
|
|
}
|
|
|
|
public func object(forKey key: StickerInfo) -> StickerReusableView? {
|
|
self.get(key: key)
|
|
}
|
|
|
|
public func removeObject(forKey key: StickerInfo) {
|
|
remove(key: key)
|
|
}
|
|
|
|
public func removeAllObjects() {
|
|
clear()
|
|
}
|
|
}
|