mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-08-23 14:05:33 +00:00
Story folders
This commit is contained in:
parent
817308f13e
commit
a6544e5d57
@ -654,13 +654,16 @@ public final class ContextControllerActionsListActionItemNode: HighlightTracking
|
||||
}
|
||||
|
||||
if let additionalIconSize {
|
||||
let iconFrame = CGRect(
|
||||
var iconFrame = CGRect(
|
||||
origin: CGPoint(
|
||||
x: 10.0,
|
||||
y: floor((size.height - additionalIconSize.height) / 2.0)
|
||||
),
|
||||
size: additionalIconSize
|
||||
)
|
||||
if self.item.iconPosition == .left {
|
||||
iconFrame.origin.x = size.width - additionalIconSize.width - 10.0
|
||||
}
|
||||
transition.updateFrame(node: self.additionalIconNode, frame: iconFrame, beginWithCurrentState: true)
|
||||
}
|
||||
})
|
||||
|
@ -672,6 +672,7 @@ final class ContextSourceContainer: ASDisplayNode {
|
||||
foreground: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.8),
|
||||
selection: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1)
|
||||
),
|
||||
theme: presentationData.theme,
|
||||
customLayout: TabSelectorComponent.CustomLayout(
|
||||
font: Font.medium(14.0),
|
||||
spacing: 9.0
|
||||
|
@ -152,6 +152,7 @@ final class HashtagSearchNavigationContentNode: NavigationBarContentNode {
|
||||
foreground: self.theme.list.itemSecondaryTextColor,
|
||||
selection: self.theme.list.itemAccentColor
|
||||
),
|
||||
theme: self.theme,
|
||||
customLayout: TabSelectorComponent.CustomLayout(
|
||||
font: Font.medium(14.0),
|
||||
spacing: self.hasCurrentChat ? 24.0 : 8.0,
|
||||
|
@ -82,6 +82,7 @@ public final class ItemListControllerSegmentedTitleView: UIView {
|
||||
foreground: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8),
|
||||
selection: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05)
|
||||
),
|
||||
theme: self.theme,
|
||||
customLayout: TabSelectorComponent.CustomLayout(
|
||||
font: Font.medium(15.0),
|
||||
spacing: 8.0
|
||||
|
@ -84,6 +84,7 @@ final class ItemListControllerTabsContentNode: NavigationBarContentNode {
|
||||
foreground: self.theme.list.itemSecondaryTextColor,
|
||||
selection: self.theme.list.itemAccentColor
|
||||
),
|
||||
theme: self.theme,
|
||||
customLayout: TabSelectorComponent.CustomLayout(
|
||||
font: Font.medium(14.0),
|
||||
spacing: 48.0,
|
||||
|
@ -1424,6 +1424,37 @@ public final class PeerStoryListContext: StoryListContext {
|
||||
}).start()
|
||||
}
|
||||
|
||||
func reorderFolders(ids: [Int64]) {
|
||||
var state = self.stateValue
|
||||
var folders: [State.Folder] = []
|
||||
for id in ids {
|
||||
if !folders.contains(where: { $0.id == id }), let folder = state.availableFolders.first(where: { $0.id == id }) {
|
||||
folders.append(folder)
|
||||
}
|
||||
}
|
||||
for folder in state.availableFolders {
|
||||
if !folders.contains(where: { $0.id == folder.id }) {
|
||||
folders.append(folder)
|
||||
}
|
||||
}
|
||||
state.availableFolders = folders
|
||||
self.stateValue = state
|
||||
|
||||
let peerId = self.peerId
|
||||
let isArchived = self.isArchived
|
||||
let items = state.items
|
||||
let pinnedIds = state.pinnedIds
|
||||
let totalCount = state.totalCount
|
||||
let _ = (self.account.postbox.transaction { transaction -> Void in
|
||||
let key = ValueBoxKey(length: 8 + 1)
|
||||
key.setInt64(0, value: peerId.toInt64())
|
||||
key.setInt8(8, value: isArchived ? 1 : 0)
|
||||
if let entry = CodableEntry(CachedPeerStoryListHead(items: items.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: Int32(totalCount), folders: folders)) {
|
||||
transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry)
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
|
||||
func addToFolder(id: Int64, items: [EngineStoryItem]) {
|
||||
let peerId = self.peerId
|
||||
let _ = (self.account.postbox.transaction { transaction -> Void in
|
||||
@ -1657,6 +1688,12 @@ public final class PeerStoryListContext: StoryListContext {
|
||||
}
|
||||
}
|
||||
|
||||
public func reorderFolders(ids: [Int64]) {
|
||||
self.impl.with { impl in
|
||||
impl.reorderFolders(ids: ids)
|
||||
}
|
||||
}
|
||||
|
||||
public func addToFolder(id: Int64, items: [EngineStoryItem]) {
|
||||
self.impl.with { impl in
|
||||
impl.addToFolder(id: id, items: items)
|
||||
@ -1675,6 +1712,27 @@ public final class PeerStoryListContext: StoryListContext {
|
||||
}
|
||||
}
|
||||
|
||||
public static func addFolderExternal(account: Account, peerId: PeerId, title: String) -> Int64 {
|
||||
let id = Int64.random(in: Int64.min ... Int64.max)
|
||||
|
||||
let _ = (account.postbox.transaction { transaction -> Void in
|
||||
let key = ValueBoxKey(length: 8 + 1)
|
||||
key.setInt64(0, value: peerId.toInt64())
|
||||
key.setInt8(8, value: 0)
|
||||
|
||||
|
||||
let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self)
|
||||
var folders = cached?.folders ?? []
|
||||
folders.append(StoryListContextState.Folder(id: id, title: title))
|
||||
|
||||
if let entry = CodableEntry(CachedPeerStoryListHead(items: cached?.items ?? [], pinnedIds: cached?.pinnedIds ?? [], totalCount: cached?.totalCount ?? 0, folders: folders)) {
|
||||
transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry)
|
||||
}
|
||||
}).start()
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
public static func folderPreviews(peerId: EnginePeer.Id, account: Account) -> Signal<(peer: PeerReference, [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)]), NoError> {
|
||||
return account.postbox.transaction { transaction -> (peer: PeerReference, [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)]) in
|
||||
let key = ValueBoxKey(length: 8 + 1)
|
||||
|
@ -1382,6 +1382,7 @@ final class GiftOptionsScreenComponent: Component {
|
||||
selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15),
|
||||
simple: true
|
||||
),
|
||||
theme: theme,
|
||||
items: tabSelectorItems,
|
||||
selectedId: AnyHashable(self.starsFilter.rawValue),
|
||||
setSelectedId: { [weak self] id in
|
||||
|
@ -271,7 +271,7 @@ public final class PeerInfoRatingComponent: Component {
|
||||
transition.setFrame(view: shimmerEffectView, frame: backgroundFrame)
|
||||
alphaTransition.setAlpha(view: shimmerEffectView, alpha: 1.0)
|
||||
|
||||
shimmerEffectView.update(color: .clear, borderColor: component.foregroundColor, rect: CGRect(origin: CGPoint(), size: backgroundFrame.size), path: UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: backgroundFrame.size), cornerRadius: backgroundFrame.height * 0.5).cgPath, transition: shimmerEffectTransition.containedViewLayoutTransition)
|
||||
shimmerEffectView.update(color: .clear, borderColor: component.foregroundColor, rect: CGRect(origin: CGPoint(), size: backgroundFrame.size), path: UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: backgroundFrame.size).insetBy(dx: 1.0, dy: 1.0), cornerRadius: backgroundFrame.height * 0.5).cgPath, transition: shimmerEffectTransition.containedViewLayoutTransition)
|
||||
} else if let shimmerEffectView = self.shimmerEffectView {
|
||||
self.shimmerEffectView = nil
|
||||
|
||||
@ -325,7 +325,7 @@ public final class PeerInfoRatingComponent: Component {
|
||||
tooltipController.view.isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
transition.setFrame(view: tooltipController.view, frame: CGRect(origin: CGPoint(), size: CGSize(width: 200.0, height: 200.0)).offsetBy(dx: -200.0 * 0.5 + foregroundFrame.width - 7.0, dy: -200.0 * 0.5))
|
||||
transition.setFrame(view: tooltipController.view, frame: CGRect(origin: CGPoint(), size: CGSize(width: 200.0, height: 200.0)).offsetBy(dx: -200.0 * 0.5 + foregroundFrame.width + 2.0, dy: -200.0 * 0.5))
|
||||
alphaTransition.setAlpha(view: tooltipController.view, alpha: component.isExpanded ? 1.0 : 0.0)
|
||||
|
||||
return size
|
||||
|
@ -39,6 +39,7 @@ import PeerInfoCoverComponent
|
||||
import PeerInfoPaneNode
|
||||
import MultilineTextComponent
|
||||
import PeerInfoRatingComponent
|
||||
import UndoUI
|
||||
|
||||
final class PeerInfoHeaderNavigationTransition {
|
||||
let sourceNavigationBar: NavigationBar
|
||||
@ -132,6 +133,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
|
||||
var titleExpandedStatusIconSize: CGSize?
|
||||
|
||||
var subtitleRatingIsExpanded: Bool = false
|
||||
var didDisplayRatingTooltip: Bool = false
|
||||
var subtitleRating: ComponentView<Empty>?
|
||||
|
||||
let subtitleNodeContainer: ASDisplayNode
|
||||
@ -1973,6 +1975,23 @@ final class PeerInfoHeaderNode: ASDisplayNode {
|
||||
}
|
||||
self.subtitleRatingIsExpanded = !self.subtitleRatingIsExpanded
|
||||
self.requestUpdateLayout?(true)
|
||||
|
||||
if self.subtitleRatingIsExpanded, let controller = self.controller, let presentationData = self.presentationData, !self.didDisplayRatingTooltip {
|
||||
self.didDisplayRatingTooltip = true
|
||||
controller.presentInGlobalOverlay(UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .info(
|
||||
title: nil,
|
||||
text: "Profile level reflects the user's payment reliability",
|
||||
timeout: 4.0,
|
||||
customUndoText: "Learn More"
|
||||
),
|
||||
position: .top,
|
||||
action: { _ in
|
||||
return true
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
)),
|
||||
environment: {},
|
||||
|
@ -3140,7 +3140,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
self.updateSelectedItems(animated: false)
|
||||
|
||||
if let gridSnapshot {
|
||||
self.view.insertSubview(gridSnapshot, aboveSubview: self.itemGrid.view)
|
||||
self.view.insertSubview(gridSnapshot, aboveSubview: self.contextGestureContainerNode.view)
|
||||
gridSnapshot.frame = self.itemGrid.frame
|
||||
if let animateDirection {
|
||||
let directionFactor: CGFloat = animateDirection ? 1.0 : -1.0
|
||||
@ -3157,7 +3157,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
}
|
||||
}
|
||||
if let emptyStateSnapshot, let animateDirection {
|
||||
self.view.insertSubview(emptyStateSnapshot, belowSubview: self.itemGrid.view)
|
||||
self.view.insertSubview(emptyStateSnapshot, belowSubview: self.contextGestureContainerNode.view)
|
||||
let directionFactor: CGFloat = animateDirection ? 1.0 : -1.0
|
||||
|
||||
let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring)
|
||||
@ -3812,7 +3812,75 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
for folder in self.currentStoryFolders {
|
||||
folderItems.append(TabSelectorComponent.Item(
|
||||
id: AnyHashable(folder.id),
|
||||
title: folder.title
|
||||
title: folder.title,
|
||||
isReorderable: self.canManageStories,
|
||||
contextAction: self.canManageStories ? { [weak self] sourceNode, gesture in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
guard let sourceNode = sourceNode as? ContextExtractedContentContainingNode else {
|
||||
return
|
||||
}
|
||||
guard let controller = self.parentController else {
|
||||
return
|
||||
}
|
||||
|
||||
var items: [ContextMenuItem] = []
|
||||
|
||||
//TODO:localize
|
||||
items.append(.action(ContextMenuActionItem(text: "Add Stories", icon: { theme in
|
||||
return generateTintedImage(image: UIImage(bundleImageName: "Chat List/AddStoryIcon"), color: theme.contextMenu.primaryColor)
|
||||
}, action: { [weak self] _, a in
|
||||
guard let self else {
|
||||
a(.default)
|
||||
return
|
||||
}
|
||||
|
||||
a(.default)
|
||||
|
||||
self.presentAddStoriesToFolder()
|
||||
})))
|
||||
|
||||
//TODO:localize
|
||||
items.append(.action(ContextMenuActionItem(text: "Reorder", icon: { theme in
|
||||
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor)
|
||||
}, action: { [weak self] _, a in
|
||||
guard let self else {
|
||||
a(.default)
|
||||
return
|
||||
}
|
||||
|
||||
a(.default)
|
||||
|
||||
self.beginReordering()
|
||||
})))
|
||||
|
||||
//TODO:localize
|
||||
items.append(.action(ContextMenuActionItem(text: "Delete Album", textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] _, f in
|
||||
guard let self else {
|
||||
f(.default)
|
||||
return
|
||||
}
|
||||
|
||||
f(.dismissWithoutContent)
|
||||
|
||||
self.presentDeleteStoryFolder(id: folder.id)
|
||||
})))
|
||||
|
||||
let presentationData = self.presentationData
|
||||
let contextController = ContextController(
|
||||
presentationData: presentationData,
|
||||
source: .extracted(ItemExtractedContentSource(
|
||||
sourceNode: sourceNode,
|
||||
containerView: controller.view,
|
||||
keepInPlace: false
|
||||
)),
|
||||
items: .single(ContextController.Items(content: .list(items))),
|
||||
recognizer: nil,
|
||||
gesture: gesture
|
||||
)
|
||||
controller.presentInGlobalOverlay(contextController)
|
||||
} : nil
|
||||
))
|
||||
}
|
||||
}
|
||||
@ -3834,6 +3902,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
foreground: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8),
|
||||
selection: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05)
|
||||
),
|
||||
theme: self.presentationData.theme,
|
||||
customLayout: TabSelectorComponent.CustomLayout(
|
||||
font: Font.medium(14.0),
|
||||
spacing: 9.0,
|
||||
@ -3841,6 +3910,30 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
),
|
||||
items: folderItems,
|
||||
selectedId: selectedId,
|
||||
reorderItem: self.isReordering ? { [weak self] fromId, toId in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
guard let sourceId = fromId.base as? Int64 else {
|
||||
return
|
||||
}
|
||||
guard let targetId = toId.base as? Int64 else {
|
||||
return
|
||||
}
|
||||
guard let sourceIndex = self.currentStoryFolders.firstIndex(where: { $0.id == sourceId }), let targetIndex = self.currentStoryFolders.firstIndex(where: { $0.id == targetId }) else {
|
||||
return
|
||||
}
|
||||
let folder = self.currentStoryFolders[sourceIndex]
|
||||
if targetIndex < sourceIndex {
|
||||
self.currentStoryFolders.remove(at: sourceIndex)
|
||||
self.currentStoryFolders.insert(folder, at: targetIndex)
|
||||
} else {
|
||||
self.currentStoryFolders.insert(folder, at: targetIndex + 1)
|
||||
self.currentStoryFolders.remove(at: sourceIndex)
|
||||
}
|
||||
|
||||
self.update(transition: .animated(duration: 0.2, curve: .easeInOut))
|
||||
} : nil,
|
||||
setSelectedId: { [weak self] id in
|
||||
guard let self else {
|
||||
return
|
||||
@ -3887,8 +3980,23 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
self.view.addSubview(folderTabView)
|
||||
}
|
||||
transition.updateFrame(view: folderTabView, frame: folderTabFrame)
|
||||
transition.updateAlpha(layer: folderTabView.layer, alpha: isSelectingOrReordering ? 0.5 : 1.0)
|
||||
folderTabView.isUserInteractionEnabled = !isSelectingOrReordering
|
||||
|
||||
var areTabsDisabled = false
|
||||
|
||||
if case .botPreview = self.scope {
|
||||
if isSelectingOrReordering {
|
||||
areTabsDisabled = true
|
||||
}
|
||||
} else {
|
||||
if self.itemInteraction.selectedIds != nil {
|
||||
areTabsDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
transition.updateAlpha(layer: folderTabView.layer, alpha: areTabsDisabled ? 0.5 : 1.0)
|
||||
folderTabView.isUserInteractionEnabled = !areTabsDisabled
|
||||
|
||||
folderTabView.disablesInteractiveTransitionGestureRecognizer = self.isReordering
|
||||
}
|
||||
}
|
||||
|
||||
@ -4033,7 +4141,12 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
}
|
||||
|
||||
if displayFolderTab {
|
||||
updateFolderTab(size: size, topInset: topInset, transition: transition)
|
||||
var folderTabsTransition = transition
|
||||
if animateBottomPanel && !folderTabsTransition.isAnimated {
|
||||
folderTabsTransition = .animated(duration: 0.4, curve: .spring)
|
||||
}
|
||||
|
||||
updateFolderTab(size: size, topInset: topInset, transition: folderTabsTransition)
|
||||
gridTopInset += 50.0
|
||||
|
||||
if case .botPreview = self.scope {
|
||||
@ -4828,17 +4941,28 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
|
||||
public func presentDeleteCurrentStoryFolder() {
|
||||
if let folder = self.currentStoryFolder {
|
||||
self.presentDeleteStoryFolder(id: folder.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentDeleteStoryFolder(id: Int64) {
|
||||
guard let folder = self.currentStoryFolders.first(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
let _ = folder
|
||||
|
||||
if self.currentStoryFolder?.id == id {
|
||||
self.setStoryFolder(id: nil, assumeEmpty: false)
|
||||
self.currentStoryFolders.removeAll(where: { $0.id == folder.id })
|
||||
self.removedStoryFolders.insert(folder.id)
|
||||
|
||||
if let listContext = self.listSource as? PeerStoryListContext {
|
||||
listContext.removeFolder(id: folder.id)
|
||||
}
|
||||
|
||||
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
||||
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate)
|
||||
}
|
||||
}
|
||||
self.currentStoryFolders.removeAll(where: { $0.id == id })
|
||||
self.removedStoryFolders.insert(id)
|
||||
|
||||
if let listContext = self.listSource as? PeerStoryListContext {
|
||||
listContext.removeFolder(id: id)
|
||||
}
|
||||
|
||||
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
||||
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4985,6 +5109,12 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
|
||||
|
||||
self.itemGrid.setReordering(isReordering: isReordering)
|
||||
|
||||
if !isReordering && !self.currentStoryFolders.isEmpty && self.canManageStories {
|
||||
if let listSource = self.listSource as? PeerStoryListContext {
|
||||
listSource.reorderFolders(ids: self.currentStoryFolders.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
if !isReordering, let reorderedIds = self.reorderedIds {
|
||||
self.reorderedIds = nil
|
||||
if case .botPreview = self.scope, let listSource = self.listSource as? BotPreviewStoryListContext {
|
||||
@ -5377,3 +5507,34 @@ private final class BottomActionsPanelComponent: Component {
|
||||
}
|
||||
}
|
||||
|
||||
private final class ItemExtractedContentSource: ContextExtractedContentSource {
|
||||
let keepInPlace: Bool
|
||||
let ignoreContentTouches: Bool = true
|
||||
let blurBackground: Bool = true
|
||||
let adjustContentForSideInset: Bool = true
|
||||
|
||||
private let sourceNode: ContextExtractedContentContainingNode
|
||||
private weak var containerView: UIView?
|
||||
|
||||
init(sourceNode: ContextExtractedContentContainingNode, containerView: UIView, keepInPlace: Bool) {
|
||||
self.sourceNode = sourceNode
|
||||
self.containerView = containerView
|
||||
self.keepInPlace = keepInPlace
|
||||
}
|
||||
|
||||
func takeView() -> ContextControllerTakeViewInfo? {
|
||||
var contentArea: CGRect?
|
||||
if let containerView = self.containerView {
|
||||
contentArea = containerView.convert(containerView.bounds, to: nil)
|
||||
}
|
||||
|
||||
return ContextControllerTakeViewInfo(
|
||||
containingItem: .node(self.sourceNode),
|
||||
contentAreaInScreenSpace: contentArea ?? UIScreen.main.bounds
|
||||
)
|
||||
}
|
||||
|
||||
func putBack() -> ContextControllerPutBackViewInfo? {
|
||||
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,10 @@ swift_library(
|
||||
"//submodules/UndoUI",
|
||||
"//submodules/TemporaryCachedPeerDataManager",
|
||||
"//submodules/CountrySelectionUI",
|
||||
"//submodules/TelegramUI/Components/ListActionItemComponent",
|
||||
"//submodules/ContextUI",
|
||||
"//submodules/PromptUI",
|
||||
"//submodules/DirectMediaImageCache",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
@ -24,6 +24,11 @@ import Markdown
|
||||
import TelegramUIPreferences
|
||||
import UndoUI
|
||||
import TelegramStringFormatting
|
||||
import ListActionItemComponent
|
||||
import ContextUI
|
||||
import BundleIconComponent
|
||||
import PromptUI
|
||||
import DirectMediaImageCache
|
||||
|
||||
final class ShareWithPeersScreenComponent: Component {
|
||||
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
||||
@ -349,6 +354,8 @@ final class ShareWithPeersScreenComponent: Component {
|
||||
private var selectedCategories = Set<CategoryId>()
|
||||
private var selectedOptions = Set<OptionId>()
|
||||
|
||||
private var shareToFolders: [StoryListContext.State.Folder] = []
|
||||
|
||||
private var component: ShareWithPeersScreenComponent?
|
||||
private weak var state: EmptyComponentState?
|
||||
private var environment: ViewControllerComponentContainer.Environment?
|
||||
@ -877,6 +884,139 @@ final class ShareWithPeersScreenComponent: Component {
|
||||
}
|
||||
}
|
||||
|
||||
private func displayFolderSelectionMenu(sourceView: UIView) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, let component = self.component, let environment = self.environment, let controller = environment.controller() else {
|
||||
return
|
||||
}
|
||||
|
||||
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
|
||||
|
||||
let (peerReference, folderPreviews) = await PeerStoryListContext.folderPreviews(peerId: component.context.account.peerId, account: component.context.account).get()
|
||||
|
||||
var items: [ContextMenuItem] = []
|
||||
|
||||
items.append(.action(ContextMenuActionItem(text: "New Album", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Folder"), color: theme.contextMenu.primaryColor) }, iconPosition: .left, action: { [weak self] c, f in
|
||||
guard let self else {
|
||||
f(.default)
|
||||
return
|
||||
}
|
||||
|
||||
c?.dismiss(completion: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.presentAddStoryFolder()
|
||||
})
|
||||
})))
|
||||
|
||||
for folderPreview in folderPreviews {
|
||||
var iconSource: ContextMenuActionItemIconSource?
|
||||
if let story = folderPreview.item {
|
||||
var imageSignal: Signal<UIImage?, NoError>?
|
||||
|
||||
var selectedMedia: Media?
|
||||
if let image = story.media._asMedia() as? TelegramMediaImage {
|
||||
selectedMedia = image
|
||||
} else if let file = story.media._asMedia() as? TelegramMediaFile {
|
||||
selectedMedia = file
|
||||
}
|
||||
|
||||
if let selectedMedia {
|
||||
let directMediaImageCache = DirectMediaImageCache(account: component.context.account)
|
||||
if let result = directMediaImageCache.getImage(peer: peerReference, story: story, media: selectedMedia, width: 24, aspectRatio: 1.0, possibleWidths: [24], includeBlurred: false, synchronous: true) {
|
||||
if let loadSignal = result.loadSignal {
|
||||
imageSignal = .single(result.image) |> then(loadSignal)
|
||||
} else {
|
||||
imageSignal = .single(result.image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let imageSignal {
|
||||
iconSource = ContextMenuActionItemIconSource(
|
||||
size: CGSize(width: 24.0, height: 24.0),
|
||||
cornerRadius: 5.0,
|
||||
signal: imageSignal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var icon: (PresentationTheme) -> UIImage? = { _ in nil }
|
||||
if iconSource == nil {
|
||||
icon = { theme in
|
||||
return generateImage(CGSize(width: 24.0, height: 24.0), opaque: false, scale: nil, rotatedContext: { size, context in
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
context.setFillColor(theme.contextMenu.primaryColor.withMultipliedAlpha(0.1).cgColor)
|
||||
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: 5.0).cgPath)
|
||||
context.fillPath()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let isSelected = self.shareToFolders.contains(where: { $0.id == folderPreview.folder.id })
|
||||
items.append(.action(ContextMenuActionItem(text: folderPreview.folder.title, icon: icon, additionalLeftIcon: { theme in
|
||||
if !isSelected {
|
||||
return nil
|
||||
}
|
||||
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor)
|
||||
}, iconSource: iconSource, iconPosition: .left, action: { [weak self] c, f in
|
||||
guard let self, let _ = self.component else {
|
||||
f(.default)
|
||||
return
|
||||
}
|
||||
|
||||
c?.dismiss(completion: {})
|
||||
|
||||
if let index = self.shareToFolders.firstIndex(where: { $0.id == folderPreview.folder.id }) {
|
||||
self.shareToFolders.remove(at: index)
|
||||
} else {
|
||||
self.shareToFolders.append(folderPreview.folder)
|
||||
}
|
||||
|
||||
self.state?.updated(transition: .immediate)
|
||||
})))
|
||||
}
|
||||
|
||||
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceView: sourceView, actionsOnTop: true)), items: .single(ContextController.Items(id: AnyHashable(0), content: .list(items))), gesture: nil)
|
||||
controller.presentInGlobalOverlay(contextController)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAddStoryFolder() {
|
||||
guard let component = self.component, let environment = self.environment, let controller = environment.controller() else {
|
||||
return
|
||||
}
|
||||
|
||||
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
|
||||
|
||||
//TODO:localize
|
||||
let promptController = promptController(
|
||||
sharedContext: component.context.sharedContext,
|
||||
updatedPresentationData: (initial: presentationData, signal: .single(presentationData)),
|
||||
text: "Create a New Album",
|
||||
titleFont: .bold,
|
||||
subtitle: "Choose a name for your album and start adding your stories there.",
|
||||
value: "",
|
||||
placeholder: "Title",
|
||||
characterLimit: 20,
|
||||
apply: { [weak self] value in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
}
|
||||
if let value, !value.isEmpty {
|
||||
let id = PeerStoryListContext.addFolderExternal(account: component.context.account, peerId: component.context.account.peerId, title: value)
|
||||
self.shareToFolders.append(StoryListContext.State.Folder(
|
||||
id: id,
|
||||
title: value
|
||||
))
|
||||
self.state?.updated(transition: .immediate)
|
||||
}
|
||||
}
|
||||
)
|
||||
controller.present(promptController, in: .window(.root))
|
||||
}
|
||||
|
||||
private func updateScrolling(transition: ComponentTransition) {
|
||||
guard let component = self.component, let environment = self.environment, let itemLayout = self.itemLayout else {
|
||||
return
|
||||
@ -1683,8 +1823,84 @@ final class ShareWithPeersScreenComponent: Component {
|
||||
}
|
||||
sectionOffset += footerSize.height
|
||||
} else if section.id == 4 && section.itemCount > 0 {
|
||||
var sectionItemOffset: CGFloat = 0.0
|
||||
if self.selectedOptions.contains(.pin) {
|
||||
let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + sectionItemOffset), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight))
|
||||
if !visibleBounds.intersects(itemFrame) {
|
||||
continue
|
||||
}
|
||||
|
||||
let itemId = AnyHashable("album")
|
||||
validIds.append(itemId)
|
||||
|
||||
var itemTransition = transition
|
||||
let visibleItem: ComponentView<Empty>
|
||||
if let current = self.visibleItems[itemId] {
|
||||
visibleItem = current
|
||||
} else {
|
||||
visibleItem = ComponentView()
|
||||
if !transition.animation.isImmediate {
|
||||
itemTransition = .immediate
|
||||
}
|
||||
self.visibleItems[itemId] = visibleItem
|
||||
}
|
||||
|
||||
//TODO:localize
|
||||
var foldersText = "All Stories"
|
||||
if !self.shareToFolders.isEmpty {
|
||||
if self.shareToFolders.count == 1 {
|
||||
foldersText = self.shareToFolders[0].title
|
||||
} else {
|
||||
foldersText = "\(self.shareToFolders.count) Albums"
|
||||
}
|
||||
}
|
||||
|
||||
//TODO:localize
|
||||
let _ = visibleItem.update(
|
||||
transition: itemTransition,
|
||||
component: AnyComponent(ListActionItemComponent(
|
||||
theme: environment.theme,
|
||||
background: nil,
|
||||
title: AnyComponent(VStack([
|
||||
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
|
||||
text: .plain(NSAttributedString(
|
||||
string: "Album",
|
||||
font: Font.regular(17.0),
|
||||
textColor: environment.theme.list.itemPrimaryTextColor
|
||||
)),
|
||||
maximumNumberOfLines: 1
|
||||
))),
|
||||
], alignment: .left, spacing: 2.0)),
|
||||
accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(AlbumLabelComponent(
|
||||
theme: environment.theme,
|
||||
title: foldersText,
|
||||
action: { [weak self] sourceView in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.displayFolderSelectionMenu(sourceView: sourceView)
|
||||
}
|
||||
))), insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0), isInteractive: true)),
|
||||
action: nil
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: itemFrame.size
|
||||
)
|
||||
if let itemView = visibleItem.view {
|
||||
if itemView.superview == nil {
|
||||
if let minSectionHeader {
|
||||
self.itemContainerView.insertSubview(itemView, belowSubview: minSectionHeader)
|
||||
} else {
|
||||
self.itemContainerView.addSubview(itemView)
|
||||
}
|
||||
}
|
||||
itemTransition.setFrame(view: itemView, frame: itemFrame)
|
||||
}
|
||||
|
||||
sectionItemOffset += section.itemHeight
|
||||
}
|
||||
if let item = component.coverItem {
|
||||
let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + CGFloat(0) * section.itemHeight), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight))
|
||||
let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + sectionItemOffset), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight))
|
||||
if !visibleBounds.intersects(itemFrame) {
|
||||
continue
|
||||
}
|
||||
@ -1750,6 +1966,9 @@ final class ShareWithPeersScreenComponent: Component {
|
||||
if let sendAsPeerId = self.sendAsPeerId, sendAsPeerId.isGroupOrChannel == true {
|
||||
footerText = isSendAsGroup ? environment.strings.Story_Privacy_ChooseCoverGroupInfo : environment.strings.Story_Privacy_ChooseCoverChannelInfo
|
||||
}
|
||||
if component.coverItem == nil {
|
||||
footerText = "Choose the albums where you want to share your story."
|
||||
}
|
||||
|
||||
let footerSize = sectionFooter.update(
|
||||
transition: sectionFooterTransition,
|
||||
@ -2496,7 +2715,14 @@ final class ShareWithPeersScreenComponent: Component {
|
||||
itemCount: component.optionItems.count
|
||||
))
|
||||
|
||||
if hasCover {
|
||||
if hasCover || self.selectedOptions.contains(.pin) {
|
||||
var itemCount = 0
|
||||
if hasCover {
|
||||
itemCount += 1
|
||||
}
|
||||
if self.selectedOptions.contains(.pin) {
|
||||
itemCount += 1
|
||||
}
|
||||
sections.append(ItemLayout.Section(
|
||||
id: 4,
|
||||
insets: UIEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0),
|
||||
@ -3303,3 +3529,131 @@ public class ShareWithPeersScreen: ViewControllerComponentContainer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
|
||||
private let controller: ViewController
|
||||
private let sourceView: UIView
|
||||
private let actionsOnTop: Bool
|
||||
|
||||
init(controller: ViewController, sourceView: UIView, actionsOnTop: Bool) {
|
||||
self.controller = controller
|
||||
self.sourceView = sourceView
|
||||
self.actionsOnTop = actionsOnTop
|
||||
}
|
||||
|
||||
func transitionInfo() -> ContextControllerReferenceViewInfo? {
|
||||
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, actionsPosition: self.actionsOnTop ? .top : .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private final class AlbumLabelComponent: Component {
|
||||
let theme: PresentationTheme
|
||||
let title: String
|
||||
let action: (UIView) -> Void
|
||||
|
||||
init(
|
||||
theme: PresentationTheme,
|
||||
title: String,
|
||||
action: @escaping (UIView) -> Void
|
||||
) {
|
||||
self.theme = theme
|
||||
self.title = title
|
||||
self.action = action
|
||||
}
|
||||
|
||||
static func ==(lhs: AlbumLabelComponent, rhs: AlbumLabelComponent) -> Bool {
|
||||
if lhs.theme !== rhs.theme {
|
||||
return false
|
||||
}
|
||||
if lhs.title != rhs.title {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
final class View: HighlightableButton {
|
||||
private let title = ComponentView<Empty>()
|
||||
private var selectorIcon: ComponentView<Empty>?
|
||||
|
||||
private var component: AlbumLabelComponent?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
|
||||
|
||||
self.isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func pressed() {
|
||||
guard let component = self.component else {
|
||||
return
|
||||
}
|
||||
component.action(self)
|
||||
}
|
||||
|
||||
func update(component: AlbumLabelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||||
self.component = component
|
||||
|
||||
let height: CGFloat = 44.0
|
||||
|
||||
let rightTextInset: CGFloat = 24.0
|
||||
|
||||
let titleSize = self.title.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(MultilineTextComponent(
|
||||
text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: component.theme.list.itemSecondaryTextColor))
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: availableSize.width - rightTextInset, height: height)
|
||||
)
|
||||
let titleFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((height - titleSize.height) * 0.5)), size: titleSize)
|
||||
if let titleView = self.title.view {
|
||||
if titleView.superview == nil {
|
||||
titleView.isUserInteractionEnabled = false
|
||||
self.addSubview(titleView)
|
||||
}
|
||||
titleView.frame = titleFrame
|
||||
}
|
||||
|
||||
let size = CGSize(width: titleSize.width + rightTextInset, height: height)
|
||||
|
||||
let selectorIcon: ComponentView<Empty>
|
||||
if let current = self.selectorIcon {
|
||||
selectorIcon = current
|
||||
} else {
|
||||
selectorIcon = ComponentView()
|
||||
self.selectorIcon = selectorIcon
|
||||
}
|
||||
let selectorIconSize = selectorIcon.update(
|
||||
transition: transition,
|
||||
component: AnyComponent(BundleIconComponent(
|
||||
name: "Item List/ExpandableSelectorArrows", tintColor: component.theme.list.itemSecondaryTextColor)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 100.0, height: 100.0)
|
||||
)
|
||||
let selectorIconFrame = CGRect(origin: CGPoint(x: size.width - 8.0 - selectorIconSize.width, y: floorToScreenPixels((size.height - selectorIconSize.height) * 0.5)), size: selectorIconSize)
|
||||
if let selectorIconView = selectorIcon.view {
|
||||
if selectorIconView.superview == nil {
|
||||
selectorIconView.isUserInteractionEnabled = false
|
||||
self.addSubview(selectorIconView)
|
||||
}
|
||||
transition.setFrame(view: selectorIconView, frame: selectorIconFrame)
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
func makeView() -> View {
|
||||
return View(frame: CGRect())
|
||||
}
|
||||
|
||||
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||
}
|
||||
}
|
||||
|
@ -348,6 +348,7 @@ private final class SheetContent: CombinedComponent {
|
||||
selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15),
|
||||
simple: true
|
||||
),
|
||||
theme: theme,
|
||||
customLayout: TabSelectorComponent.CustomLayout(
|
||||
font: Font.medium(14.0),
|
||||
spacing: 10.0
|
||||
|
@ -1604,6 +1604,7 @@ final class StoryItemSetViewListComponent: Component {
|
||||
foreground: .white,
|
||||
selection: UIColor(rgb: 0xffffff, alpha: 0.09)
|
||||
),
|
||||
theme: component.theme,
|
||||
items: [
|
||||
TabSelectorComponent.Item(
|
||||
id: AnyHashable(ListMode.everyone.rawValue),
|
||||
|
@ -10,12 +10,14 @@ swift_library(
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
"//submodules/AsyncDisplayKit",
|
||||
"//submodules/Display",
|
||||
"//submodules/ComponentFlow",
|
||||
"//submodules/TelegramUI/Components/PlainButtonComponent",
|
||||
"//submodules/Components/MultilineTextWithEntitiesComponent",
|
||||
"//submodules/TextFormat",
|
||||
"//submodules/AccountContext"
|
||||
"//submodules/AccountContext",
|
||||
"//submodules/TelegramPresentationData",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
@ -1,11 +1,13 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import AsyncDisplayKit
|
||||
import ComponentFlow
|
||||
import PlainButtonComponent
|
||||
import MultilineTextWithEntitiesComponent
|
||||
import TextFormat
|
||||
import AccountContext
|
||||
import TelegramPresentationData
|
||||
|
||||
public final class TabSelectorComponent: Component {
|
||||
public final class ItemEnvironment: Equatable {
|
||||
@ -57,53 +59,83 @@ public final class TabSelectorComponent: Component {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Item: Equatable {
|
||||
public final class Item: Equatable {
|
||||
public enum Content: Equatable {
|
||||
case text(String)
|
||||
case component(AnyComponent<ItemEnvironment>)
|
||||
}
|
||||
|
||||
public var id: AnyHashable
|
||||
public var content: Content
|
||||
public let id: AnyHashable
|
||||
public let content: Content
|
||||
public let isReorderable: Bool
|
||||
public let contextAction: ((ASDisplayNode, ContextGesture) -> Void)?
|
||||
|
||||
public init(
|
||||
id: AnyHashable,
|
||||
content: Content
|
||||
content: Content,
|
||||
isReorderable: Bool = false,
|
||||
contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.content = content
|
||||
self.isReorderable = isReorderable
|
||||
self.contextAction = contextAction
|
||||
}
|
||||
|
||||
public init(
|
||||
convenience public init(
|
||||
id: AnyHashable,
|
||||
title: String
|
||||
title: String,
|
||||
isReorderable: Bool = false,
|
||||
contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil
|
||||
) {
|
||||
self.init(id: id, content: .text(title))
|
||||
self.init(id: id, content: .text(title), isReorderable: isReorderable, contextAction: contextAction)
|
||||
}
|
||||
|
||||
public static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
if lhs.id != rhs.id {
|
||||
return false
|
||||
}
|
||||
if lhs.content != rhs.content {
|
||||
return false
|
||||
}
|
||||
if lhs.isReorderable != rhs.isReorderable {
|
||||
return false
|
||||
}
|
||||
if (lhs.contextAction == nil) != (rhs.contextAction == nil) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public let context: AccountContext?
|
||||
public let colors: Colors
|
||||
public let theme: PresentationTheme
|
||||
public let customLayout: CustomLayout?
|
||||
public let items: [Item]
|
||||
public let selectedId: AnyHashable?
|
||||
public let reorderItem: ((AnyHashable, AnyHashable) -> Void)?
|
||||
public let setSelectedId: (AnyHashable) -> Void
|
||||
public let transitionFraction: CGFloat?
|
||||
|
||||
public init(
|
||||
context: AccountContext? = nil,
|
||||
colors: Colors,
|
||||
theme: PresentationTheme,
|
||||
customLayout: CustomLayout? = nil,
|
||||
items: [Item],
|
||||
selectedId: AnyHashable?,
|
||||
reorderItem: ((AnyHashable, AnyHashable) -> Void)? = nil,
|
||||
setSelectedId: @escaping (AnyHashable) -> Void,
|
||||
transitionFraction: CGFloat? = nil
|
||||
) {
|
||||
self.context = context
|
||||
self.colors = colors
|
||||
self.theme = theme
|
||||
self.customLayout = customLayout
|
||||
self.items = items
|
||||
self.selectedId = selectedId
|
||||
self.reorderItem = reorderItem
|
||||
self.setSelectedId = setSelectedId
|
||||
self.transitionFraction = transitionFraction
|
||||
}
|
||||
@ -115,6 +147,9 @@ public final class TabSelectorComponent: Component {
|
||||
if lhs.colors != rhs.colors {
|
||||
return false
|
||||
}
|
||||
if lhs.theme !== rhs.theme {
|
||||
return false
|
||||
}
|
||||
if lhs.customLayout != rhs.customLayout {
|
||||
return false
|
||||
}
|
||||
@ -124,16 +159,188 @@ public final class TabSelectorComponent: Component {
|
||||
if lhs.selectedId != rhs.selectedId {
|
||||
return false
|
||||
}
|
||||
if (lhs.reorderItem == nil) != (rhs.reorderItem == nil) {
|
||||
return false
|
||||
}
|
||||
if lhs.transitionFraction != rhs.transitionFraction {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private final class VisibleItem {
|
||||
final class VisibleItem: UIView {
|
||||
let action: () -> Void
|
||||
let contextAction: (ASDisplayNode, ContextGesture) -> Void
|
||||
|
||||
let extractedContainerNode: ContextExtractedContentContainingNode
|
||||
let containerNode: ContextControllerSourceNode
|
||||
|
||||
let containerButton: UIView
|
||||
var extractedBackgroundView: UIImageView?
|
||||
|
||||
let title = ComponentView<Empty>()
|
||||
|
||||
init() {
|
||||
var item: Item?
|
||||
|
||||
var tapGesture: UITapGestureRecognizer?
|
||||
var theme: PresentationTheme?
|
||||
var size: CGSize?
|
||||
var isReordering: Bool = false
|
||||
|
||||
init(action: @escaping () -> Void, contextAction: @escaping (ASDisplayNode, ContextGesture) -> Void) {
|
||||
self.action = action
|
||||
self.contextAction = contextAction
|
||||
|
||||
self.extractedContainerNode = ContextExtractedContentContainingNode()
|
||||
self.containerNode = ContextControllerSourceNode()
|
||||
|
||||
self.containerButton = UIView()
|
||||
|
||||
super.init(frame: CGRect())
|
||||
|
||||
self.extractedContainerNode.contentNode.view.addSubview(self.containerButton)
|
||||
|
||||
self.containerNode.addSubnode(self.extractedContainerNode)
|
||||
self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode
|
||||
self.addSubview(self.containerNode.view)
|
||||
|
||||
//self.containerButton.addSubview(self.iconContainer)
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))
|
||||
self.tapGesture = tapGesture
|
||||
self.containerButton.addGestureRecognizer(tapGesture)
|
||||
tapGesture.isEnabled = false
|
||||
|
||||
self.containerNode.activated = { [weak self] gesture, _ in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.contextAction(self.extractedContainerNode, gesture)
|
||||
}
|
||||
|
||||
self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
|
||||
guard let self, let theme = self.theme, let size = self.size else {
|
||||
return
|
||||
}
|
||||
|
||||
if isExtracted {
|
||||
let extractedBackgroundView: UIImageView
|
||||
if let current = self.extractedBackgroundView {
|
||||
extractedBackgroundView = current
|
||||
} else {
|
||||
extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: size.height, color: theme.contextMenu.backgroundColor))
|
||||
self.extractedBackgroundView = extractedBackgroundView
|
||||
self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0)
|
||||
extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 0.0, dy: 0.0)
|
||||
extractedBackgroundView.alpha = 0.0
|
||||
}
|
||||
transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0)
|
||||
} else if let extractedBackgroundView = self.extractedBackgroundView {
|
||||
self.extractedBackgroundView = nil
|
||||
let alphaTransition: ContainedViewLayoutTransition
|
||||
if transition.isAnimated {
|
||||
alphaTransition = .animated(duration: 0.18, curve: .easeInOut)
|
||||
} else {
|
||||
alphaTransition = .immediate
|
||||
}
|
||||
alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in
|
||||
extractedBackgroundView?.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
self.containerNode.isGestureEnabled = false
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
|
||||
if case .ended = recognizer.state {
|
||||
self.action()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateIsShaking(animated: Bool) {
|
||||
if self.isReordering {
|
||||
if self.containerButton.layer.animation(forKey: "shaking_position") == nil {
|
||||
let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in
|
||||
return .pi * x / 180.0
|
||||
}
|
||||
|
||||
let duration: Double = 0.4
|
||||
let displacement: CGFloat = 1.0
|
||||
let degreesRotation: CGFloat = 2.0
|
||||
|
||||
let negativeDisplacement = -1.0 * displacement
|
||||
let position = CAKeyframeAnimation.init(keyPath: "position")
|
||||
position.beginTime = 0.8
|
||||
position.duration = duration
|
||||
position.values = [
|
||||
NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)),
|
||||
NSValue(cgPoint: CGPoint(x: 0, y: 0)),
|
||||
NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)),
|
||||
NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)),
|
||||
NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement))
|
||||
]
|
||||
position.calculationMode = .linear
|
||||
position.isRemovedOnCompletion = false
|
||||
position.repeatCount = Float.greatestFiniteMagnitude
|
||||
position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))
|
||||
position.isAdditive = true
|
||||
|
||||
let transform = CAKeyframeAnimation.init(keyPath: "transform")
|
||||
transform.beginTime = 2.6
|
||||
transform.duration = 0.3
|
||||
transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ)
|
||||
transform.values = [
|
||||
degreesToRadians(-1.0 * degreesRotation),
|
||||
degreesToRadians(degreesRotation),
|
||||
degreesToRadians(-1.0 * degreesRotation)
|
||||
]
|
||||
transform.calculationMode = .linear
|
||||
transform.isRemovedOnCompletion = false
|
||||
transform.repeatCount = Float.greatestFiniteMagnitude
|
||||
transform.isAdditive = true
|
||||
transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))
|
||||
|
||||
self.containerButton.layer.add(position, forKey: "shaking_position")
|
||||
self.containerButton.layer.add(transform, forKey: "shaking_rotation")
|
||||
}
|
||||
} else if self.containerButton.layer.animation(forKey: "shaking_position") != nil {
|
||||
if let presentationLayer = self.containerButton.layer.presentation() {
|
||||
let transition: ComponentTransition = .easeInOut(duration: 0.1)
|
||||
if presentationLayer.position != self.containerButton.layer.position {
|
||||
transition.animatePosition(layer: self.containerButton.layer, from: CGPoint(x: presentationLayer.position.x - self.containerButton.layer.position.x, y: presentationLayer.position.y - self.containerButton.layer.position.y), to: CGPoint(), additive: true)
|
||||
}
|
||||
if !CATransform3DIsIdentity(presentationLayer.transform) {
|
||||
transition.setTransform(layer: self.containerButton.layer, transform: CATransform3DIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
self.containerButton.layer.removeAnimation(forKey: "shaking_position")
|
||||
self.containerButton.layer.removeAnimation(forKey: "shaking_rotation")
|
||||
}
|
||||
}
|
||||
|
||||
func update(theme: PresentationTheme, size: CGSize, item: Item, isReordering: Bool, transition: ComponentTransition) {
|
||||
self.theme = theme
|
||||
self.size = size
|
||||
self.isReordering = isReordering
|
||||
self.item = item
|
||||
|
||||
self.containerNode.isGestureEnabled = item.contextAction != nil && !isReordering
|
||||
self.tapGesture?.isEnabled = !isReordering
|
||||
|
||||
transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size))
|
||||
|
||||
self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size)
|
||||
self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size)
|
||||
self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))
|
||||
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
|
||||
|
||||
self.updateIsShaking(animated: !transition.animation.isImmediate)
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,6 +353,10 @@ public final class TabSelectorComponent: Component {
|
||||
|
||||
private var didInitiallyScroll = false
|
||||
|
||||
private var reorderRecognizer: ReorderGestureRecognizer?
|
||||
private weak var reorderingItem: VisibleItem?
|
||||
private var reorderingItemPosition: (initial: CGFloat, offset: CGFloat) = (0.0, 0.0)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.selectionView = UIImageView()
|
||||
|
||||
@ -161,6 +372,53 @@ public final class TabSelectorComponent: Component {
|
||||
self.clipsToBounds = false
|
||||
|
||||
self.addSubview(self.selectionView)
|
||||
|
||||
let reorderRecognizer = ReorderGestureRecognizer(
|
||||
shouldBegin: { [weak self] point in
|
||||
guard let self, let component = self.component, component.reorderItem != nil else {
|
||||
return (allowed: false, requiresLongPress: false, item: nil)
|
||||
}
|
||||
|
||||
var item: VisibleItem?
|
||||
for (_, visibleItem) in self.visibleItems {
|
||||
if visibleItem.bounds.contains(self.convert(point, to: visibleItem)) {
|
||||
item = visibleItem
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let item, let itemValue = item.item, itemValue.isReorderable {
|
||||
return (allowed: true, requiresLongPress: false, item: item)
|
||||
} else {
|
||||
return (allowed: false, requiresLongPress: false, item: nil)
|
||||
}
|
||||
},
|
||||
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.x)
|
||||
},
|
||||
isActiveUpdated: { _ in
|
||||
}
|
||||
)
|
||||
self.reorderRecognizer = reorderRecognizer
|
||||
self.addGestureRecognizer(reorderRecognizer)
|
||||
reorderRecognizer.isEnabled = false
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@ -174,12 +432,65 @@ public final class TabSelectorComponent: Component {
|
||||
return true
|
||||
}
|
||||
|
||||
private func setReorderingItem(item: VisibleItem?) {
|
||||
self.reorderingItem = item
|
||||
if let item {
|
||||
self.reorderingItemPosition.initial = item.frame.minX
|
||||
self.reorderingItemPosition.offset = 0.0
|
||||
} else {
|
||||
self.reorderingItemPosition = (0.0, 0.0)
|
||||
}
|
||||
self.state?.updated(transition: .easeInOut(duration: 0.2))
|
||||
}
|
||||
|
||||
private func moveReorderingItem(distance: CGFloat) {
|
||||
guard let reorderingItem = self.reorderingItem else {
|
||||
return
|
||||
}
|
||||
let previousPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5
|
||||
self.reorderingItemPosition.offset = distance
|
||||
let updatedPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5
|
||||
|
||||
self.state?.updated(transition: .immediate)
|
||||
|
||||
if let component = self.component, let reorderItem = component.reorderItem {
|
||||
var currentId: AnyHashable?
|
||||
var reorderToId: AnyHashable?
|
||||
for (id, item) in self.visibleItems {
|
||||
if item === reorderingItem {
|
||||
currentId = id
|
||||
continue
|
||||
}
|
||||
guard let targetItem = item.item else {
|
||||
continue
|
||||
}
|
||||
if !targetItem.isReorderable {
|
||||
continue
|
||||
}
|
||||
if reorderToId != nil {
|
||||
continue
|
||||
}
|
||||
let itemCenter = item.center.x
|
||||
if previousPosition < itemCenter && updatedPosition > itemCenter {
|
||||
reorderToId = id
|
||||
} else if previousPosition > itemCenter && updatedPosition < itemCenter {
|
||||
reorderToId = id
|
||||
}
|
||||
}
|
||||
if let currentId, let reorderToId {
|
||||
reorderItem(currentId, reorderToId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(component: TabSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||||
let selectionColorUpdated = component.colors.selection != self.component?.colors.selection
|
||||
|
||||
self.component = component
|
||||
self.state = state
|
||||
|
||||
self.reorderRecognizer?.isEnabled = component.reorderItem != nil
|
||||
|
||||
let baseHeight: CGFloat = 28.0
|
||||
|
||||
var verticalInset: CGFloat = 0.0
|
||||
@ -231,7 +542,24 @@ public final class TabSelectorComponent: Component {
|
||||
if let current = self.visibleItems[item.id] {
|
||||
itemView = current
|
||||
} else {
|
||||
itemView = VisibleItem()
|
||||
let itemId = item.id
|
||||
itemView = VisibleItem(action: { [weak self] in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
}
|
||||
guard let item = component.items.first(where: { $0.id == itemId }) else {
|
||||
return
|
||||
}
|
||||
component.setSelectedId(item.id)
|
||||
}, contextAction: { [weak self] sourceNode, gesture in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
}
|
||||
guard let item = component.items.first(where: { $0.id == itemId }) else {
|
||||
return
|
||||
}
|
||||
item.contextAction?(sourceNode, gesture)
|
||||
})
|
||||
self.visibleItems[item.id] = itemView
|
||||
itemTransition = itemTransition.withAnimation(.none)
|
||||
}
|
||||
@ -261,32 +589,18 @@ public final class TabSelectorComponent: Component {
|
||||
|
||||
let itemSize = itemView.title.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(PlainButtonComponent(
|
||||
content: AnyComponent(ItemComponent(
|
||||
context: component.context,
|
||||
content: item.content,
|
||||
font: itemFont,
|
||||
color: component.colors.foreground,
|
||||
selectedColor: component.colors.selection,
|
||||
selectionFraction: useSelectionFraction ? selectionFraction : 0.0
|
||||
)),
|
||||
effectAlignment: .center,
|
||||
minSize: nil,
|
||||
action: { [weak self, weak itemView] in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
}
|
||||
component.setSelectedId(itemId)
|
||||
|
||||
if let view = itemView?.title.view, allowScroll && self.contentSize.width > self.bounds.width {
|
||||
self.scrollRectToVisible(view.frame.insetBy(dx: -64.0, dy: 0.0), animated: true)
|
||||
}
|
||||
},
|
||||
animateScale: !isLineSelection
|
||||
component: AnyComponent(ItemComponent(
|
||||
context: component.context,
|
||||
content: item.content,
|
||||
font: itemFont,
|
||||
color: component.colors.foreground,
|
||||
selectedColor: component.colors.selection,
|
||||
selectionFraction: useSelectionFraction ? selectionFraction : 0.0
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 200.0, height: 100.0)
|
||||
)
|
||||
|
||||
innerContentWidth += itemSize.width
|
||||
itemViews[item.id] = (itemView, itemSize, itemTransition)
|
||||
index += 1
|
||||
@ -302,6 +616,7 @@ public final class TabSelectorComponent: Component {
|
||||
var previousBackgroundRect: CGRect?
|
||||
var selectedBackgroundRect: CGRect?
|
||||
var nextBackgroundRect: CGRect?
|
||||
var selectedItemIsReordering = false
|
||||
|
||||
for item in component.items {
|
||||
guard let (itemView, itemSize, itemTransition) = itemViews[item.id] else {
|
||||
@ -310,10 +625,18 @@ public final class TabSelectorComponent: Component {
|
||||
if contentWidth > spacing {
|
||||
contentWidth += spacing
|
||||
}
|
||||
let itemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize)
|
||||
let itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: verticalInset), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight))
|
||||
let baseItemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize)
|
||||
var itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: verticalInset), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight))
|
||||
let itemTitleFrame = CGRect(origin: CGPoint(x: baseItemTitleFrame.minX - itemBackgroundRect.minX, y: baseItemTitleFrame.minY - itemBackgroundRect.minY), size: baseItemTitleFrame.size)
|
||||
contentWidth = itemBackgroundRect.maxX
|
||||
|
||||
if self.reorderingItem === itemView {
|
||||
itemBackgroundRect.origin.x = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset
|
||||
if item.id == component.selectedId {
|
||||
selectedItemIsReordering = true
|
||||
}
|
||||
}
|
||||
|
||||
if item.id == component.selectedId {
|
||||
selectedBackgroundRect = itemBackgroundRect
|
||||
}
|
||||
@ -323,14 +646,40 @@ public final class TabSelectorComponent: Component {
|
||||
nextBackgroundRect = itemBackgroundRect
|
||||
}
|
||||
|
||||
if itemView.superview == nil {
|
||||
self.addSubview(itemView)
|
||||
}
|
||||
|
||||
if let itemTitleView = itemView.title.view {
|
||||
if itemTitleView.superview == nil {
|
||||
itemTitleView.layer.anchorPoint = CGPoint()
|
||||
self.addSubview(itemTitleView)
|
||||
itemTitleView.isUserInteractionEnabled = false
|
||||
itemView.containerButton.addSubview(itemTitleView)
|
||||
}
|
||||
itemTransition.setPosition(view: itemTitleView, position: itemTitleFrame.origin)
|
||||
|
||||
itemTransition.setPosition(view: itemView, position: itemBackgroundRect.center)
|
||||
itemTransition.setBounds(view: itemView, bounds: CGRect(origin: CGPoint(), size: itemBackgroundRect.size))
|
||||
|
||||
if self.reorderingItem === itemView {
|
||||
itemTransition.setTransform(view: itemView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0))
|
||||
} else {
|
||||
itemTransition.setTransform(view: itemView, transform: CATransform3DIdentity)
|
||||
}
|
||||
|
||||
itemView.update(theme: component.theme, size: itemBackgroundRect.size, item: item, isReordering: item.isReorderable && component.reorderItem != nil, transition: itemTransition)
|
||||
|
||||
itemTransition.setPosition(view: itemTitleView, position: CGPoint(x: itemTitleFrame.minX, y: itemTitleFrame.minY))
|
||||
itemTransition.setBounds(view: itemTitleView, bounds: CGRect(origin: CGPoint(), size: itemTitleFrame.size))
|
||||
itemTransition.setAlpha(view: itemTitleView, alpha: item.id == component.selectedId || isLineSelection || component.colors.simple ? 1.0 : 0.4)
|
||||
|
||||
var itemAlpha: CGFloat = item.id == component.selectedId || isLineSelection || component.colors.simple ? 1.0 : 0.4
|
||||
if component.reorderItem != nil && !item.isReorderable {
|
||||
itemAlpha *= 0.5
|
||||
itemView.isUserInteractionEnabled = false
|
||||
} else {
|
||||
itemView.isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
itemTransition.setAlpha(view: itemTitleView, alpha: itemAlpha)
|
||||
}
|
||||
}
|
||||
contentWidth += spacing
|
||||
@ -339,7 +688,7 @@ public final class TabSelectorComponent: Component {
|
||||
for (id, itemView) in self.visibleItems {
|
||||
if !validIds.contains(id) {
|
||||
removeIds.append(id)
|
||||
itemView.title.view?.removeFromSuperview()
|
||||
itemView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
for id in removeIds {
|
||||
@ -366,9 +715,17 @@ public final class TabSelectorComponent: Component {
|
||||
var mappedSelectionFrame = effectiveBackgroundRect.insetBy(dx: innerInset, dy: 0.0)
|
||||
mappedSelectionFrame.origin.y = mappedSelectionFrame.maxY + 6.0
|
||||
mappedSelectionFrame.size.height = 3.0
|
||||
transition.setFrame(view: self.selectionView, frame: mappedSelectionFrame)
|
||||
transition.setPosition(view: self.selectionView, position: mappedSelectionFrame.center)
|
||||
transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: mappedSelectionFrame.size))
|
||||
transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity)
|
||||
} else {
|
||||
transition.setFrame(view: self.selectionView, frame: selectedBackgroundRect)
|
||||
transition.setPosition(view: self.selectionView, position: selectedBackgroundRect.center)
|
||||
transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: selectedBackgroundRect.size))
|
||||
if selectedItemIsReordering {
|
||||
transition.setTransform(view: self.selectionView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0))
|
||||
} else {
|
||||
transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.selectionView.alpha = 0.0
|
||||
@ -528,3 +885,197 @@ private final class ItemComponent: CombinedComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReorderGestureRecognizer: UIGestureRecognizer {
|
||||
private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?)
|
||||
private let willBegin: (CGPoint) -> Void
|
||||
private let began: (TabSelectorComponent.VisibleItem) -> Void
|
||||
private let ended: () -> Void
|
||||
private let moved: (CGPoint) -> Void
|
||||
private let isActiveUpdated: (Bool) -> Void
|
||||
|
||||
private var initialLocation: CGPoint?
|
||||
private var longTapTimer: Foundation.Timer?
|
||||
private var longPressTimer: Foundation.Timer?
|
||||
|
||||
private var itemView: TabSelectorComponent.VisibleItem?
|
||||
|
||||
public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (TabSelectorComponent.VisibleItem) -> 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 = Foundation.Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] _ in
|
||||
self?.longTapTimerFired()
|
||||
})
|
||||
self.longTapTimer = longTapTimer
|
||||
}
|
||||
|
||||
private func stopLongTapTimer() {
|
||||
self.itemView = nil
|
||||
self.longTapTimer?.invalidate()
|
||||
self.longTapTimer = nil
|
||||
}
|
||||
|
||||
private func startLongPressTimer() {
|
||||
self.longPressTimer?.invalidate()
|
||||
let longPressTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.6, repeats: false, block: { [weak self] _ in
|
||||
self?.longPressTimerFired()
|
||||
})
|
||||
self.longPressTimer = longPressTimer
|
||||
}
|
||||
|
||||
private func stopLongPressTimer() {
|
||||
self.itemView = nil
|
||||
self.longPressTimer?.invalidate()
|
||||
self.longPressTimer = nil
|
||||
}
|
||||
|
||||
override public 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 itemView = self.itemView {
|
||||
self.began(itemView)
|
||||
}
|
||||
self.isActiveUpdated(true)
|
||||
}
|
||||
|
||||
override public 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, itemView) = self.shouldBegin(location)
|
||||
if allowed {
|
||||
self.isActiveUpdated(true)
|
||||
|
||||
self.itemView = itemView
|
||||
self.initialLocation = location
|
||||
if requiresLongPress {
|
||||
self.startLongTapTimer()
|
||||
self.startLongPressTimer()
|
||||
} else {
|
||||
self.state = .began
|
||||
if let itemView = self.itemView {
|
||||
self.began(itemView)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.isActiveUpdated(false)
|
||||
self.state = .failed
|
||||
}
|
||||
} else {
|
||||
self.isActiveUpdated(false)
|
||||
self.state = .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public 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 public 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 public 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: 0.0)
|
||||
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
|
||||
|
||||
if dX > 3.0 {
|
||||
self.stopLongTapTimer()
|
||||
self.stopLongPressTimer()
|
||||
self.initialLocation = nil
|
||||
self.isActiveUpdated(false)
|
||||
self.state = .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -280,7 +280,7 @@ public final class TextLoadingEffectView: UIView {
|
||||
maskBorderShapeLayer = SimpleShapeLayer()
|
||||
maskBorderShapeLayer.fillColor = nil
|
||||
maskBorderShapeLayer.strokeColor = UIColor.white.cgColor
|
||||
maskBorderShapeLayer.lineWidth = 2.0
|
||||
maskBorderShapeLayer.lineWidth = 1.0
|
||||
self.maskBorderShapeLayer = maskBorderShapeLayer
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user