mirror of
https://github.com/signalapp/Signal-iOS.git
synced 2025-12-05 01:10:41 +00:00
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:
committed by
GitHub
parent
cff419bf4c
commit
effab076b4
@@ -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 */,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
862
SignalUI/Stickers/StickerPickerView.swift
Normal file
862
SignalUI/Stickers/StickerPickerView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
53
SignalUI/Stickers/StickerViewCache.swift
Normal file
53
SignalUI/Stickers/StickerViewCache.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user