Story folders

This commit is contained in:
Isaac 2025-07-18 16:10:54 +02:00
parent 817308f13e
commit a6544e5d57
17 changed files with 1222 additions and 63 deletions

View File

@ -654,13 +654,16 @@ public final class ContextControllerActionsListActionItemNode: HighlightTracking
} }
if let additionalIconSize { if let additionalIconSize {
let iconFrame = CGRect( var iconFrame = CGRect(
origin: CGPoint( origin: CGPoint(
x: 10.0, x: 10.0,
y: floor((size.height - additionalIconSize.height) / 2.0) y: floor((size.height - additionalIconSize.height) / 2.0)
), ),
size: additionalIconSize 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) transition.updateFrame(node: self.additionalIconNode, frame: iconFrame, beginWithCurrentState: true)
} }
}) })

View File

@ -672,6 +672,7 @@ final class ContextSourceContainer: ASDisplayNode {
foreground: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.8), foreground: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.8),
selection: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1) selection: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1)
), ),
theme: presentationData.theme,
customLayout: TabSelectorComponent.CustomLayout( customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0), font: Font.medium(14.0),
spacing: 9.0 spacing: 9.0

View File

@ -152,6 +152,7 @@ final class HashtagSearchNavigationContentNode: NavigationBarContentNode {
foreground: self.theme.list.itemSecondaryTextColor, foreground: self.theme.list.itemSecondaryTextColor,
selection: self.theme.list.itemAccentColor selection: self.theme.list.itemAccentColor
), ),
theme: self.theme,
customLayout: TabSelectorComponent.CustomLayout( customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0), font: Font.medium(14.0),
spacing: self.hasCurrentChat ? 24.0 : 8.0, spacing: self.hasCurrentChat ? 24.0 : 8.0,

View File

@ -82,6 +82,7 @@ public final class ItemListControllerSegmentedTitleView: UIView {
foreground: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8), foreground: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8),
selection: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05) selection: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05)
), ),
theme: self.theme,
customLayout: TabSelectorComponent.CustomLayout( customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(15.0), font: Font.medium(15.0),
spacing: 8.0 spacing: 8.0

View File

@ -84,6 +84,7 @@ final class ItemListControllerTabsContentNode: NavigationBarContentNode {
foreground: self.theme.list.itemSecondaryTextColor, foreground: self.theme.list.itemSecondaryTextColor,
selection: self.theme.list.itemAccentColor selection: self.theme.list.itemAccentColor
), ),
theme: self.theme,
customLayout: TabSelectorComponent.CustomLayout( customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0), font: Font.medium(14.0),
spacing: 48.0, spacing: 48.0,

View File

