mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-08-26 15:31:12 +00:00
1242 lines
59 KiB
Swift
1242 lines
59 KiB
Swift
import AsyncDisplayKit
|
|
import UIKit
|
|
import Display
|
|
import ComponentFlow
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import Postbox
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import ContextUI
|
|
import PhotoResources
|
|
import TelegramUIPreferences
|
|
import TelegramStringFormatting
|
|
import ItemListPeerItem
|
|
import ItemListPeerActionItem
|
|
import MergeLists
|
|
import ItemListUI
|
|
import MultilineTextComponent
|
|
import BalancedTextComponent
|
|
import Markdown
|
|
import PeerInfoPaneNode
|
|
import GiftItemComponent
|
|
import PlainButtonComponent
|
|
import GiftViewScreen
|
|
import SolidRoundedButtonNode
|
|
import UndoUI
|
|
import LottieComponent
|
|
import ButtonComponent
|
|
import ContextUI
|
|
|
|
final class GiftsListView: UIView {
|
|
private let context: AccountContext
|
|
private let peerId: PeerId
|
|
let profileGifts: ProfileGiftsContext
|
|
private let giftsCollections: ProfileGiftsCollectionsContext?
|
|
|
|
private let canSelect: Bool
|
|
private let ignoreCollection: Int32?
|
|
private let remainingSelectionCount: Int32
|
|
|
|
private var dataDisposable: Disposable?
|
|
|
|
weak var parentController: ViewController?
|
|
|
|
private var footerText: ComponentView<Empty>?
|
|
|
|
private let emptyResultsClippingView = UIView()
|
|
private let emptyResultsAnimation = ComponentView<Empty>()
|
|
private let emptyResultsTitle = ComponentView<Empty>()
|
|
private let emptyResultsText = ComponentView<Empty>()
|
|
private let emptyResultsAction = ComponentView<Empty>()
|
|
|
|
private var currentParams: (size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData)?
|
|
private var visibleBounds: CGRect?
|
|
private var topInset: CGFloat?
|
|
|
|
private var theme: PresentationTheme?
|
|
private let presentationDataPromise = Promise<PresentationData>()
|
|
|
|
private let ready = Promise<Bool>()
|
|
private var didSetReady: Bool = false
|
|
var isReady: Signal<Bool, NoError> {
|
|
return self.ready.get()
|
|
}
|
|
|
|
private let statusPromise = Promise<PeerInfoStatusData?>(nil)
|
|
var status: Signal<PeerInfoStatusData?, NoError> {
|
|
self.statusPromise.get()
|
|
}
|
|
|
|
private var starsProducts: [ProfileGiftsContext.State.StarGift]?
|
|
private var starsItems: [AnyHashable: (StarGiftReference?, ComponentView<Empty>)] = [:]
|
|
|
|
private(set) var resultsAreEmpty = false
|
|
private var filteredResultsAreEmpty = false
|
|
|
|
var onContentUpdated: () -> Void = { }
|
|
|
|
private(set) var selectedItemIds = Set<AnyHashable>()
|
|
private var selectedItemsMap: [AnyHashable: ProfileGiftsContext.State.StarGift] = [:]
|
|
var selectionUpdated: () -> Void = { }
|
|
|
|
var selectedItems: [ProfileGiftsContext.State.StarGift] {
|
|
var gifts: [ProfileGiftsContext.State.StarGift] = []
|
|
var existingIds = Set<AnyHashable>()
|
|
if let currentGifts = self.profileGifts.currentState?.gifts {
|
|
for gift in currentGifts {
|
|
if let itemId = gift.reference?.stringValue {
|
|
if self.selectedItemIds.contains(itemId) {
|
|
gifts.append(gift)
|
|
existingIds.insert(itemId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for itemId in self.selectedItemIds {
|
|
if !existingIds.contains(itemId), let item = self.selectedItemsMap[itemId] {
|
|
gifts.append(item)
|
|
}
|
|
}
|
|
return gifts
|
|
}
|
|
|
|
private(set) var pinnedReferences: [StarGiftReference] = []
|
|
private var isReordering: Bool = false
|
|
private var reorderingItem: (id: AnyHashable, initialPosition: CGPoint, position: CGPoint)?
|
|
private var reorderedReferences: [StarGiftReference]? {
|
|
didSet {
|
|
self.reorderedReferencesPromise.set(self.reorderedReferences)
|
|
}
|
|
}
|
|
private var reorderedReferencesPromise = ValuePromise<[StarGiftReference]?>(nil)
|
|
|
|
private var reorderedPinnedReferences: Set<StarGiftReference>? {
|
|
didSet {
|
|
self.reorderedPinnedReferencesPromise.set(self.reorderedPinnedReferences)
|
|
}
|
|
}
|
|
private var reorderedPinnedReferencesPromise = ValuePromise<Set<StarGiftReference>?>(nil)
|
|
|
|
private var reorderRecognizer: ReorderGestureRecognizer?
|
|
|
|
let maxPinnedCount: Int
|
|
|
|
var contextAction: ((ProfileGiftsContext.State.StarGift, UIView, ContextGesture) -> Void)?
|
|
var addToCollection: (() -> Void)?
|
|
|
|
init(context: AccountContext, peerId: PeerId, profileGifts: ProfileGiftsContext, giftsCollections: ProfileGiftsCollectionsContext?, canSelect: Bool, ignoreCollection: Int32? = nil, remainingSelectionCount: Int32 = 0) {
|
|
self.context = context
|
|
self.peerId = peerId
|
|
self.profileGifts = profileGifts
|
|
self.giftsCollections = giftsCollections
|
|
self.canSelect = canSelect
|
|
self.ignoreCollection = ignoreCollection
|
|
self.remainingSelectionCount = remainingSelectionCount
|
|
|
|
if let value = context.currentAppConfiguration.with({ $0 }).data?["stargifts_pinned_to_top_limit"] as? Double {
|
|
self.maxPinnedCount = Int(value)
|
|
} else {
|
|
self.maxPinnedCount = 6
|
|
}
|
|
|
|
super.init(frame: .zero)
|
|
|
|
self.dataDisposable = combineLatest(
|
|
queue: Queue.mainQueue(),
|
|
profileGifts.state,
|
|
self.reorderedReferencesPromise.get()
|
|
).startStrict(next: { [weak self] state, reorderedReferences in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let isFirstTime = self.starsProducts == nil
|
|
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
|
self.statusPromise.set(.single(PeerInfoStatusData(text: presentationData.strings.SharedMedia_GiftCount(state.count ?? 0), isActivity: true, key: .gifts)))
|
|
|
|
if self.isReordering {
|
|
var stateItems: [ProfileGiftsContext.State.StarGift] = state.gifts
|
|
if let reorderedReferences {
|
|
var fixedStateItems: [ProfileGiftsContext.State.StarGift] = []
|
|
|
|
var seenIds = Set<StarGiftReference>()
|
|
for reference in reorderedReferences {
|
|
if let index = stateItems.firstIndex(where: { $0.reference == reference }) {
|
|
seenIds.insert(reference)
|
|
var item = stateItems[index]
|
|
if self.reorderedPinnedReferences?.contains(reference) == true, !item.pinnedToTop {
|
|
item = item.withPinnedToTop(true)
|
|
}
|
|
fixedStateItems.append(item)
|
|
}
|
|
}
|
|
|
|
for item in stateItems {
|
|
if let reference = item.reference, !seenIds.contains(reference) {
|
|
var item = item
|
|
if self.reorderedPinnedReferences?.contains(reference) == true, !item.pinnedToTop {
|
|
item = item.withPinnedToTop(true)
|
|
}
|
|
fixedStateItems.append(item)
|
|
}
|
|
}
|
|
stateItems = fixedStateItems
|
|
}
|
|
self.starsProducts = stateItems
|
|
self.pinnedReferences = Array(stateItems.filter { $0.pinnedToTop }.compactMap { $0.reference })
|
|
} else {
|
|
self.starsProducts = state.filteredGifts
|
|
self.pinnedReferences = Array(state.gifts.filter { $0.pinnedToTop }.compactMap { $0.reference })
|
|
}
|
|
|
|
self.resultsAreEmpty = state.filter == .All && state.gifts.isEmpty && state.dataState != .loading
|
|
self.filteredResultsAreEmpty = state.filter != .All && state.filteredGifts.isEmpty
|
|
|
|
if !self.didSetReady {
|
|
self.didSetReady = true
|
|
self.ready.set(.single(true))
|
|
}
|
|
|
|
let _ = self.updateScrolling(transition: isFirstTime ? .immediate : .easeInOut(duration: 0.25))
|
|
|
|
Queue.mainQueue().justDispatch {
|
|
self.onContentUpdated()
|
|
}
|
|
})
|
|
|
|
self.emptyResultsClippingView.clipsToBounds = true
|
|
self.emptyResultsClippingView.isHidden = true
|
|
self.addSubview(self.emptyResultsClippingView)
|
|
|
|
let reorderRecognizer = ReorderGestureRecognizer(
|
|
shouldBegin: { [weak self] point in
|
|
guard let self, let (id, item) = self.item(at: point) else {
|
|
return (allowed: false, requiresLongPress: false, id: nil, item: nil)
|
|
}
|
|
return (allowed: true, requiresLongPress: false, id: id, item: item)
|
|
},
|
|
willBegin: { point in
|
|
},
|
|
began: { [weak self] item in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.setReorderingItem(item: item)
|
|
},
|
|
ended: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.setReorderingItem(item: nil)
|
|
},
|
|
moved: { [weak self] distance in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.moveReorderingItem(distance: distance)
|
|
},
|
|
isActiveUpdated: { _ in
|
|
}
|
|
)
|
|
self.reorderRecognizer = reorderRecognizer
|
|
self.addGestureRecognizer(reorderRecognizer)
|
|
reorderRecognizer.isEnabled = false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
deinit {
|
|
self.dataDisposable?.dispose()
|
|
}
|
|
|
|
func item(at point: CGPoint) -> (AnyHashable, ComponentView<Empty>)? {
|
|
for (id, visibleItem) in self.starsItems {
|
|
if let view = visibleItem.1.view, view.frame.contains(point), let reference = visibleItem.0, self.isCollection || self.pinnedReferences.contains(reference) {
|
|
return (id, visibleItem.1)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func beginReordering() {
|
|
self.profileGifts.updateFilter(.All)
|
|
self.profileGifts.updateSorting(.date)
|
|
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.togglePaneIsReordering(isReordering: true)
|
|
} else {
|
|
self.updateIsReordering(isReordering: true, animated: true)
|
|
}
|
|
}
|
|
|
|
func endReordering() {
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.togglePaneIsReordering(isReordering: false)
|
|
} else {
|
|
self.updateIsReordering(isReordering: false, animated: true)
|
|
}
|
|
}
|
|
|
|
func updateIsReordering(isReordering: Bool, animated: Bool) {
|
|
if self.isReordering != isReordering {
|
|
self.isReordering = isReordering
|
|
|
|
self.reorderRecognizer?.isEnabled = isReordering
|
|
|
|
if !isReordering, let _ = self.reorderedReferences, let starsProducts = self.starsProducts {
|
|
if let collectionId = self.profileGifts.collectionId {
|
|
var orderedReferences: [StarGiftReference] = []
|
|
for gift in starsProducts {
|
|
if let reference = gift.reference {
|
|
orderedReferences.append(reference)
|
|
}
|
|
}
|
|
let _ = self.giftsCollections?.reorderGifts(id: collectionId, gifts: orderedReferences).start()
|
|
} else {
|
|
var pinnedReferences: [StarGiftReference] = []
|
|
for gift in starsProducts.prefix(self.maxPinnedCount) {
|
|
if gift.pinnedToTop, let reference = gift.reference {
|
|
pinnedReferences.append(reference)
|
|
}
|
|
}
|
|
self.profileGifts.updatePinnedToTopStarGifts(references: pinnedReferences)
|
|
}
|
|
|
|
Queue.mainQueue().after(1.0) {
|
|
self.reorderedReferences = nil
|
|
self.reorderedPinnedReferences = nil
|
|
}
|
|
}
|
|
|
|
self.updateScrolling(transition: animated ? .spring(duration: 0.4) : .immediate)
|
|
}
|
|
}
|
|
|
|
func setReorderingItem(item: AnyHashable?) {
|
|
var mappedItem: (AnyHashable, ComponentView<Empty>)?
|
|
for (id, visibleItem) in self.starsItems {
|
|
if id == item {
|
|
mappedItem = (id, visibleItem.1)
|
|
break
|
|
}
|
|
}
|
|
|
|
if self.reorderingItem?.id != mappedItem?.0 {
|
|
if let (id, visibleItem) = mappedItem, let view = visibleItem.view {
|
|
self.addSubview(view)
|
|
self.reorderingItem = (id, view.center, view.center)
|
|
} else {
|
|
self.reorderingItem = nil
|
|
}
|
|
self.updateScrolling(transition: item == nil ? .spring(duration: 0.3) : .immediate)
|
|
}
|
|
}
|
|
|
|
func moveReorderingItem(distance: CGPoint) {
|
|
if let (id, initialPosition, _) = self.reorderingItem {
|
|
let targetPosition = CGPoint(x: initialPosition.x + distance.x, y: initialPosition.y + distance.y)
|
|
self.reorderingItem = (id, initialPosition, targetPosition)
|
|
self.updateScrolling(transition: .immediate)
|
|
|
|
if let starsProducts = self.starsProducts, let visibleReorderingItem = self.starsItems[id] {
|
|
for (_, visibleItem) in self.starsItems {
|
|
if visibleItem.1 === visibleReorderingItem.1 {
|
|
continue
|
|
}
|
|
if let view = visibleItem.1.view, view.frame.contains(targetPosition), let reorderItemReference = self.starsItems[id]?.0 {
|
|
if let targetIndex = starsProducts.firstIndex(where: { $0.reference == visibleItem.0 }) {
|
|
self.reorderIfPossible(reference: reorderItemReference, toIndex: targetIndex)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isCollection: Bool {
|
|
return self.profileGifts.collectionId != nil
|
|
}
|
|
|
|
private func reorderIfPossible(reference: StarGiftReference, toIndex: Int) {
|
|
if let items = self.starsProducts {
|
|
var toIndex = toIndex
|
|
|
|
let maxPinnedIndex: Int?
|
|
if self.isCollection {
|
|
maxPinnedIndex = items.count - 1
|
|
} else {
|
|
maxPinnedIndex = items.lastIndex(where: { $0.pinnedToTop })
|
|
}
|
|
if let maxPinnedIndex {
|
|
toIndex = min(toIndex, maxPinnedIndex)
|
|
} else {
|
|
return
|
|
}
|
|
|
|
var ids = items.compactMap { item -> StarGiftReference? in
|
|
return item.reference
|
|
}
|
|
|
|
if let fromIndex = ids.firstIndex(of: reference) {
|
|
if fromIndex < toIndex {
|
|
ids.insert(reference, at: toIndex + 1)
|
|
ids.remove(at: fromIndex)
|
|
} else if fromIndex > toIndex {
|
|
ids.remove(at: fromIndex)
|
|
ids.insert(reference, at: toIndex)
|
|
}
|
|
}
|
|
if self.reorderedReferences != ids {
|
|
self.reorderedReferences = ids
|
|
|
|
HapticFeedback().tap()
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadMore() {
|
|
self.profileGifts.loadMore()
|
|
}
|
|
|
|
@discardableResult
|
|
private func updateScrolling(interactive: Bool = false, transition: ComponentTransition) -> CGFloat {
|
|
guard let topInset = self.topInset, let visibleBounds = self.visibleBounds else {
|
|
return 0.0
|
|
}
|
|
return self.updateScrolling(interactive: interactive, topInset: topInset, visibleBounds: visibleBounds, transition: transition)
|
|
}
|
|
|
|
func updateScrolling(interactive: Bool = false, topInset: CGFloat, visibleBounds: CGRect, transition: ComponentTransition) -> CGFloat {
|
|
self.topInset = topInset
|
|
self.visibleBounds = visibleBounds
|
|
|
|
guard let starsProducts = self.starsProducts, let params = self.currentParams else {
|
|
return 0.0
|
|
}
|
|
|
|
let optionSpacing: CGFloat = 10.0
|
|
let itemsSideInset = params.sideInset + 16.0
|
|
|
|
let defaultItemsInRow: Int
|
|
if params.size.width > params.size.height || params.size.width > 480.0 {
|
|
if case .tablet = params.deviceMetrics.type {
|
|
defaultItemsInRow = 4
|
|
} else {
|
|
defaultItemsInRow = 5
|
|
}
|
|
} else {
|
|
defaultItemsInRow = 3
|
|
}
|
|
let itemsInRow = max(1, min(starsProducts.count, defaultItemsInRow))
|
|
let defaultOptionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(defaultItemsInRow - 1)) / CGFloat(defaultItemsInRow)
|
|
let optionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(itemsInRow - 1)) / CGFloat(itemsInRow)
|
|
|
|
let starsOptionSize = CGSize(width: optionWidth, height: defaultOptionWidth)
|
|
|
|
var validIds: [AnyHashable] = []
|
|
var itemFrame = CGRect(origin: CGPoint(x: itemsSideInset, y: topInset), size: starsOptionSize)
|
|
|
|
var index: Int32 = 0
|
|
for product in starsProducts {
|
|
var isVisible = false
|
|
if visibleBounds.intersects(itemFrame) {
|
|
isVisible = true
|
|
}
|
|
|
|
if isVisible {
|
|
let info: String
|
|
switch product.gift {
|
|
case let .generic(gift):
|
|
info = "g_\(gift.id)"
|
|
case let .unique(gift):
|
|
info = "u_\(gift.id)"
|
|
}
|
|
let stableId = product.reference?.stringValue ?? "\(index)"
|
|
let id = "\(stableId)_\(info)"
|
|
let itemId = AnyHashable(id)
|
|
validIds.append(itemId)
|
|
|
|
var itemTransition = transition
|
|
let visibleItem: ComponentView<Empty>
|
|
if let (_, current) = self.starsItems[itemId] {
|
|
visibleItem = current
|
|
} else {
|
|
visibleItem = ComponentView()
|
|
self.starsItems[itemId] = (product.reference, visibleItem)
|
|
itemTransition = .immediate
|
|
}
|
|
|
|
var ribbonText: String?
|
|
var ribbonColor: GiftItemComponent.Ribbon.Color = .blue
|
|
var ribbonFont: GiftItemComponent.Ribbon.Font = .generic
|
|
var ribbonOutline: UIColor?
|
|
|
|
let peer: GiftItemComponent.Peer?
|
|
let subject: GiftItemComponent.Subject
|
|
var resellPrice: Int64?
|
|
|
|
switch product.gift {
|
|
case let .generic(gift):
|
|
subject = .starGift(gift: gift, price: "# \(gift.price)")
|
|
peer = product.fromPeer.flatMap { .peer($0) } ?? .anonymous
|
|
|
|
if let availability = gift.availability {
|
|
ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(availability.total), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string
|
|
} else {
|
|
ribbonText = nil
|
|
}
|
|
case let .unique(gift):
|
|
subject = .uniqueGift(gift: gift, price: nil)
|
|
peer = nil
|
|
resellPrice = gift.resellStars
|
|
|
|
if let _ = resellPrice {
|
|
ribbonText = params.presentationData.strings.PeerInfo_Gifts_Sale
|
|
ribbonFont = .larger
|
|
ribbonColor = .green
|
|
ribbonOutline = params.presentationData.theme.list.blocksBackgroundColor
|
|
} else {
|
|
if product.pinnedToTop || self.canSelect || self.isCollection {
|
|
ribbonFont = .monospaced
|
|
ribbonText = "#\(gift.number)"
|
|
} else {
|
|
ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(gift.availability.issued), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string
|
|
}
|
|
for attribute in gift.attributes {
|
|
if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute {
|
|
ribbonColor = .custom(outerColor, innerColor)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let itemReferenceId = product.reference?.stringValue ?? ""
|
|
|
|
var isAdded = false
|
|
if let ignoreCollection = self.ignoreCollection, let collectionIds = product.collectionIds, collectionIds.contains(ignoreCollection) {
|
|
isAdded = true
|
|
}
|
|
|
|
var itemAlpha: CGFloat = 1.0
|
|
if isAdded {
|
|
itemAlpha = 0.3
|
|
}
|
|
|
|
let _ = visibleItem.update(
|
|
transition: itemTransition,
|
|
component: AnyComponent(
|
|
GiftItemComponent(
|
|
context: self.context,
|
|
theme: params.presentationData.theme,
|
|
strings: params.presentationData.strings,
|
|
peer: peer,
|
|
subject: subject,
|
|
ribbon: ribbonText.flatMap { GiftItemComponent.Ribbon(text: $0, font: ribbonFont, color: ribbonColor, outline: ribbonOutline) },
|
|
resellPrice: resellPrice,
|
|
isHidden: !product.savedToProfile,
|
|
isSelected: self.selectedItemIds.contains(itemReferenceId),
|
|
isPinned: !self.canSelect && product.pinnedToTop,
|
|
isEditing: self.isReordering && !self.isCollection,
|
|
mode: self.canSelect && !isAdded ? .select : .profile,
|
|
action: { [weak self] in
|
|
guard let self, !isAdded, let presentationData = self.currentParams?.presentationData else {
|
|
return
|
|
}
|
|
if self.canSelect {
|
|
if self.selectedItemIds.contains(itemReferenceId) {
|
|
self.selectedItemIds.remove(itemReferenceId)
|
|
} else {
|
|
if self.selectedItemIds.count < self.remainingSelectionCount {
|
|
self.selectedItemIds.insert(itemReferenceId)
|
|
self.selectedItemsMap[itemReferenceId] = product
|
|
}
|
|
}
|
|
self.selectionUpdated()
|
|
self.updateScrolling(transition: .easeInOut(duration: 0.25))
|
|
} else if self.isReordering {
|
|
if case .unique = product.gift, !product.pinnedToTop, let reference = product.reference, let items = self.starsProducts {
|
|
if self.pinnedReferences.count >= self.maxPinnedCount {
|
|
self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.PeerInfo_Gifts_ToastPinLimit_Text(Int32(self.maxPinnedCount)), timeout: nil, customUndoText: nil), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
|
|
return
|
|
}
|
|
|
|
var reorderedPinnedReferences = Set<StarGiftReference>()
|
|
if let current = self.reorderedPinnedReferences {
|
|
reorderedPinnedReferences = current
|
|
}
|
|
reorderedPinnedReferences.insert(reference)
|
|
self.reorderedPinnedReferences = reorderedPinnedReferences
|
|
|
|
if let maxPinnedIndex = items.lastIndex(where: { $0.pinnedToTop }) {
|
|
var reorderedReferences: [StarGiftReference]
|
|
if let current = self.reorderedReferences {
|
|
reorderedReferences = current
|
|
} else {
|
|
let ids = items.compactMap { item -> StarGiftReference? in
|
|
return item.reference
|
|
}
|
|
reorderedReferences = ids
|
|
}
|
|
reorderedReferences.removeAll(where: { $0 == reference })
|
|
reorderedReferences.insert(reference, at: maxPinnedIndex + 1)
|
|
self.reorderedReferences = reorderedReferences
|
|
}
|
|
}
|
|
} else {
|
|
let allSubjects: [GiftViewScreen.Subject] = (self.starsProducts ?? []).map { .profileGift(self.peerId, $0) }
|
|
let index = self.starsProducts?.firstIndex(where: { $0 == product }) ?? 0
|
|
|
|
var dismissImpl: (() -> Void)?
|
|
let controller = GiftViewScreen(
|
|
context: self.context,
|
|
subject: .profileGift(self.peerId, product),
|
|
allSubjects: allSubjects,
|
|
index: index,
|
|
updateSavedToProfile: { [weak self] reference, added in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.profileGifts.updateStarGiftAddedToProfile(reference: reference, added: added)
|
|
},
|
|
convertToStars: { [weak self] in
|
|
guard let self, let reference = product.reference else {
|
|
return
|
|
}
|
|
self.profileGifts.convertStarGift(reference: reference)
|
|
},
|
|
transferGift: { [weak self] prepaid, peerId in
|
|
guard let self, let reference = product.reference else {
|
|
return .complete()
|
|
}
|
|
return self.profileGifts.transferStarGift(prepaid: prepaid, reference: reference, peerId: peerId)
|
|
},
|
|
upgradeGift: { [weak self] formId, keepOriginalInfo in
|
|
guard let self, let reference = product.reference else {
|
|
return .never()
|
|
}
|
|
return self.profileGifts.upgradeStarGift(formId: formId, reference: reference, keepOriginalInfo: keepOriginalInfo)
|
|
},
|
|
buyGift: { [weak self] slug, peerId, price in
|
|
guard let self else {
|
|
return .never()
|
|
}
|
|
return self.profileGifts.buyStarGift(slug: slug, peerId: peerId, price: price)
|
|
},
|
|
updateResellStars: { [weak self] price in
|
|
guard let self, let reference = product.reference else {
|
|
return .never()
|
|
}
|
|
return self.profileGifts.updateStarGiftResellPrice(reference: reference, price: price)
|
|
},
|
|
togglePinnedToTop: { [weak self] pinnedToTop in
|
|
guard let self else {
|
|
return false
|
|
}
|
|
if let reference = product.reference {
|
|
if pinnedToTop && self.pinnedReferences.count >= self.maxPinnedCount {
|
|
// self.displayUnpinScreen(gift: product, completion: {
|
|
dismissImpl?()
|
|
// })
|
|
return false
|
|
}
|
|
self.profileGifts.updateStarGiftPinnedToTop(reference: reference, pinnedToTop: pinnedToTop)
|
|
|
|
var title = ""
|
|
if case let .unique(uniqueGift) = product.gift {
|
|
title = "\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, params.presentationData.dateTimeFormat.groupingSeparator))"
|
|
}
|
|
|
|
if pinnedToTop {
|
|
Queue.mainQueue().after(0.35) {
|
|
let toastTitle = params.presentationData.strings.PeerInfo_Gifts_ToastPinned_TitleNew(title).string
|
|
let toastText = params.presentationData.strings.PeerInfo_Gifts_ToastPinned_Text
|
|
self.parentController?.present(UndoOverlayController(presentationData: params.presentationData, content: .universal(animation: "anim_toastpin", scale: 0.06, colors: [:], title: toastTitle, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
shareStory: { [weak self] uniqueGift in
|
|
guard let self, let parentController = self.parentController else {
|
|
return
|
|
}
|
|
Queue.mainQueue().after(0.15) {
|
|
let controller = self.context.sharedContext.makeStorySharingScreen(context: self.context, subject: .gift(uniqueGift), parentController: parentController)
|
|
parentController.push(controller)
|
|
}
|
|
}
|
|
)
|
|
dismissImpl = { [weak controller] in
|
|
controller?.dismissAnimated()
|
|
}
|
|
self.parentController?.push(controller)
|
|
}
|
|
},
|
|
contextAction: self.isReordering || self.canSelect ? nil : { [weak self] view, gesture in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.contextAction?(product, view, gesture)
|
|
}
|
|
)
|
|
),
|
|
environment: {},
|
|
containerSize: starsOptionSize
|
|
)
|
|
if let itemView = visibleItem.view {
|
|
if itemView.superview == nil {
|
|
self.addSubview(itemView)
|
|
|
|
if !transition.animation.isImmediate {
|
|
itemView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.25)
|
|
itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
|
|
}
|
|
}
|
|
var itemFrame = itemFrame
|
|
var isReordering = false
|
|
if let reorderingItem = self.reorderingItem, itemId == reorderingItem.id {
|
|
itemFrame = itemFrame.size.centered(around: reorderingItem.position)
|
|
isReordering = true
|
|
}
|
|
if self.isReordering, itemView.layer.animation(forKey: "position") != nil && !isReordering {
|
|
} else {
|
|
itemTransition.setFrame(view: itemView, frame: itemFrame)
|
|
}
|
|
|
|
itemTransition.setAlpha(view: itemView, alpha: itemAlpha)
|
|
if itemAlpha < 1.0 {
|
|
itemView.layer.allowsGroupOpacity = true
|
|
}
|
|
|
|
if self.isReordering && (product.pinnedToTop || self.isCollection) {
|
|
if itemView.layer.animation(forKey: "shaking_position") == nil {
|
|
itemView.layer.addReorderingShaking()
|
|
}
|
|
} else {
|
|
if itemView.layer.animation(forKey: "shaking_position") != nil {
|
|
itemView.layer.removeAnimation(forKey: "shaking_position")
|
|
itemView.layer.removeAnimation(forKey: "shaking_rotation")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
itemFrame.origin.x += itemFrame.width + optionSpacing
|
|
if itemFrame.maxX > params.size.width {
|
|
itemFrame.origin.x = itemsSideInset
|
|
itemFrame.origin.y += starsOptionSize.height + optionSpacing
|
|
}
|
|
index += 1
|
|
}
|
|
|
|
var removeIds: [AnyHashable] = []
|
|
for (id, item) in self.starsItems {
|
|
if !validIds.contains(id) {
|
|
removeIds.append(id)
|
|
if let itemView = item.1.view {
|
|
if !transition.animation.isImmediate {
|
|
itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false)
|
|
itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
itemView.removeFromSuperview()
|
|
})
|
|
} else {
|
|
itemView.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for id in removeIds {
|
|
self.starsItems.removeValue(forKey: id)
|
|
}
|
|
|
|
var contentHeight = ceil(CGFloat(starsProducts.count) / CGFloat(defaultItemsInRow)) * (starsOptionSize.height + optionSpacing) - optionSpacing + topInset + 16.0
|
|
|
|
let size = params.size
|
|
let sideInset = params.sideInset
|
|
let bottomInset = params.bottomInset
|
|
let presentationData = params.presentationData
|
|
|
|
self.theme = presentationData.theme
|
|
|
|
let textFont = Font.regular(13.0)
|
|
let boldTextFont = Font.semibold(13.0)
|
|
let textColor = presentationData.theme.list.itemSecondaryTextColor
|
|
let linkColor = presentationData.theme.list.itemAccentColor
|
|
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in
|
|
return nil
|
|
})
|
|
|
|
let buttonSideInset = sideInset + 16.0
|
|
let buttonSize = CGSize(width: size.width - buttonSideInset * 2.0, height: 50.0)
|
|
let effectiveBottomInset = max(8.0, bottomInset)
|
|
let bottomPanelHeight = effectiveBottomInset + buttonSize.height + 8.0
|
|
let visibleHeight = params.visibleHeight
|
|
|
|
let panelTransition = ComponentTransition.immediate
|
|
let fadeTransition = ComponentTransition.easeInOut(duration: 0.25)
|
|
if self.resultsAreEmpty && self.isCollection {
|
|
let sideInset: CGFloat = 44.0
|
|
let topInset: CGFloat = 52.0
|
|
let emptyTextSpacing: CGFloat = 18.0
|
|
|
|
self.emptyResultsClippingView.isHidden = false
|
|
|
|
panelTransition.setFrame(view: self.emptyResultsClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size))
|
|
panelTransition.setBounds(view: self.emptyResultsClippingView, bounds: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size))
|
|
|
|
let emptyResultsTitleSize = self.emptyResultsTitle.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(
|
|
MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_EmptyCollection_Title, font: Font.semibold(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor)),
|
|
horizontalAlignment: .center
|
|
)
|
|
),
|
|
environment: {},
|
|
containerSize: CGSize(width: params.size.width - sideInset * 2.0, height: params.size.height)
|
|
)
|
|
let emptyResultsTextSize = self.emptyResultsText.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(
|
|
MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_EmptyCollection_Text, font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor)),
|
|
horizontalAlignment: .center
|
|
)
|
|
),
|
|
environment: {},
|
|
containerSize: CGSize(width: params.size.width - sideInset * 2.0, height: params.size.height)
|
|
)
|
|
let buttonAttributedString = NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_EmptyCollection_Action, font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center)
|
|
let emptyResultsActionSize = self.emptyResultsAction.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(
|
|
ButtonComponent(
|
|
background: ButtonComponent.Background(
|
|
color: presentationData.theme.list.itemCheckColors.fillColor,
|
|
foreground: presentationData.theme.list.itemCheckColors.foregroundColor,
|
|
pressedColor: presentationData.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8)
|
|
),
|
|
content: AnyComponentWithIdentity(
|
|
id: AnyHashable(buttonAttributedString.string),
|
|
component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString)))
|
|
),
|
|
isEnabled: true,
|
|
action: { [weak self] in
|
|
self?.addToCollection?()
|
|
}
|
|
)
|
|
),
|
|
environment: {},
|
|
containerSize: CGSize(width: 240.0, height: 50.0)
|
|
)
|
|
|
|
let emptyTotalHeight = emptyResultsTitleSize.height + emptyTextSpacing + emptyResultsTextSize.height + emptyTextSpacing + emptyResultsActionSize.height
|
|
let emptyTitleY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0)
|
|
|
|
let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTitleSize.width) / 2.0), y: emptyTitleY), size: emptyResultsTitleSize)
|
|
let emptyResultsTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTextSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsTextSize)
|
|
let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTextFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize)
|
|
|
|
if let view = self.emptyResultsTitle.view {
|
|
if view.superview == nil {
|
|
view.alpha = 0.0
|
|
fadeTransition.setAlpha(view: view, alpha: 1.0)
|
|
self.emptyResultsClippingView.addSubview(view)
|
|
}
|
|
view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size)
|
|
panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center)
|
|
}
|
|
if let view = self.emptyResultsText.view {
|
|
if view.superview == nil {
|
|
view.alpha = 0.0
|
|
fadeTransition.setAlpha(view: view, alpha: 1.0)
|
|
self.emptyResultsClippingView.addSubview(view)
|
|
}
|
|
view.bounds = CGRect(origin: .zero, size: emptyResultsTextFrame.size)
|
|
panelTransition.setPosition(view: view, position: emptyResultsTextFrame.center)
|
|
}
|
|
if let view = self.emptyResultsAction.view {
|
|
if view.superview == nil {
|
|
view.alpha = 0.0
|
|
fadeTransition.setAlpha(view: view, alpha: 1.0)
|
|
self.emptyResultsClippingView.addSubview(view)
|
|
}
|
|
view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size)
|
|
panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center)
|
|
}
|
|
} else if self.filteredResultsAreEmpty {
|
|
let sideInset: CGFloat = 44.0
|
|
let emptyAnimationHeight = 148.0
|
|
let topInset: CGFloat = 0.0
|
|
let bottomInset: CGFloat = bottomPanelHeight
|
|
let emptyAnimationSpacing: CGFloat = 20.0
|
|
let emptyTextSpacing: CGFloat = 18.0
|
|
|
|
self.emptyResultsClippingView.isHidden = false
|
|
|
|
panelTransition.setFrame(view: self.emptyResultsClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size))
|
|
panelTransition.setBounds(view: self.emptyResultsClippingView, bounds: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size))
|
|
|
|
let emptyResultsTitleSize = self.emptyResultsTitle.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(
|
|
MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_NoResults, font: Font.semibold(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor)),
|
|
horizontalAlignment: .center
|
|
)
|
|
),
|
|
environment: {},
|
|
containerSize: params.size
|
|
)
|
|
let emptyResultsActionSize = self.emptyResultsAction.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(
|
|
PlainButtonComponent(
|
|
content: AnyComponent(
|
|
MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_NoResults_ViewAll, font: Font.regular(17.0), textColor: presentationData.theme.list.itemAccentColor)),
|
|
horizontalAlignment: .center,
|
|
maximumNumberOfLines: 0
|
|
)
|
|
),
|
|
effectAlignment: .center,
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.profileGifts.updateFilter(.All)
|
|
},
|
|
animateScale: false
|
|
)
|
|
),
|
|
environment: {},
|
|
containerSize: CGSize(width: params.size.width - sideInset * 2.0, height: visibleHeight)
|
|
)
|
|
let emptyResultsAnimationSize = self.emptyResultsAnimation.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(LottieComponent(
|
|
content: LottieComponent.AppBundleContent(name: "ChatListNoResults")
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: emptyAnimationHeight, height: emptyAnimationHeight)
|
|
)
|
|
|
|
let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyResultsTitleSize.height + emptyResultsActionSize.height + emptyTextSpacing
|
|
let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0)
|
|
|
|
let emptyResultsAnimationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsAnimationSize.width) / 2.0), y: emptyAnimationY), size: emptyResultsAnimationSize)
|
|
|
|
let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTitleSize.width) / 2.0), y: emptyResultsAnimationFrame.maxY + emptyAnimationSpacing), size: emptyResultsTitleSize)
|
|
|
|
let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize)
|
|
|
|
if let view = self.emptyResultsAnimation.view as? LottieComponent.View {
|
|
if view.superview == nil {
|
|
view.alpha = 0.0
|
|
fadeTransition.setAlpha(view: view, alpha: 1.0)
|
|
self.emptyResultsClippingView.addSubview(view)
|
|
view.playOnce()
|
|
}
|
|
view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size)
|
|
panelTransition.setPosition(view: view, position: emptyResultsAnimationFrame.center)
|
|
}
|
|
if let view = self.emptyResultsTitle.view {
|
|
if view.superview == nil {
|
|
view.alpha = 0.0
|
|
fadeTransition.setAlpha(view: view, alpha: 1.0)
|
|
self.emptyResultsClippingView.addSubview(view)
|
|
}
|
|
view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size)
|
|
panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center)
|
|
}
|
|
if let view = self.emptyResultsAction.view {
|
|
if view.superview == nil {
|
|
view.alpha = 0.0
|
|
fadeTransition.setAlpha(view: view, alpha: 1.0)
|
|
self.emptyResultsClippingView.addSubview(view)
|
|
}
|
|
view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size)
|
|
panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center)
|
|
}
|
|
} else {
|
|
if let view = self.emptyResultsAnimation.view {
|
|
fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in
|
|
view.removeFromSuperview()
|
|
})
|
|
}
|
|
if let view = self.emptyResultsTitle.view {
|
|
fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in
|
|
self.emptyResultsClippingView.isHidden = true
|
|
view.removeFromSuperview()
|
|
})
|
|
}
|
|
if let view = self.emptyResultsText.view {
|
|
fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in
|
|
view.removeFromSuperview()
|
|
})
|
|
}
|
|
if let view = self.emptyResultsAction.view {
|
|
fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in
|
|
view.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
|
|
fadeTransition.setAlpha(view: self.emptyResultsClippingView, alpha: visibleHeight < 300.0 ? 0.0 : 1.0)
|
|
|
|
if self.peerId == self.context.account.peerId, !self.canSelect && !self.filteredResultsAreEmpty && self.profileGifts.collectionId == nil {
|
|
let footerText: ComponentView<Empty>
|
|
if let current = self.footerText {
|
|
footerText = current
|
|
} else {
|
|
footerText = ComponentView<Empty>()
|
|
self.footerText = footerText
|
|
}
|
|
let footerTextSize = footerText.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(
|
|
BalancedTextComponent(
|
|
text: .markdown(text: presentationData.strings.PeerInfo_Gifts_Info, attributes: markdownAttributes),
|
|
horizontalAlignment: .center,
|
|
maximumNumberOfLines: 0,
|
|
lineSpacing: 0.2
|
|
)
|
|
),
|
|
environment: {},
|
|
containerSize: CGSize(width: size.width - 32.0, height: 200.0)
|
|
)
|
|
if let view = footerText.view {
|
|
if view.superview == nil {
|
|
self.addSubview(view)
|
|
}
|
|
transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: floor((size.width - footerTextSize.width) / 2.0), y: contentHeight), size: footerTextSize))
|
|
}
|
|
contentHeight += footerTextSize.height
|
|
}
|
|
|
|
return contentHeight
|
|
}
|
|
|
|
func update(size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, visibleBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGFloat {
|
|
self.currentParams = (size, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData)
|
|
self.presentationDataPromise.set(.single(presentationData))
|
|
|
|
return self.updateScrolling(topInset: self.topInset ?? 0.0, visibleBounds: visibleBounds, transition: ComponentTransition(transition))
|
|
}
|
|
}
|
|
|
|
private extension StarGiftReference {
|
|
var stringValue: String {
|
|
switch self {
|
|
case let .message(messageId):
|
|
return "m_\(messageId.id)"
|
|
case let .peer(peerId, id):
|
|
return "p_\(peerId.toInt64())_\(id)"
|
|
case let .slug(slug):
|
|
return "s_\(slug)"
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private final class ReorderGestureRecognizer: UIGestureRecognizer {
|
|
private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?)
|
|
private let willBegin: (CGPoint) -> Void
|
|
private let began: (AnyHashable) -> Void
|
|
private let ended: () -> Void
|
|
private let moved: (CGPoint) -> Void
|
|
private let isActiveUpdated: (Bool) -> Void
|
|
|
|
private var initialLocation: CGPoint?
|
|
private var longTapTimer: SwiftSignalKit.Timer?
|
|
private var longPressTimer: SwiftSignalKit.Timer?
|
|
|
|
private var id: AnyHashable?
|
|
private var itemView: ComponentView<Empty>?
|
|
|
|
init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (AnyHashable) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) {
|
|
self.shouldBegin = shouldBegin
|
|
self.willBegin = willBegin
|
|
self.began = began
|
|
self.ended = ended
|
|
self.moved = moved
|
|
self.isActiveUpdated = isActiveUpdated
|
|
|
|
super.init(target: nil, action: nil)
|
|
}
|
|
|
|
deinit {
|
|
self.longTapTimer?.invalidate()
|
|
self.longPressTimer?.invalidate()
|
|
}
|
|
|
|
private func startLongTapTimer() {
|
|
self.longTapTimer?.invalidate()
|
|
let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in
|
|
self?.longTapTimerFired()
|
|
}, queue: Queue.mainQueue())
|
|
self.longTapTimer = longTapTimer
|
|
longTapTimer.start()
|
|
}
|
|
|
|
private func stopLongTapTimer() {
|
|
self.itemView = nil
|
|
self.longTapTimer?.invalidate()
|
|
self.longTapTimer = nil
|
|
}
|
|
|
|
private func startLongPressTimer() {
|
|
self.longPressTimer?.invalidate()
|
|
let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in
|
|
self?.longPressTimerFired()
|
|
}, queue: Queue.mainQueue())
|
|
self.longPressTimer = longPressTimer
|
|
longPressTimer.start()
|
|
}
|
|
|
|
private func stopLongPressTimer() {
|
|
self.itemView = nil
|
|
self.longPressTimer?.invalidate()
|
|
self.longPressTimer = nil
|
|
}
|
|
|
|
override func reset() {
|
|
super.reset()
|
|
|
|
self.itemView = nil
|
|
self.stopLongTapTimer()
|
|
self.stopLongPressTimer()
|
|
self.initialLocation = nil
|
|
|
|
self.isActiveUpdated(false)
|
|
}
|
|
|
|
private func longTapTimerFired() {
|
|
guard let location = self.initialLocation else {
|
|
return
|
|
}
|
|
|
|
self.longTapTimer?.invalidate()
|
|
self.longTapTimer = nil
|
|
|
|
self.willBegin(location)
|
|
}
|
|
|
|
private func longPressTimerFired() {
|
|
guard let _ = self.initialLocation else {
|
|
return
|
|
}
|
|
|
|
self.isActiveUpdated(true)
|
|
self.state = .began
|
|
self.longPressTimer?.invalidate()
|
|
self.longPressTimer = nil
|
|
self.longTapTimer?.invalidate()
|
|
self.longTapTimer = nil
|
|
if let id = self.id {
|
|
self.began(id)
|
|
}
|
|
self.isActiveUpdated(true)
|
|
}
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesBegan(touches, with: event)
|
|
|
|
if self.numberOfTouches > 1 {
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
self.ended()
|
|
return
|
|
}
|
|
|
|
if self.state == .possible {
|
|
if let location = touches.first?.location(in: self.view) {
|
|
let (allowed, requiresLongPress, id, itemView) = self.shouldBegin(location)
|
|
if allowed {
|
|
self.isActiveUpdated(true)
|
|
|
|
self.id = id
|
|
self.itemView = itemView
|
|
self.initialLocation = location
|
|
if requiresLongPress {
|
|
self.startLongTapTimer()
|
|
self.startLongPressTimer()
|
|
} else {
|
|
self.state = .began
|
|
if let id = self.id {
|
|
self.began(id)
|
|
}
|
|
}
|
|
} else {
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
} else {
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
}
|
|
}
|
|
|
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesEnded(touches, with: event)
|
|
|
|
self.initialLocation = nil
|
|
|
|
self.stopLongTapTimer()
|
|
if self.longPressTimer != nil {
|
|
self.stopLongPressTimer()
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
if self.state == .began || self.state == .changed {
|
|
self.isActiveUpdated(false)
|
|
self.ended()
|
|
self.state = .failed
|
|
}
|
|
}
|
|
|
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesCancelled(touches, with: event)
|
|
|
|
self.initialLocation = nil
|
|
|
|
self.stopLongTapTimer()
|
|
if self.longPressTimer != nil {
|
|
self.isActiveUpdated(false)
|
|
self.stopLongPressTimer()
|
|
self.state = .failed
|
|
}
|
|
if self.state == .began || self.state == .changed {
|
|
self.isActiveUpdated(false)
|
|
self.ended()
|
|
self.state = .failed
|
|
}
|
|
}
|
|
|
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesMoved(touches, with: event)
|
|
|
|
if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) {
|
|
self.state = .changed
|
|
let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y)
|
|
self.moved(offset)
|
|
} else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil {
|
|
let touchLocation = touch.location(in: self.view)
|
|
let dX = touchLocation.x - initialTapLocation.x
|
|
let dY = touchLocation.y - initialTapLocation.y
|
|
|
|
if dX * dX + dY * dY > 3.0 * 3.0 {
|
|
self.stopLongTapTimer()
|
|
self.stopLongPressTimer()
|
|
self.initialLocation = nil
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
}
|
|
}
|
|
}
|