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 {
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)
}
})

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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: {},

View File

@ -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)
}
}

View File

@ -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",

View File

@ -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)
}
}

View File

@ -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

View File

@ -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),

View File

@ -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",

View File

@ -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
}
}
}
}

View File

@ -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
}