Sticker picker redesign.

1. Sticker pack toolbar is now at the bottom of the "sticker keyboard" in chat.
2. Sticker pack toolbar is now "floating" in a a glass panel on iOS 26, both in sticker keyboard in chat and in full-screen sticker picker in media editor.
3. There's some helper text when "recent stickers" panel is empty.
This commit is contained in:
Igor Solomennikov
2025-11-25 13:12:10 -08:00
committed by GitHub
parent cff419bf4c
commit effab076b4
15 changed files with 1415 additions and 938 deletions

View File

@@ -118,14 +118,13 @@
3402AA42271D9DCD0084CBAE /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9559E271B510500B05242 /* AttachmentTextToolbar.swift */; };
3402AA44271D9DCD0084CBAE /* ModalActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95591271B510500B05242 /* ModalActivityIndicatorViewController.swift */; };
3402AA47271D9DCD0084CBAE /* StickerHorizontalListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95572271B510500B05242 /* StickerHorizontalListView.swift */; };
3402AA48271D9DCD0084CBAE /* StickerPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95573271B510500B05242 /* StickerPicker.swift */; };
3402AA48271D9DCD0084CBAE /* StickerPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95573271B510500B05242 /* StickerPickerView.swift */; };
3402AA49271D9DCD0084CBAE /* EditContactShareNameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9558D271B510500B05242 /* EditContactShareNameViewController.swift */; };
3402AA4A271D9DCD0084CBAE /* AttachmentApprovalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9559C271B510500B05242 /* AttachmentApprovalViewController.swift */; };
3402AA4B271D9DCD0084CBAE /* AttachmentApprovalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9559D271B510500B05242 /* AttachmentApprovalToolbar.swift */; };
3402AA4D271D9DCD0084CBAE /* StickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9556F271B510500B05242 /* StickerView.swift */; };
3402AA4E271D9DCD0084CBAE /* ApprovalFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9558E271B510500B05242 /* ApprovalFooterView.swift */; };
3402AA4F271D9DCD0084CBAE /* SpamCaptchaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9557E271B510500B05242 /* SpamCaptchaViewController.swift */; };
3402AA54271D9DCD0084CBAE /* LinearHorizontalLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9556E271B510500B05242 /* LinearHorizontalLayout.swift */; };
3402AA55271D9DCD0084CBAE /* AttachmentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95597271B510500B05242 /* AttachmentTextView.swift */; };
3402AA56271D9DCD0084CBAE /* FindByPhoneNumberViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9557D271B510500B05242 /* FindByPhoneNumberViewController.swift */; };
3402AA57271D9DCD0084CBAE /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95578271B510500B05242 /* MediaMessageView.swift */; };
@@ -1422,6 +1421,8 @@
762A416B2A38397500057955 /* UIKit+Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762A416A2A38397500057955 /* UIKit+Text.swift */; };
762A416D2A383ABF00057955 /* UIKit+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762A416C2A383ABF00057955 /* UIKit+Image.swift */; };
762EBBD22A2FE370002FD28F /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762EBBD12A2FE370002FD28F /* BlockListUIUtils.swift */; };
7638499E2ED258CF00D0E43E /* StickerPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7638499D2ED258CA00D0E43E /* StickerPicker.swift */; };
763849A02ED2595200D0E43E /* StickerViewCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7638499F2ED2594D00D0E43E /* StickerViewCache.swift */; };
76387BF028F4ED73002C7BA5 /* CaseIterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76387BEF28F4ED73002C7BA5 /* CaseIterable.swift */; };
763A16002AEC3A490081D7E5 /* OWSContact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763A15FF2AEC3A490081D7E5 /* OWSContact.swift */; };
763D7DDB27E155ED002EA7E6 /* RoundMediaButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763D7DDA27E155ED002EA7E6 /* RoundMediaButton.swift */; };
@@ -4273,12 +4274,11 @@
34A95564271B510400B05242 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = "<group>"; };
34A95567271B510500B05242 /* OWSWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSWindow.swift; sourceTree = "<group>"; };
34A95569271B510500B05242 /* ActionSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionSheetController.swift; sourceTree = "<group>"; };
34A9556E271B510500B05242 /* LinearHorizontalLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinearHorizontalLayout.swift; sourceTree = "<group>"; };
34A9556F271B510500B05242 /* StickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerView.swift; sourceTree = "<group>"; };
34A95570271B510500B05242 /* StickerPackCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerPackCollectionView.swift; sourceTree = "<group>"; };
34A95571271B510500B05242 /* StickerPackDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerPackDataSource.swift; sourceTree = "<group>"; };
34A95572271B510500B05242 /* StickerHorizontalListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerHorizontalListView.swift; sourceTree = "<group>"; };
34A95573271B510500B05242 /* StickerPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerPicker.swift; sourceTree = "<group>"; };
34A95573271B510500B05242 /* StickerPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerPickerView.swift; sourceTree = "<group>"; };
34A95578271B510500B05242 /* MediaMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMessageView.swift; sourceTree = "<group>"; };
34A9557B271B510500B05242 /* ConversationPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationPicker.swift; sourceTree = "<group>"; };
34A9557C271B510500B05242 /* ConversationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationItem.swift; sourceTree = "<group>"; };
@@ -5329,6 +5329,8 @@
762EBBCF2A2FB759002FD28F /* AttachmentSharing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentSharing.swift; sourceTree = "<group>"; };
762EBBD12A2FE370002FD28F /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = "<group>"; };
7634F08C2A21963600BB93D5 /* Sounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sounds.swift; sourceTree = "<group>"; };
7638499D2ED258CA00D0E43E /* StickerPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerPicker.swift; sourceTree = "<group>"; };
7638499F2ED2594D00D0E43E /* StickerViewCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerViewCache.swift; sourceTree = "<group>"; };
76387BEF28F4ED73002C7BA5 /* CaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseIterable.swift; sourceTree = "<group>"; };
763A15FF2AEC3A490081D7E5 /* OWSContact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSContact.swift; sourceTree = "<group>"; };
763D7DDA27E155ED002EA7E6 /* RoundMediaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundMediaButton.swift; sourceTree = "<group>"; };
@@ -8673,14 +8675,15 @@
isa = PBXGroup;
children = (
B9A87A352A9D1D25009FCA13 /* EditorSticker.swift */,
34A9556E271B510500B05242 /* LinearHorizontalLayout.swift */,
34A95572271B510500B05242 /* StickerHorizontalListView.swift */,
34A95570271B510500B05242 /* StickerPackCollectionView.swift */,
34A95571271B510500B05242 /* StickerPackDataSource.swift */,
34A95573271B510500B05242 /* StickerPicker.swift */,
7638499D2ED258CA00D0E43E /* StickerPicker.swift */,
B91ACD9D2A797698000CFBC7 /* StickerPickerKeyboard.swift */,
B9F2155C2A93C9E8002DCAE0 /* StickerPickerSheet.swift */,
34A95573271B510500B05242 /* StickerPickerView.swift */,
34A9556F271B510500B05242 /* StickerView.swift */,
7638499F2ED2594D00D0E43E /* StickerViewCache.swift */,
);
path = Stickers;
sourceTree = "<group>";
@@ -16651,7 +16654,6 @@
7628DDC5280A01B8009AA53D /* ImageEditorViewController.swift in Sources */,
8868A08A287F4551000E74A5 /* InteractiveSheetViewController.swift in Sources */,
88B986FB28807F1D00F8C74D /* InviteFlow.swift in Sources */,
3402AA54271D9DCD0084CBAE /* LinearHorizontalLayout.swift in Sources */,
66F98DE62DBBED6C009F1A86 /* LineWrappingStackView.swift in Sources */,
3402AA7F271D9E180084CBAE /* LinkingTextView.swift in Sources */,
E1B32F842CA6162A002141F4 /* LinkPreviewCallLink.swift in Sources */,
@@ -16748,10 +16750,12 @@
3402AA47271D9DCD0084CBAE /* StickerHorizontalListView.swift in Sources */,
3402AA5B271D9DCD0084CBAE /* StickerPackCollectionView.swift in Sources */,
3402AA3E271D9DCD0084CBAE /* StickerPackDataSource.swift in Sources */,
3402AA48271D9DCD0084CBAE /* StickerPicker.swift in Sources */,
7638499E2ED258CF00D0E43E /* StickerPicker.swift in Sources */,
B91ACD9E2A797698000CFBC7 /* StickerPickerKeyboard.swift in Sources */,
B9F2155D2A93C9E8002DCAE0 /* StickerPickerSheet.swift in Sources */,
3402AA48271D9DCD0084CBAE /* StickerPickerView.swift in Sources */,
3402AA4D271D9DCD0084CBAE /* StickerView.swift in Sources */,
763849A02ED2595200D0E43E /* StickerViewCache.swift in Sources */,
B99B155D2A71BA5200E26DAC /* StoryContextViewState.swift in Sources */,
88B6D674280770C4005D86EC /* StoryMessage+SignalUI.swift in Sources */,
88F5FA9428EBD4CF007AA1BF /* StorySharing.swift in Sources */,