@ -1424,6 +1424,37 @@ public final class PeerStoryListContext: StoryListContext {
}).start() }).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]) { func addToFolder(id: Int64, items: [EngineStoryItem]) {
let peerId = self.peerId let peerId = self.peerId
let _ = (self.account.postbox.transaction { transaction -> Void in 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]) { public func addToFolder(id: Int64, items: [EngineStoryItem]) {
self.impl.with { impl in self.impl.with { impl in
impl.addToFolder(id: id, items: items) 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> { 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 return account.postbox.transaction { transaction -> (peer: PeerReference, [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)]) in
let key = ValueBoxKey(length: 8 + 1) let key = ValueBoxKey(length: 8 + 1)

View File

@ -1382,6 +1382,7 @@ final class GiftOptionsScreenComponent: Component {
selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15), selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15),
simple: true simple: true
), ),
theme: theme,
items: tabSelectorItems, items: tabSelectorItems,
selectedId: AnyHashable(self.starsFilter.rawValue), selectedId: AnyHashable(self.starsFilter.rawValue),
setSelectedId: { [weak self] id in setSelectedId: { [weak self] id in

View File

@ -271,7 +271,7 @@ public final class PeerInfoRatingComponent: Component {
transition.setFrame(view: shimmerEffectView, frame: backgroundFrame) transition.setFrame(view: shimmerEffectView, frame: backgroundFrame)
alphaTransition.setAlpha(view: shimmerEffectView, alpha: 1.0) 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 { } else if let shimmerEffectView = self.shimmerEffectView {
self.shimmerEffectView = nil self.shimmerEffectView = nil
@ -325,7 +325,7 @@ public final class PeerInfoRatingComponent: Component {
tooltipController.view.isUserInteractionEnabled = false 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) alphaTransition.setAlpha(view: tooltipController.view, alpha: component.isExpanded ? 1.0 : 0.0)
return size return size

View File

@ -39,6 +39,7 @@ import PeerInfoCoverComponent
import PeerInfoPaneNode import PeerInfoPaneNode
import MultilineTextComponent import MultilineTextComponent
import PeerInfoRatingComponent import PeerInfoRatingComponent
import UndoUI
final class PeerInfoHeaderNavigationTransition { final class PeerInfoHeaderNavigationTransition {
let sourceNavigationBar: NavigationBar let sourceNavigationBar: NavigationBar
@ -132,6 +133,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
var titleExpandedStatusIconSize: CGSize? var titleExpandedStatusIconSize: CGSize?
var subtitleRatingIsExpanded: Bool = false var subtitleRatingIsExpanded: Bool = false
var didDisplayRatingTooltip: Bool = false
var subtitleRating: ComponentView<Empty>? var subtitleRating: ComponentView<Empty>?
let subtitleNodeContainer: ASDisplayNode let subtitleNodeContainer: ASDisplayNode
@ -1973,6 +1975,23 @@ final class PeerInfoHeaderNode: ASDisplayNode {
} }
self.subtitleRatingIsExpanded = !self.subtitleRatingIsExpanded self.subtitleRatingIsExpanded = !self.subtitleRatingIsExpanded
self.requestUpdateLayout?(true) 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: {}, environment: {},

View File

@ -3140,7 +3140,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
self.updateSelectedItems(animated: false) self.updateSelectedItems(animated: false)
if let gridSnapshot { if let gridSnapshot {
self.view.insertSubview(gridSnapshot, aboveSubview: self.itemGrid.view) self.view.insertSubview(gridSnapshot, aboveSubview: self.contextGestureContainerNode.view)
gridSnapshot.frame = self.itemGrid.frame gridSnapshot.frame = self.itemGrid.frame
if let animateDirection { if let animateDirection {
let directionFactor: CGFloat = animateDirection ? 1.0 : -1.0 let directionFactor: CGFloat = animateDirection ? 1.0 : -1.0
@ -3157,7 +3157,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
} }
} }
if let emptyStateSnapshot, let animateDirection { 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 directionFactor: CGFloat = animateDirection ? 1.0 : -1.0
let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) 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 { for folder in self.currentStoryFolders {
folderItems.append(TabSelectorComponent.Item( folderItems.append(TabSelectorComponent.Item(
id: AnyHashable(folder.id), 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), foreground: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8),
selection: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05) selection: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05)
), ),
theme: self.presentationData.theme,
customLayout: TabSelectorComponent.CustomLayout( customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0), font: Font.medium(14.0),
spacing: 9.0, spacing: 9.0,
@ -3841,6 +3910,30 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
), ),
items: folderItems, items: folderItems,
selectedId: selectedId, 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 setSelectedId: { [weak self] id in
guard let self else { guard let self else {
return return
@ -3887,8 +3980,23 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
self.view.addSubview(folderTabView) self.view.addSubview(folderTabView)
} }
transition.updateFrame(view: folderTabView, frame: folderTabFrame) 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 { 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 gridTopInset += 50.0
if case .botPreview = self.scope { if case .botPreview = self.scope {
@ -4828,17 +4941,28 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
public func presentDeleteCurrentStoryFolder() { public func presentDeleteCurrentStoryFolder() {
if let folder = self.currentStoryFolder { 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.setStoryFolder(id: nil, assumeEmpty: false)
self.currentStoryFolders.removeAll(where: { $0.id == folder.id }) }
self.removedStoryFolders.insert(folder.id) self.currentStoryFolders.removeAll(where: { $0.id == id })
self.removedStoryFolders.insert(id)
if let listContext = self.listSource as? PeerStoryListContext {
listContext.removeFolder(id: folder.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) 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) 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 { if !isReordering, let reorderedIds = self.reorderedIds {
self.reorderedIds = nil self.reorderedIds = nil
if case .botPreview = self.scope, let listSource = self.listSource as? BotPreviewStoryListContext { 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)
}
}

View File

@ -42,6 +42,10 @@ swift_library(
"//submodules/UndoUI", "//submodules/UndoUI",
"//submodules/TemporaryCachedPeerDataManager", "//submodules/TemporaryCachedPeerDataManager",
"//submodules/CountrySelectionUI", "//submodules/CountrySelectionUI",
"//submodules/TelegramUI/Components/ListActionItemComponent",
"//submodules/ContextUI",
"//submodules/PromptUI",
"//submodules/DirectMediaImageCache",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -24,6 +24,11 @@ import Markdown
import TelegramUIPreferences import TelegramUIPreferences
import UndoUI import UndoUI
import TelegramStringFormatting import TelegramStringFormatting
import ListActionItemComponent
import ContextUI
import BundleIconComponent
import PromptUI
import DirectMediaImageCache
final class ShareWithPeersScreenComponent: Component { final class ShareWithPeersScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment typealias EnvironmentType = ViewControllerComponentContainer.Environment
@ -349,6 +354,8 @@ final class ShareWithPeersScreenComponent: Component {
private var selectedCategories = Set<CategoryId>() private var selectedCategories = Set<CategoryId>()
private var selectedOptions = Set<OptionId>() private var selectedOptions = Set<OptionId>()
private var shareToFolders: [StoryListContext.State.Folder] = []
private var component: ShareWithPeersScreenComponent? private var component: ShareWithPeersScreenComponent?
private weak var state: EmptyComponentState? private weak var state: EmptyComponentState?
private var environment: ViewControllerComponentContainer.Environment? 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) { private func updateScrolling(transition: ComponentTransition) {
guard let component = self.component, let environment = self.environment, let itemLayout = self.itemLayout else { guard let component = self.component, let environment = self.environment, let itemLayout = self.itemLayout else {
return return
@ -1683,8 +1823,84 @@ final class ShareWithPeersScreenComponent: Component {
} }
sectionOffset += footerSize.height sectionOffset += footerSize.height
} else if section.id == 4 && section.itemCount > 0 { } 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 { 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) { if !visibleBounds.intersects(itemFrame) {
continue continue
} }
@ -1750,6 +1966,9 @@ final class ShareWithPeersScreenComponent: Component {
if let sendAsPeerId = self.sendAsPeerId, sendAsPeerId.isGroupOrChannel == true { if let sendAsPeerId = self.sendAsPeerId, sendAsPeerId.isGroupOrChannel == true {
footerText = isSendAsGroup ? environment.strings.Story_Privacy_ChooseCoverGroupInfo : environment.strings.Story_Privacy_ChooseCoverChannelInfo 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( let footerSize = sectionFooter.update(
transition: sectionFooterTransition, transition: sectionFooterTransition,
@ -2496,7 +2715,14 @@ final class ShareWithPeersScreenComponent: Component {
itemCount: component.optionItems.count 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( sections.append(ItemLayout.Section(
id: 4, id: 4,
insets: UIEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0), 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)
}
}

View File

@ -348,6 +348,7 @@ private final class SheetContent: CombinedComponent {
selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15), selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15),
simple: true simple: true
), ),
theme: theme,
customLayout: TabSelectorComponent.CustomLayout( customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0), font: Font.medium(14.0),
spacing: 10.0 spacing: 10.0

View File

@ -1604,6 +1604,7 @@ final class StoryItemSetViewListComponent: Component {
foreground: .white, foreground: .white,
selection: UIColor(rgb: 0xffffff, alpha: 0.09) selection: UIColor(rgb: 0xffffff, alpha: 0.09)
), ),
theme: component.theme,
items: [ items: [
TabSelectorComponent.Item( TabSelectorComponent.Item(
id: AnyHashable(ListMode.everyone.rawValue), id: AnyHashable(ListMode.everyone.rawValue),

View File

@ -10,12 +10,14 @@ swift_library(
"-warnings-as-errors", "-warnings-as-errors",
], ],
deps = [ deps = [
"//submodules/AsyncDisplayKit",
"//submodules/Display", "//submodules/Display",
"//submodules/ComponentFlow", "//submodules/ComponentFlow",
"//submodules/TelegramUI/Components/PlainButtonComponent", "//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/Components/MultilineTextWithEntitiesComponent", "//submodules/Components/MultilineTextWithEntitiesComponent",
"//submodules/TextFormat", "//submodules/TextFormat",
"//submodules/AccountContext" "//submodules/AccountContext",
"//submodules/TelegramPresentationData",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -1,11 +1,13 @@
import Foundation import Foundation
import UIKit import UIKit
import Display import Display
import AsyncDisplayKit
import ComponentFlow import ComponentFlow
import PlainButtonComponent import PlainButtonComponent
import MultilineTextWithEntitiesComponent import MultilineTextWithEntitiesComponent
import TextFormat import TextFormat
import AccountContext import AccountContext
import TelegramPresentationData
public final class TabSelectorComponent: Component { public final class TabSelectorComponent: Component {
public final class ItemEnvironment: Equatable { 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 { public enum Content: Equatable {
case text(String) case text(String)
case component(AnyComponent<ItemEnvironment>) case component(AnyComponent<ItemEnvironment>)
} }
public var id: AnyHashable public let id: AnyHashable
public var content: Content public let content: Content
public let isReorderable: Bool
public let contextAction: ((ASDisplayNode, ContextGesture) -> Void)?
public init( public init(
id: AnyHashable, id: AnyHashable,
content: Content content: Content,
isReorderable: Bool = false,
contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil
) { ) {
self.id = id self.id = id
self.content = content self.content = content
self.isReorderable = isReorderable
self.contextAction = contextAction
} }
public init( convenience public init(
id: AnyHashable, 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 context: AccountContext?
public let colors: Colors public let colors: Colors
public let theme: PresentationTheme
public let customLayout: CustomLayout? public let customLayout: CustomLayout?
public let items: [Item] public let items: [Item]
public let selectedId: AnyHashable? public let selectedId: AnyHashable?
public let reorderItem: ((AnyHashable, AnyHashable) -> Void)?
public let setSelectedId: (AnyHashable) -> Void public let setSelectedId: (AnyHashable) -> Void
public let transitionFraction: CGFloat? public let transitionFraction: CGFloat?
public init( public init(
context: AccountContext? = nil, context: AccountContext? = nil,
colors: Colors, colors: Colors,
theme: PresentationTheme,
customLayout: CustomLayout? = nil, customLayout: CustomLayout? = nil,
items: [Item], items: [Item],
selectedId: AnyHashable?, selectedId: AnyHashable?,
reorderItem: ((AnyHashable, AnyHashable) -> Void)? = nil,
setSelectedId: @escaping (AnyHashable) -> Void, setSelectedId: @escaping (AnyHashable) -> Void,
transitionFraction: CGFloat? = nil transitionFraction: CGFloat? = nil
) { ) {
self.context = context self.context = context
self.colors = colors self.colors = colors
self.theme = theme
self.customLayout = customLayout self.customLayout = customLayout
self.items = items self.items = items
self.selectedId = selectedId self.selectedId = selectedId
self.reorderItem = reorderItem
self.setSelectedId = setSelectedId self.setSelectedId = setSelectedId
self.transitionFraction = transitionFraction self.transitionFraction = transitionFraction
} }
@ -115,6 +147,9 @@ public final class TabSelectorComponent: Component {
if lhs.colors != rhs.colors { if lhs.colors != rhs.colors {
return false return false
} }
if lhs.theme !== rhs.theme {
return false
}
if lhs.customLayout != rhs.customLayout { if lhs.customLayout != rhs.customLayout {
return false return false
} }
@ -124,16 +159,188 @@ public final class TabSelectorComponent: Component {
if lhs.selectedId != rhs.selectedId { if lhs.selectedId != rhs.selectedId {
return false return false
} }
if (lhs.reorderItem == nil) != (rhs.reorderItem == nil) {
return false
}
if lhs.transitionFraction != rhs.transitionFraction { if lhs.transitionFraction != rhs.transitionFraction {
return false return false
} }
return true 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>() 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 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) { override init(frame: CGRect) {
self.selectionView = UIImageView() self.selectionView = UIImageView()
@ -161,6 +372,53 @@ public final class TabSelectorComponent: Component {
self.clipsToBounds = false self.clipsToBounds = false
self.addSubview(self.selectionView) 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) { required init?(coder: NSCoder) {
@ -174,12 +432,65 @@ public final class TabSelectorComponent: Component {
return true 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 { func update(component: TabSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let selectionColorUpdated = component.colors.selection != self.component?.colors.selection let selectionColorUpdated = component.colors.selection != self.component?.colors.selection
self.component = component self.component = component
self.state = state self.state = state
self.reorderRecognizer?.isEnabled = component.reorderItem != nil
let baseHeight: CGFloat = 28.0 let baseHeight: CGFloat = 28.0
var verticalInset: CGFloat = 0.0 var verticalInset: CGFloat = 0.0
@ -231,7 +542,24 @@ public final class TabSelectorComponent: Component {
if let current = self.visibleItems[item.id] { if let current = self.visibleItems[item.id] {
itemView = current itemView = current
} else { } 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 self.visibleItems[item.id] = itemView
itemTransition = itemTransition.withAnimation(.none) itemTransition = itemTransition.withAnimation(.none)
} }
@ -261,32 +589,18 @@ public final class TabSelectorComponent: Component {
let itemSize = itemView.title.update( let itemSize = itemView.title.update(
transition: .immediate, transition: .immediate,
component: AnyComponent(PlainButtonComponent( component: AnyComponent(ItemComponent(
content: AnyComponent(ItemComponent( context: component.context,
context: component.context, content: item.content,
content: item.content, font: itemFont,
font: itemFont, color: component.colors.foreground,
color: component.colors.foreground, selectedColor: component.colors.selection,
selectedColor: component.colors.selection, selectionFraction: useSelectionFraction ? selectionFraction : 0.0
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
)), )),
environment: {}, environment: {},
containerSize: CGSize(width: 200.0, height: 100.0) containerSize: CGSize(width: 200.0, height: 100.0)
) )
innerContentWidth += itemSize.width innerContentWidth += itemSize.width
itemViews[item.id] = (itemView, itemSize, itemTransition) itemViews[item.id] = (itemView, itemSize, itemTransition)
index += 1 index += 1
@ -302,6 +616,7 @@ public final class TabSelectorComponent: Component {
var previousBackgroundRect: CGRect? var previousBackgroundRect: CGRect?
var selectedBackgroundRect: CGRect? var selectedBackgroundRect: CGRect?
var nextBackgroundRect: CGRect? var nextBackgroundRect: CGRect?
var selectedItemIsReordering = false
for item in component.items { for item in component.items {
guard let (itemView, itemSize, itemTransition) = itemViews[item.id] else { guard let (itemView, itemSize, itemTransition) = itemViews[item.id] else {
@ -310,10 +625,18 @@ public final class TabSelectorComponent: Component {
if contentWidth > spacing { if contentWidth > spacing {
contentWidth += spacing contentWidth += spacing
} }
let itemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize) let baseItemTitleFrame = 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)) 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 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 { if item.id == component.selectedId {
selectedBackgroundRect = itemBackgroundRect selectedBackgroundRect = itemBackgroundRect
} }
@ -323,14 +646,40 @@ public final class TabSelectorComponent: Component {
nextBackgroundRect = itemBackgroundRect nextBackgroundRect = itemBackgroundRect
} }
if itemView.superview == nil {
self.addSubview(itemView)
}
if let itemTitleView = itemView.title.view { if let itemTitleView = itemView.title.view {
if itemTitleView.superview == nil { if itemTitleView.superview == nil {
itemTitleView.layer.anchorPoint = CGPoint() 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.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 contentWidth += spacing
@ -339,7 +688,7 @@ public final class TabSelectorComponent: Component {
for (id, itemView) in self.visibleItems { for (id, itemView) in self.visibleItems {
if !validIds.contains(id) { if !validIds.contains(id) {
removeIds.append(id) removeIds.append(id)
itemView.title.view?.removeFromSuperview() itemView.removeFromSuperview()
} }
} }
for id in removeIds { for id in removeIds {
@ -366,9 +715,17 @@ public final class TabSelectorComponent: Component {
var mappedSelectionFrame = effectiveBackgroundRect.insetBy(dx: innerInset, dy: 0.0) var mappedSelectionFrame = effectiveBackgroundRect.insetBy(dx: innerInset, dy: 0.0)
mappedSelectionFrame.origin.y = mappedSelectionFrame.maxY + 6.0 mappedSelectionFrame.origin.y = mappedSelectionFrame.maxY + 6.0
mappedSelectionFrame.size.height = 3.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 { } 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 { } else {
self.selectionView.alpha = 0.0 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
}
}
}
}

View File

@ -280,7 +280,7 @@ public final class TextLoadingEffectView: UIView {
maskBorderShapeLayer = SimpleShapeLayer() maskBorderShapeLayer = SimpleShapeLayer()
maskBorderShapeLayer.fillColor = nil maskBorderShapeLayer.fillColor = nil
maskBorderShapeLayer.strokeColor = UIColor.white.cgColor maskBorderShapeLayer.strokeColor = UIColor.white.cgColor
maskBorderShapeLayer.lineWidth = 2.0 maskBorderShapeLayer.lineWidth = 1.0
self.maskBorderShapeLayer = maskBorderShapeLayer self.maskBorderShapeLayer = maskBorderShapeLayer
} }