View File

@@ -207,6 +207,8 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
public enum Style {
@available(iOS 26, *)
static var glassTintColor: UIColor {
// This set of colors is copied to elsewhere.
// Please update all places if you change color values.
UIColor { traitCollection in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(white: 0, alpha: 0.2)
@@ -2158,7 +2160,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
private lazy var stickerListViewWrapper: UIVisualEffectView = {
let view = UIVisualEffectView()
#if compiler(>=6.2)
if #available(iOS 26.0, *) {
view.clipsToBounds = true
view.cornerConfiguration = .uniformCorners(radius: .fixed(StickerLayout.backgroundCornerRadius))
@@ -2168,7 +2169,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
let minRadius = StickerLayout.backgroundCornerRadius - max(StickerLayout.backgroundMargins.leading, StickerLayout.backgroundMargins.top)
stickersListView.cornerConfiguration = .uniformCorners(radius: .containerConcentric(minimum: minRadius))
}
#endif
// List view.
view.directionalLayoutMargins = StickerLayout.backgroundMargins
@@ -2191,7 +2191,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
private lazy var stickersListView: StickerHorizontalListView = {
let view = StickerHorizontalListView(
cellSize: StickerLayout.listItemSize,
cellInset: 0,
cellContentInset: 0,
spacing: StickerLayout.listItemSpacing
)
view.backgroundColor = .clear
@@ -3159,7 +3159,7 @@ extension ConversationInputToolbar {
hasInstalledStickerPacks = !StickerManager.installedStickerPacks(transaction: transaction).isEmpty
}
guard hasInstalledStickerPacks else {
presentManageStickersView()
inputToolbarDelegate?.presentManageStickersView()
return
}
toggleKeyboardType(.sticker, animated: true)
@@ -3237,19 +3237,14 @@ extension ConversationInputToolbar: ConversationTextViewToolbarDelegate {
func textViewDidChangeSelection(_ textView: UITextView) { }
}
extension ConversationInputToolbar: StickerPickerDelegate {
public func didSelectSticker(stickerInfo: StickerInfo) {
extension ConversationInputToolbar: StickerKeyboardDelegate {
public func stickerKeyboard(_: StickerKeyboard, didSelect stickerInfo: StickerInfo) {
AssertIsOnMainThread()
inputToolbarDelegate?.sendSticker(stickerInfo)
}
public var storyStickerConfiguration: SignalUI.StoryStickerConfiguration {
.hide
}
}
extension ConversationInputToolbar: StickerPacksToolbarDelegate {
public func presentManageStickersView() {
public func stickerKeyboardDidRequestPresentManageStickersView(_ stickerKeyboard: StickerKeyboard) {
AssertIsOnMainThread()
inputToolbarDelegate?.presentManageStickersView()
}

View File

@@ -962,7 +962,7 @@ extension ConversationViewController: SendMediaNavDataSource {
// MARK: - StickerPickerSheetDelegate
extension ConversationViewController: StickerPickerSheetDelegate {
public func makeManageStickersViewController() -> UIViewController {
public func makeManageStickersViewController(for stickerPickerSheet: StickerPickerSheet) -> UIViewController {
let manageStickersView = ManageStickersViewController()
let navigationController = OWSNavigationController(rootViewController: manageStickersView)
return navigationController

View File

@@ -632,7 +632,7 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDataSou
}
extension SendMediaNavigationController: StickerPickerSheetDelegate {
func makeManageStickersViewController() -> UIViewController {
func makeManageStickersViewController(for stickerPickerSheet: StickerPickerSheet) -> UIViewController {
let manageStickersView = ManageStickersViewController()
let navigationController = OWSNavigationController(rootViewController: manageStickersView)
return navigationController

View File

@@ -181,15 +181,10 @@ class StickerPackViewController: OWSViewController {
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard #available(iOS 26, *) else { return }
let topInset = headerView.frame.maxY + 16
stickerCollectionView.contentInset.top = topInset
stickerCollectionView.verticalScrollIndicatorInsets.top = topInset
let bottomInset = bottomButtonContainer.frame.height + 16
stickerCollectionView.contentInset.bottom = bottomInset
stickerCollectionView.verticalScrollIndicatorInsets.bottom = bottomInset
// Necessary to set top and bottom content insets.
DispatchQueue.main.async {
self.updateCollectionViewContentInset()
}
}
override func viewLayoutMarginsDidChange() {
@@ -199,6 +194,8 @@ class StickerPackViewController: OWSViewController {
let leadingInset = view.layoutMargins.leading - view.safeAreaInsets.leading
headerViewTopEdgeConstraint.constant = leadingInset
}
updateCollectionViewContentInset()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
@@ -265,12 +262,27 @@ class StickerPackViewController: OWSViewController {
return imageView
}()
private let stickerCollectionView: StickerPackCollectionView = {
let collectionView = StickerPackCollectionView(placeholderColor: .ows_blackAlpha60)
collectionView.backgroundColor = .clear
collectionView.preservesSuperviewLayoutMargins = true
return collectionView
}()
private let stickerCollectionView = StickerPackCollectionView(placeholderColor: .ows_blackAlpha60)
private func updateCollectionViewContentInset() {
var contentInset = stickerCollectionView.contentInset
if #available(iOS 26, *) {
// On iOS 26 collection view extends underneath header and footer.
contentInset.top = headerView.frame.maxY + 16
contentInset.bottom = bottomButtonContainer.frame.height + 16
stickerCollectionView.verticalScrollIndicatorInsets.top = contentInset.top
stickerCollectionView.verticalScrollIndicatorInsets.bottom = contentInset.bottom
}
contentInset.leading = view.layoutMargins.leading - view.safeAreaInsets.leading
contentInset.trailing = view.layoutMargins.trailing - view.safeAreaInsets.trailing
guard contentInset != stickerCollectionView.contentInset else { return }
stickerCollectionView.contentInset = contentInset
stickerCollectionView.contentOffset.y = -contentInset.top
}
private lazy var installButton: UIButton = {
UIButton(
@@ -594,17 +606,11 @@ extension StickerPackViewController: StickerPackDataSourceDelegate {
extension StickerPackViewController: StickerPackCollectionViewDelegate {
func didSelectSticker(stickerInfo: StickerInfo) {
AssertIsOnMainThread()
}
var storyStickerConfiguration: StoryStickerConfiguration {
.hide
func didSelectSticker(_: StickerInfo) {
// This view controller does nothing.
}
func stickerPreviewHostView() -> UIView? {
AssertIsOnMainThread()
return view
}

View File

@@ -8743,6 +8743,12 @@
/* The name for the sticker category 'Featured' */
"STICKER_CATEGORY_FEATURED_NAME" = "Featured";
/* Subtitle of the helper text displayed when Recent stickers are empty. */
"STICKER_CATEGORY_RECENTS_EMPTY_SUBTITLE" = "Send a sticker and it will appear here";
/* Title of the helper text displayed when Recent stickers are empty. */
"STICKER_CATEGORY_RECENTS_EMPTY_TITLE" = "No Recent Stickers";
/* The name for the sticker category 'Recents' */
"STICKER_CATEGORY_RECENTS_NAME" = "Recents";

View File

@@ -514,16 +514,8 @@ extension ImageEditorViewController {
@objc
private func didTapAddSticker(sender: UIButton) {
let stickerPicker: StickerPickerSheet
if UIAccessibility.isReduceTransparencyEnabled {
stickerPicker = StickerPickerSheet(backgroundColor: Theme.darkThemeBackgroundColor)
} else {
stickerPicker = StickerPickerSheet(visualEffect: UIBlurEffect(style: .dark))
}
stickerPicker.pickerDelegate = self
let stickerPicker = StickerPickerSheet(pickerDelegate: self)
stickerPicker.sheetDelegate = stickerSheetDelegate
present(stickerPicker, animated: true)
}
@@ -646,11 +638,8 @@ extension ImageEditorViewController: ColorPickerBarViewDelegate {
// MARK: - StickerPickerDelegate
extension ImageEditorViewController: StickerPickerDelegate {
var storyStickerConfiguration: StoryStickerConfiguration {
.showWithDelegate(self)
}
func didSelectSticker(stickerInfo: StickerInfo) {
func didSelectSticker(_ stickerInfo: StickerInfo) {
let stickerItem = imageEditorView.createNewStickerItem(with: .regular(stickerInfo))
selectStickerItem(stickerItem)
dismiss(animated: true)
@@ -658,6 +647,7 @@ extension ImageEditorViewController: StickerPickerDelegate {
}
extension ImageEditorViewController: StoryStickerPickerDelegate {
func didSelect(storySticker: EditorSticker.StorySticker) {
let stickerItem = imageEditorView.createNewStickerItem(with: .story(storySticker))
selectStickerItem(stickerItem)

View File

@@ -1,99 +0,0 @@
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
// A trivial layout that places each item in a horizontal line.
// Each item has uniform size.
class LinearHorizontalLayout: UICollectionViewLayout {
private let itemSize: CGSize
private let spacing: CGFloat
private var itemAttributesMap = [UICollectionViewLayoutAttributes]()
private var contentSize = CGSize.zero
// MARK: Initializers and Factory Methods
@available(*, unavailable, message: "use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init(itemSize: CGSize, spacing: CGFloat = 0) {
self.itemSize = itemSize
self.spacing = spacing
super.init()
}
// MARK: Methods
override func invalidateLayout() {
super.invalidateLayout()
itemAttributesMap.removeAll()
}
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
super.invalidateLayout(with: context)
itemAttributesMap.removeAll()
}
override func prepare() {
super.prepare()
guard let collectionView = collectionView else {
return
}
guard collectionView.numberOfSections == 1 else {
owsFailDebug("This layout only support a single section.")
return
}
let itemCount = collectionView.numberOfItems(inSection: 0)
guard itemCount > 0 else {
contentSize = .zero
return
}
for row in 0..<itemCount {
// TODO: We should ultimately make this layout RTL.
let itemX: CGFloat = CGFloat(row) * (itemSize.width + spacing)
let itemFrame = CGRect(x: itemX, y: 0, width: itemSize.width, height: itemSize.height)
let indexPath = NSIndexPath(row: row, section: 0)
let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath as IndexPath)
itemAttributes.frame = itemFrame
itemAttributesMap.append(itemAttributes)
}
contentSize = CGSize(width: CGFloat(itemCount) * itemSize.width + CGFloat(itemCount - 1) * spacing,
height: itemSize.height)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return itemAttributesMap.filter { itemAttributes in
return itemAttributes.frame.intersects(rect)
}
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return itemAttributesMap[safe: indexPath.row]
}
override var collectionViewContentSize: CGSize {
return contentSize
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let collectionView = collectionView else {
return false
}
return collectionView.width != newBounds.size.width
}
}

View File

@@ -21,16 +21,20 @@ public class StickerHorizontalListViewItemSticker: StickerHorizontalListViewItem
private weak var cache: StickerViewCache?
// This initializer can be used for cells which are never selected.
public convenience init(stickerInfo: StickerInfo,
didSelectBlock: @escaping () -> Void,
cache: StickerViewCache? = nil) {
public convenience init(
stickerInfo: StickerInfo,
didSelectBlock: @escaping () -> Void,
cache: StickerViewCache? = nil
) {
self.init(stickerInfo: stickerInfo, didSelectBlock: didSelectBlock, isSelectedBlock: { false }, cache: cache)
}
public init(stickerInfo: StickerInfo,
didSelectBlock: @escaping () -> Void,
isSelectedBlock: @escaping () -> Bool,
cache: StickerViewCache? = nil) {
public init(
stickerInfo: StickerInfo,
didSelectBlock: @escaping () -> Void,
isSelectedBlock: @escaping () -> Bool,
cache: StickerViewCache? = nil
) {
self.stickerInfo = stickerInfo
self.didSelectBlock = didSelectBlock
self.isSelectedBlock = isSelectedBlock
@@ -76,22 +80,18 @@ public class StickerHorizontalListViewItemRecents: StickerHorizontalListViewItem
public let didSelectBlock: () -> Void
public let isSelectedBlock: () -> Bool
private let forceDarkTheme: Bool
public init(
didSelectBlock: @escaping () -> Void,
isSelectedBlock: @escaping () -> Bool,
forceDarkTheme: Bool = false
isSelectedBlock: @escaping () -> Bool
) {
self.didSelectBlock = didSelectBlock
self.isSelectedBlock = isSelectedBlock
self.forceDarkTheme = forceDarkTheme
}
public var view: UIView {
let imageView = UIImageView()
let tintColor = forceDarkTheme ? Theme.darkThemeSecondaryTextAndIconColor : Theme.secondaryTextAndIconColor
imageView.setTemplateImageName("recent", tintColor: tintColor)
let imageView = UIImageView(image: UIImage(named: "recent"))
imageView.tintColor = .Signal.label
return imageView
}
@@ -109,8 +109,7 @@ public class StickerHorizontalListViewItemRecents: StickerHorizontalListViewItem
public class StickerHorizontalListView: UICollectionView {
private let cellSize: CGFloat
private let cellInset: CGFloat
private let forceDarkTheme: Bool
private let cellContentInset: CGFloat
public typealias Item = StickerHorizontalListViewItem
@@ -123,26 +122,67 @@ public class StickerHorizontalListView: UICollectionView {
}
}
private let cellReuseIdentifier = "cellReuseIdentifier"
private var cellRegistration: UICollectionView.CellRegistration<UICollectionViewCell, Item>!
public init(cellSize: CGFloat, cellInset: CGFloat, spacing: CGFloat, forceDarkTheme: Bool = false) {
public init(cellSize: CGFloat, cellContentInset: CGFloat, spacing: CGFloat) {
self.cellSize = cellSize
self.cellInset = cellInset
let layout = LinearHorizontalLayout(itemSize: CGSize(square: cellSize), spacing: spacing)
self.cellContentInset = cellContentInset
self.forceDarkTheme = forceDarkTheme
let layout = LinearHorizontalLayout(
configuration: .init(itemSize: CGSize(square: cellSize), minimumInteritemSpacing: spacing)
)
super.init(frame: .zero, collectionViewLayout: layout)
showsHorizontalScrollIndicator = false
let selectedBackgroundColor = UIColor { traitCollection in
traitCollection.userInterfaceStyle == .dark
? UIColor(white: 1, alpha: 0.2)
: UIColor(white: 0, alpha: 0.12)
}
cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, Item>
{ cell, indexPath, item in
// Remove previous content.
cell.contentView.removeAllSubviews()
// Add custom view to the cell.
let itemView = item.view
itemView.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(itemView)
NSLayoutConstraint.activate([
itemView.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: cellContentInset),
itemView.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: cellContentInset),
itemView.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -cellContentInset),
itemView.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -cellContentInset),
])
// Configure background - this closure is called whenever cell state changes.
cell.configurationUpdateHandler = { cell, state in
var background = UIBackgroundConfiguration.clear()
background.cornerRadius = cellSize / 2
if item.isSelected {
background.backgroundColor = selectedBackgroundColor
} else {
background.backgroundColor = .clear
}
cell.backgroundConfiguration = background
}
}
backgroundColor = .clear
delegate = self
dataSource = self
register(UICollectionViewCell.self, forCellWithReuseIdentifier: cellReuseIdentifier)
showsHorizontalScrollIndicator = false
setContentHuggingHorizontalLow()
setCompressionResistanceHorizontalLow()
}
required public init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// Reload visible items to refresh the "selected" state
func updateSelections(scrollToSelectedItem: Bool = false) {
reloadData()
@@ -150,10 +190,6 @@ public class StickerHorizontalListView: UICollectionView {
guard let (selectedIndex, _) = items.enumerated().first(where: { $1.isSelected }) else { return }
scrollToItem(at: IndexPath(row: selectedIndex, section: 0), at: .centeredHorizontally, animated: true)
}
required public init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - UICollectionViewDelegate
@@ -161,17 +197,13 @@ public class StickerHorizontalListView: UICollectionView {
extension StickerHorizontalListView: UICollectionViewDelegate {
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
Logger.debug("")
guard let item = items[safe: indexPath.row] else {
owsFailDebug("Invalid index path: \(indexPath)")
return
}
item.didSelectBlock()
// Selection has changed; update cells to reflect that.
self.reloadData()
reloadItems(at: [indexPath])
}
}
@@ -188,36 +220,139 @@ extension StickerHorizontalListView: UICollectionViewDataSource {
}
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
// We could eventually use cells that lazy-load the sticker views
// when the cells becomes visible and eagerly unload them.
// But we probably won't need to do that.
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath)
for subview in cell.contentView.subviews {
subview.removeFromSuperview()
}
guard let item = items[safe: indexPath.row] else {
owsFailDebug("Invalid index path: \(indexPath)")
return cell
return UICollectionViewCell()
}
if item.isSelected {
let selectionView = UIView()
selectionView.backgroundColor = (Theme.isDarkThemeEnabled || forceDarkTheme
? UIColor.ows_gray75
: UIColor.ows_gray15)
selectionView.layer.cornerRadius = 8
cell.contentView.addSubview(selectionView)
selectionView.autoPinEdgesToSuperviewEdges()
}
let itemView = item.view
cell.contentView.addSubview(itemView)
itemView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: cellInset, leading: cellInset, bottom: cellInset, trailing: cellInset))
itemView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: item.accessibilityName + ".item")
cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: item.accessibilityName + ".cell")
return cell
return collectionView.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: item
)
}
}
// A trivial layout that places each item in a horizontal line.
// Each item has uniform size.
private class LinearHorizontalLayout: UICollectionViewLayout {
struct Configuration {
var itemSize: CGSize
var itemSpacing: CGFloat
init(
itemSize: CGSize,
minimumInteritemSpacing: CGFloat = 8,
) {
self.itemSize = itemSize
self.itemSpacing = minimumInteritemSpacing
}
}
// MARK: - Properties
private let configuration: Configuration
private var cachedAttributes: [UICollectionViewLayoutAttributes] = []
private var contentWidth: CGFloat = 0
override var flipsHorizontallyInOppositeLayoutDirection: Bool {
true
}
override var collectionViewContentSize: CGSize {
guard let collectionView else { return .zero }
return CGSize(
width: contentWidth,
height: collectionView.bounds.height - collectionView.contentInset.totalHeight
)
}
// MARK: Initializers
@available(*, unavailable, message: "use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init(configuration: Configuration) {
self.configuration = configuration
super.init()
}
// MARK: Methods
override func invalidateLayout() {
super.invalidateLayout()
cachedAttributes.removeAll()
contentWidth = 0
}
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
super.invalidateLayout(with: context)
cachedAttributes.removeAll()
contentWidth = 0
}
override func prepare() {
super.prepare()
guard let collectionView, cachedAttributes.isEmpty else { return }
guard collectionView.numberOfSections == 1 else {
owsFailDebug("This layout only support a single section.")
return
}
let itemCount = collectionView.numberOfItems(inSection: 0)
guard itemCount > 0 else { return }
let itemSize = configuration.itemSize
let spacing = configuration.itemSpacing
// Calculate vertical centering
let collectionViewHeight = collectionView.bounds.height - collectionView.contentInset.totalHeight
let yPosition = (collectionViewHeight - itemSize.height) / 2
var xPosition: CGFloat = 0
// Create attributes for each item
for item in 0..<itemCount {
let indexPath = IndexPath(item: item, section: 0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = CGRect(
x: xPosition,
y: yPosition,
width: itemSize.width,
height: itemSize.height
)
cachedAttributes.append(attributes)
xPosition += itemSize.width + spacing
}
// Remove trailing spacing and add trailing inset
contentWidth = xPosition - spacing
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
cachedAttributes.filter { $0.frame.intersects(rect) }
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
cachedAttributes[safe: indexPath.row]
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let collectionView else { return false }
return newBounds.height != collectionView.bounds.height
}
}

View File

@@ -5,43 +5,23 @@
public import SignalServiceKit
// MARK: - Delegate Protocols
public enum StoryStickerConfiguration {
case hide
case showWithDelegate(StoryStickerPickerDelegate)
}
public protocol StickerPickerDelegate: AnyObject {
func didSelectSticker(stickerInfo: StickerInfo)
var storyStickerConfiguration: StoryStickerConfiguration { get }
}
public protocol StickerPackCollectionViewDelegate: StickerPickerDelegate {
func stickerPreviewHostView() -> UIView?
func stickerPreviewHasOverlay() -> Bool
}
public protocol StoryStickerPickerDelegate: AnyObject {
func didSelect(storySticker: EditorSticker.StorySticker)
}
// MARK: - StickerPackCollectionView
public class StickerPackCollectionView: UICollectionView {
private typealias StorySticker = EditorSticker.StorySticker
private var stickerPackDataSource: StickerPackDataSource? {
didSet {
AssertIsOnMainThread()
stickerPackDataSource?.add(delegate: self)
reloadStickers()
// Scroll to the top.
contentOffset = .zero
contentOffset.y = -contentInset.top
}
}
@@ -54,7 +34,7 @@ public class StickerPackCollectionView: UICollectionView {
public weak var stickerDelegate: StickerPackCollectionViewDelegate?
private var shouldShowStoryStickers: Bool {
if case .showWithDelegate = stickerDelegate?.storyStickerConfiguration {
if case .showWithDelegate = storyStickerConfiguration {
// Story sticker configuration must be `showWithDelegate`
// while also being a "Recents" page.
return stickerPackDataSource is RecentStickerPackDataSource
@@ -63,59 +43,80 @@ public class StickerPackCollectionView: UICollectionView {
return false
}
override public var frame: CGRect {
override public var bounds: CGRect {
didSet {
updateLayout()
// This is necessary in case view width changes but safe areas don't.
if bounds.width != oldValue.width {
updateLayout()
}
}
}
override public var bounds: CGRect {
override public var contentInset: UIEdgeInsets {
didSet {
updateLayout()
// 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
public init(placeholderColor: UIColor = .ows_gray45) {
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)
isUserInteractionEnabled = true
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)))
}
required public init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Modes
public func showInstalledPack(stickerPack: StickerPack) {
AssertIsOnMainThread()
self.stickerPackDataSource = InstalledStickerPackDataSource(stickerPackInfo: stickerPack.info)
stickerPackDataSource = InstalledStickerPackDataSource(stickerPackInfo: stickerPack.info)
}
public func showUninstalledPack(stickerPack: StickerPack) {
AssertIsOnMainThread()
self.stickerPackDataSource = TransientStickerPackDataSource(stickerPackInfo: stickerPack.info,
shouldDownloadAllStickers: true)
stickerPackDataSource = TransientStickerPackDataSource(stickerPackInfo: stickerPack.info,
shouldDownloadAllStickers: true)
}
public func showRecents() {
AssertIsOnMainThread()
self.stickerPackDataSource = RecentStickerPackDataSource()
stickerPackDataSource = RecentStickerPackDataSource()
}
public func showInstalledPackOrRecents(stickerPack: StickerPack?) {
if let stickerPack = stickerPack {
if let stickerPack {
showInstalledPack(stickerPack: stickerPack)
} else {
showRecents()
@@ -123,23 +124,98 @@ public class StickerPackCollectionView: UICollectionView {
}
public func show(dataSource: StickerPackDataSource) {
AssertIsOnMainThread()
stickerPackDataSource = dataSource
}
self.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
required public init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func reloadStickers() {
AssertIsOnMainThread()
defer { reloadData() }
guard let stickerPackDataSource = stickerPackDataSource else {
guard let stickerPackDataSource else {
stickerInfos = []
return
}
@@ -160,6 +236,14 @@ public class StickerPackCollectionView: UICollectionView {
}
}
public override func reloadData() {
super.reloadData()
updateEmptyState()
}
// MARK: Sticker Preview
@objc
private func handleLongPress(sender: UIGestureRecognizer) {
switch sender.state {
@@ -186,22 +270,17 @@ public class StickerPackCollectionView: UICollectionView {
}
private var previewView: UIView?
private var previewStickerInfo: StickerInfo?
private func hidePreview() {
AssertIsOnMainThread()
previewView?.removeFromSuperview()
previewView = nil
previewStickerInfo = nil
}
private func ensurePreview(stickerInfo: StickerInfo) {
AssertIsOnMainThread()
if previewView != nil,
let previewStickerInfo = previewStickerInfo,
previewStickerInfo == stickerInfo {
if previewView != nil, let previewStickerInfo, previewStickerInfo == stickerInfo {
// Already showing a preview for this sticker.
return
}
@@ -212,7 +291,7 @@ public class StickerPackCollectionView: UICollectionView {
Logger.warn("Couldn't load sticker for display")
return
}
guard let stickerDelegate = stickerDelegate else {
guard let stickerDelegate else {
owsFailDebug("Missing stickerDelegate")
return
}
@@ -228,7 +307,6 @@ public class StickerPackCollectionView: UICollectionView {
overlayView.autoPinEdgesToSuperviewEdges()
overlayView.setContentHuggingLow()
overlayView.setCompressionResistanceLow()
overlayView.addSubview(stickerView)
previewView = overlayView
} else {
@@ -250,7 +328,7 @@ public class StickerPackCollectionView: UICollectionView {
}
private func imageView(forStickerInfo stickerInfo: StickerInfo) -> UIView? {
guard let stickerPackDataSource = stickerPackDataSource else {
guard let stickerPackDataSource else {
owsFailDebug("Missing stickerPackDataSource.")
return nil
}
@@ -258,6 +336,7 @@ public class StickerPackCollectionView: UICollectionView {
}
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 }
@@ -282,6 +361,7 @@ public class StickerPackCollectionView: UICollectionView {
// MARK: - UICollectionViewDelegate
extension StickerPackCollectionView: UICollectionViewDelegate {
private func isStoryStickerSection(sectionIndex: Int) -> Bool {
return shouldShowStoryStickers && sectionIndex == 0
}
@@ -294,16 +374,12 @@ extension StickerPackCollectionView: UICollectionViewDelegate {
owsFailDebug("Invalid index path: \(indexPath)")
return
}
switch stickerDelegate?.storyStickerConfiguration {
case .showWithDelegate(let storyStickerPickerDelegate):
storyStickerPickerDelegate.didSelect(storySticker: storySticker)
case .hide:
guard case .showWithDelegate(let storyStickerPickerDelegate) = storyStickerConfiguration else {
owsFailDebug("Unexpectedly found hidden story stickers.")
case .none:
owsFailDebug("Missing delegate.")
return
}
storyStickerPickerDelegate.didSelect(storySticker: storySticker)
return
}
@@ -312,7 +388,7 @@ extension StickerPackCollectionView: UICollectionViewDelegate {
return
}
self.stickerDelegate?.didSelectSticker(stickerInfo: stickerInfo)
self.stickerDelegate?.didSelectSticker(stickerInfo)
}
}
@@ -358,7 +434,11 @@ extension StickerPackCollectionView: UICollectionViewDataSource {
return cell
}
public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
public func collectionView(
_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath
) -> UICollectionReusableView {
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: headerReuseIdentifier, for: indexPath)
guard
@@ -390,17 +470,22 @@ extension StickerPackCollectionView: UICollectionViewDataSource {
}
extension StickerPackCollectionView: UICollectionViewDelegateFlowLayout {
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
guard let headerText = self.headerText(for: section) else { return .zero }
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()
@@ -437,7 +522,6 @@ extension StickerPackCollectionView {
private class func buildLayout() -> UICollectionViewFlowLayout {
let layout = UICollectionViewFlowLayout()
layout.sectionInsetReference = .fromLayoutMargins
layout.minimumInteritemSpacing = minimumCellSpacing
layout.minimumLineSpacing = minimumCellSpacing
return layout
@@ -449,7 +533,7 @@ extension StickerPackCollectionView {
return
}
let contentWidth = layoutMarginsGuide.layoutFrame.size.width
let contentWidth = safeAreaLayoutGuide.layoutFrame.size.width - contentInset.totalWidth
let cellSpacing = Self.minimumCellSpacing
let preferredCellSize: CGFloat = 80
let columnCount = UInt((contentWidth + cellSpacing) / (preferredCellSize + cellSpacing))
@@ -466,9 +550,8 @@ extension StickerPackCollectionView {
// MARK: -
extension StickerPackCollectionView: StickerPackDataSourceDelegate {
public func stickerPackDataDidChange() {
AssertIsOnMainThread()
public func stickerPackDataDidChange() {
reloadStickers()
}
}

View File

@@ -1,549 +1,19 @@
//
// Copyright 2019 Signal Messenger, LLC
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
// MARK: - Delegate Protocols
public protocol StickerPacksToolbarDelegate: AnyObject {
func presentManageStickersView()
public protocol StoryStickerPickerDelegate: AnyObject {
func didSelect(storySticker: EditorSticker.StorySticker)
}
public protocol StickerPickerPageViewDelegate: StickerPickerDelegate {
func setItems(_ items: [StickerHorizontalListViewItem])
func updateSelections(scrollToSelectedItem: Bool)
public enum StoryStickerConfiguration {
case hide
case showWithDelegate(StoryStickerPickerDelegate)
}
// 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()
}
public protocol StickerPickerDelegate: AnyObject {
func didSelectSticker(_ stickerInfo: StickerInfo)
}

View File

@@ -7,81 +7,69 @@ public import SignalServiceKit
// MARK: - StickerKeyboard
public protocol StickerKeyboardDelegate: AnyObject {
func stickerKeyboardDidRequestPresentManageStickersView(_ stickerKeyboard: StickerKeyboard)
func stickerKeyboard(_: StickerKeyboard, didSelect stickerInfo: StickerInfo)
}
public class StickerKeyboard: CustomKeyboard {
public typealias StickerKeyboardDelegate = StickerPickerDelegate & StickerPacksToolbarDelegate
public weak var delegate: StickerKeyboardDelegate?
private let headerView = StickerPacksToolbar()
private lazy var stickerPickerPageView = StickerPickerPageView(delegate: self)
private lazy var stickerPickerView = StickerPickerView(delegate: self)
public init(delegate: StickerKeyboardDelegate?) {
self.delegate = delegate
super.init()
let topInset: CGFloat
if #available(iOS 26, *), BuildFlags.iOS26SDKIsAvailable {
topInset = 24
backgroundColor = .clear
} else {
topInset = 0
backgroundColor = .Signal.background
backgroundColor = if #available(iOS 26, *) { .clear } else { .Signal.background }
// Match rounded corners of the keyboard backdrop view.
if #available(iOS 26, *) {
contentView.clipsToBounds = true
contentView.cornerConfiguration = .uniformTopRadius(.fixed(26))
}
let stackView = UIStackView(arrangedSubviews: [ headerView, stickerPickerPageView ])
contentView.addSubview(stackView)
stackView.axis = .vertical
stackView.alignment = .fill
stackView.autoPinEdges(toSuperviewEdgesExcludingEdge: .top)
stackView.autoPinEdge(toSuperviewEdge: .top, withInset: topInset)
headerView.delegate = self
// Need to set horizontal margins explicitly because they can't be inherited from the parent.
let hMargin = OWSTableViewController2.cellHInnerMargin
stickerPickerView.directionalLayoutMargins = .init(margin: hMargin)
stickerPickerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stickerPickerView)
NSLayoutConstraint.activate([
stickerPickerView.topAnchor.constraint(equalTo: contentView.topAnchor),
stickerPickerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stickerPickerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
stickerPickerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
required public init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func wasPresented() {
super.wasPresented()
stickerPickerPageView.wasPresented()
public override func willPresent() {
super.willPresent()
stickerPickerView.willBePresented()
}
public override func wasPresented() {
super.wasPresented()
stickerPickerView.wasPresented()
}
}
// MARK: StickerPacksToolbarDelegate
extension StickerKeyboard: StickerPacksToolbarDelegate {
public var shouldShowManageButton: Bool { true }
extension StickerKeyboard: StickerPickerViewDelegate {
public func manageButtonWasPressed() {
AssertIsOnMainThread()
func presentManageStickersView(for stickerPickerView: StickerPickerView) {
delegate?.stickerKeyboardDidRequestPresentManageStickersView(self)
}
delegate?.presentManageStickersView()
}
}
// MARK: StickerPickerPageViewDelegate
extension StickerKeyboard: StickerPickerPageViewDelegate {
public func didSelectSticker(stickerInfo: StickerInfo) {
self.delegate?.didSelectSticker(stickerInfo: stickerInfo)
}
public var storyStickerConfiguration: StoryStickerConfiguration {
.hide
}
public func presentManageStickersView() {
self.delegate?.presentManageStickersView()
}
public func setItems(_ items: [StickerHorizontalListViewItem]) {
headerView.packsCollectionView.items = items
}
public func updateSelections(scrollToSelectedItem: Bool) {
headerView.packsCollectionView.updateSelections(scrollToSelectedItem: scrollToSelectedItem)
public func didSelectSticker(_ stickerInfo: StickerInfo) {
delegate?.stickerKeyboard(self, didSelect: stickerInfo)
}
}

View File

@@ -3,19 +3,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import UIKit
public import SignalServiceKit
// MARK: - StickerPickerSheetDelegate
public protocol StickerPickerSheetDelegate: AnyObject {
func makeManageStickersViewController() -> UIViewController
func makeManageStickersViewController(for: StickerPickerSheet) -> UIViewController
}
// MARK: - StickerPickerSheet
public class StickerPickerSheet: InteractiveSheetViewController {
public override var interactiveScrollViews: [UIScrollView] { stickerPicker.stickerPackCollectionViews }
public override var interactiveScrollViews: [UIScrollView] { stickerPickerView.stickerPackCollectionViewPages }
public override var sheetBackgroundColor: UIColor { .clear }
/// Used for presenting the sticker manager from the toolbar.
@@ -25,93 +22,80 @@ public class StickerPickerSheet: InteractiveSheetViewController {
/// on the toolbar.
public weak var sheetDelegate: StickerPickerSheetDelegate? {
didSet {
// The toolbar only shows the manage button if it has a delegate
// The picker view only shows the manage button if it has a delegate
// If the sheet doesn't have a delegate, it can't present the
// manage stickers view controller, so only set the toolbar
// manage stickers view controller, so only set the picker view
// delegate if there is a sheet delegate.
stickerPacksToolbar.delegate = sheetDelegate == nil ? nil : self
stickerPickerView.delegate = sheetDelegate == nil ? nil : self
}
}
/// Used for handling sticker selection.
public weak var pickerDelegate: StickerPickerDelegate?
private let stickerPacksToolbar = StickerPacksToolbar(forceDarkTheme: true)
private lazy var stickerPicker = StickerPickerPageView(delegate: self, forceDarkTheme: true)
private weak var pickerDelegate: (StickerPickerDelegate&StoryStickerPickerDelegate)?
override init(visualEffect: UIVisualEffect? = nil) {
super.init(visualEffect: visualEffect)
}
private lazy var stickerPickerView = StickerPickerView(
delegate: self,
storyStickerConfiguration: .showWithDelegate(pickerDelegate!)
)
init(backgroundColor: UIColor) {
super.init()
stickerPicker.backgroundColor = backgroundColor
public init(pickerDelegate: StickerPickerDelegate&StoryStickerPickerDelegate) {
self.pickerDelegate = pickerDelegate
let useBlurEffect = !UIAccessibility.isReduceTransparencyEnabled
super.init(visualEffect: useBlurEffect ? UIBlurEffect(style: .dark) : nil)
if !useBlurEffect {
stickerPickerView.backgroundColor = .Signal.background
}
}
public override func viewDidLoad() {
super.viewDidLoad()
contentView.addSubview(stickerPicker)
stickerPicker.autoPinEdgesToSuperviewEdges()
stickerPicker.stickerPackCollectionViews.forEach { $0.alwaysBounceVertical = true }
overrideUserInterfaceStyle = .dark
view.addSubview(stickerPacksToolbar)
stickerPacksToolbar.autoPinEdges(toSuperviewEdgesExcludingEdge: .top)
stickerPacksToolbar.backgroundColor = .ows_gray90
stickerPickerView.directionalLayoutMargins = .init(
hMargin: OWSTableViewController2.cellHInnerMargin,
vMargin: 8
)
contentView.addSubview(stickerPickerView)
stickerPickerView.autoPinEdgesToSuperviewEdges()
stickerPickerView.stickerPackCollectionViewPages.forEach { $0.alwaysBounceVertical = true }
}
private var viewHasAppeared = false
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
stickerPickerView.willBePresented()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewHasAppeared = true
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Ensure the scrollView's layout has completed
// as we're about to use its bounds to calculate
// the masking view and contentOffset.
contentView.layoutIfNeeded()
// Ensure you can scroll to the last sticker without
// them being stuck behind the toolbar.
let bottomInset = stickerPacksToolbar.height - stickerPacksToolbar.safeAreaInsets.bottom
let contentInset = UIEdgeInsets(top: 0, leading: 0, bottom: bottomInset, trailing: 0)
stickerPicker.stickerPackCollectionViews.forEach { collectionView in
collectionView.contentInset = contentInset
collectionView.scrollIndicatorInsets = contentInset
if !viewHasAppeared {
stickerPickerView.wasPresented()
}
guard !viewHasAppeared else { return }
stickerPicker.wasPresented()
}
}
// MARK: StickerPacksToolbarDelegate
// MARK: StickerPickerViewDelegate
extension StickerPickerSheet: StickerPacksToolbarDelegate {
public func presentManageStickersView() {
extension StickerPickerSheet: StickerPickerViewDelegate {
func presentManageStickersView(for stickerPickerView: StickerPickerView) {
guard let sheetDelegate else { return }
let manageStickersViewController = sheetDelegate.makeManageStickersViewController()
let manageStickersViewController = sheetDelegate.makeManageStickersViewController(for: self)
presentFormSheet(manageStickersViewController, animated: true)
}
}
// MARK: StickerPickerPageViewDelegate
extension StickerPickerSheet: StickerPickerPageViewDelegate {
public func didSelectSticker(stickerInfo: StickerInfo) {
self.pickerDelegate?.didSelectSticker(stickerInfo: stickerInfo)
}
public var storyStickerConfiguration: StoryStickerConfiguration {
self.pickerDelegate?.storyStickerConfiguration ?? .hide
}
public func setItems(_ items: [StickerHorizontalListViewItem]) {
stickerPacksToolbar.packsCollectionView.items = items
}
public func updateSelections(scrollToSelectedItem: Bool) {
stickerPacksToolbar.packsCollectionView.updateSelections(scrollToSelectedItem: scrollToSelectedItem)
public func didSelectSticker(_ stickerInfo: StickerInfo) {
pickerDelegate?.didSelectSticker(stickerInfo)
}
}

View File

@@ -0,0 +1,862 @@
//
// 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)
// Copied from ConversationInputToolbar.
glassEffect.tintColor = UIColor { traitCollection in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(white: 0, alpha: 0.2)
}
return UIColor(white: 1, alpha: 0.12)
}
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 non-zero padding on devices with the home button that doesn't have bottom safe area inset.
safeAreaInsets.bottom == 0 ? Metrics.minimumBottomMargin : safeAreaInsets.bottom
}
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 = 8 // 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 = [StickerPack]()
private var selectedStickerPack: StickerPack? {
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 public 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 = 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: 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
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: 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) }
// 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: 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) {
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
}
}

View File

@@ -0,0 +1,53 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
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)
}
func get(key: StickerInfo) -> StickerReusableView? {
self.backingCache.get(key: key)?.value
}
func set(key: StickerInfo, value: StickerReusableView) {
self.backingCache.set(key: key, value: ThreadSafeCacheHandle(value))
}
func remove(key: StickerInfo) {
self.backingCache.remove(key: key)
}
func clear() {
self.backingCache.clear()
}
// MARK: NSCache Compatibility
func setObject(_ value: StickerReusableView, forKey key: StickerInfo) {
set(key: key, value: value)
}
func object(forKey key: StickerInfo) -> StickerReusableView? {
self.get(key: key)
}
func removeObject(forKey key: StickerInfo) {
remove(key: key)
}
func removeAllObjects() {
clear()
}
}