From 230adaea73f5cfe0d3f658f3da4624bec4db77ca Mon Sep 17 00:00:00 2001 From: Daniil Vinogradov Date: Sat, 16 Nov 2024 12:32:47 +0100 Subject: [PATCH] Torrent list filters added --- Submodules/LibTorrent-Swift | 2 +- iTorrent.xcodeproj/project.pbxproj | 16 ++ iTorrent/Components/Views/TagsView.swift | 186 ++++++++++++++++++ iTorrent/Core/Assets/Localizable.xcstrings | 56 ++++++ .../Rss/Search/RssSearchViewController.swift | 8 +- .../Rss/Search/RssSearchViewModel.swift | 2 +- .../TorrentListViewController.swift | 77 +++++++- .../TorrentList/TorrentListViewModel.swift | 29 ++- .../Combine/CombineLatest/CombineLatest.swift | 54 +++++ .../NavigationItemPalette.swift | 64 ++++++ 10 files changed, 476 insertions(+), 18 deletions(-) create mode 100644 iTorrent/Components/Views/TagsView.swift create mode 100644 iTorrent/Utils/Extensions/UIKit/NavigationItemPalette/NavigationItemPalette.swift diff --git a/Submodules/LibTorrent-Swift b/Submodules/LibTorrent-Swift index 2031fc5d..17f03417 160000 --- a/Submodules/LibTorrent-Swift +++ b/Submodules/LibTorrent-Swift @@ -1 +1 @@ -Subproject commit 2031fc5df0c0254f3aac2e6db61fee7f9e4d6d1d +Subproject commit 17f034170492be931c8b3a3e48cd8a4c7b855b10 diff --git a/iTorrent.xcodeproj/project.pbxproj b/iTorrent.xcodeproj/project.pbxproj index d8348789..c9cf1893 100644 --- a/iTorrent.xcodeproj/project.pbxproj +++ b/iTorrent.xcodeproj/project.pbxproj @@ -62,6 +62,8 @@ 7CAD30202BC34BCE00592990 /* RssFeedProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAD301F2BC34BCE00592990 /* RssFeedProvider.swift */; }; 7CB2639C2C0671320083C052 /* Publisher+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB2639B2C0671320083C052 /* Publisher+UI.swift */; }; 7CB2639E2C0A5B420083C052 /* BaseSafariViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB2639D2C0A5B420083C052 /* BaseSafariViewController.swift */; }; + 7CB58D842CE662BB00929205 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB58D832CE662BB00929205 /* TagsView.swift */; }; + 7CB58D872CE6AC7D00929205 /* NavigationItemPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB58D862CE6AC7500929205 /* NavigationItemPalette.swift */; }; 7CB6F6CE2BD82BAB00D0813B /* FileSharingPreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB6F6CD2BD82BAB00D0813B /* FileSharingPreferencesViewModel.swift */; }; 7CBDBAAD2C31EF0C008C986B /* UserDefaults+AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBDBAAC2C31EF0C008C986B /* UserDefaults+AppGroup.swift */; }; 7CBDBAAE2C31EF52008C986B /* UserDefaults+AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBDBAAC2C31EF0C008C986B /* UserDefaults+AppGroup.swift */; }; @@ -300,6 +302,8 @@ 7CAD301F2BC34BCE00592990 /* RssFeedProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RssFeedProvider.swift; sourceTree = ""; }; 7CB2639B2C0671320083C052 /* Publisher+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+UI.swift"; sourceTree = ""; }; 7CB2639D2C0A5B420083C052 /* BaseSafariViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseSafariViewController.swift; sourceTree = ""; }; + 7CB58D832CE662BB00929205 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = ""; }; + 7CB58D862CE6AC7500929205 /* NavigationItemPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationItemPalette.swift; sourceTree = ""; }; 7CB6F6CD2BD82BAB00D0813B /* FileSharingPreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSharingPreferencesViewModel.swift; sourceTree = ""; }; 7CBDBAAC2C31EF0C008C986B /* UserDefaults+AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+AppGroup.swift"; sourceTree = ""; }; 7CC411612BD319AE00CA8B13 /* AppDelegate+RemoteConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RemoteConfig.swift"; sourceTree = ""; }; @@ -542,6 +546,7 @@ 7C4CF2FF2BDE7DF60078FEA1 /* AdView */, 7C5FBE6E2BC2EF8B0069E5A0 /* EditTextView */, D173D9DD2BC024DD00E4F9EB /* UISwitchWithMenu.swift */, + 7CB58D832CE662BB00929205 /* TagsView.swift */, 7C5FBE0E2BBB227D0069E5A0 /* UISegmentedProgressView.swift */, 7C4CF2FD2BDE7DE50078FEA1 /* BaseView.swift */, 7C4ED0932BE961A20034B62C /* UIModernBarButtonItem.swift */, @@ -626,6 +631,7 @@ 7C95B7B52C385B8E000EC50F /* UIKit */ = { isa = PBXGroup; children = ( + 7CB58D852CE6AC6F00929205 /* NavigationItemPalette */, D173D9DF2BC0285800E4F9EB /* UIMenu */, 7C95B7B62C385B97000EC50F /* UICellAccessory+Image.swift */, 7C5FBE2B2BBDD6B40069E5A0 /* UIView+LayerColors.swift */, @@ -643,6 +649,14 @@ path = RssFeed; sourceTree = ""; }; + 7CB58D852CE6AC6F00929205 /* NavigationItemPalette */ = { + isa = PBXGroup; + children = ( + 7CB58D862CE6AC7500929205 /* NavigationItemPalette.swift */, + ); + path = NavigationItemPalette; + sourceTree = ""; + }; 7CB6F6CC2BD82B8A00D0813B /* FileSharing */ = { isa = PBXGroup; children = ( @@ -1691,6 +1705,7 @@ D1352D352BBD721700104E7B /* PRStorageViewModel.swift in Sources */, D1352D2C2BBD6E0E00104E7B /* MemorySpaceManager.swift in Sources */, D1AA00D22AFAC95500B74629 /* String+Localized.swift in Sources */, + 7CB58D872CE6AC7D00929205 /* NavigationItemPalette.swift in Sources */, 7CFEBE732BC3ED480013233F /* RssFeedCell.swift in Sources */, 7C5FBE552BC0B3550069E5A0 /* ProgressWidgetAttributes.swift in Sources */, 7CFEBEA92BC721D50013233F /* RssListPreferencesViewModel.swift in Sources */, @@ -1709,6 +1724,7 @@ D1A226A02AEEEFCC00669D6D /* SceneDelegate.swift in Sources */, D11BE5442AFB901E00780C1B /* PRSwitchView.swift in Sources */, D173D9E32BC0327D00E4F9EB /* BackgroundService.swift in Sources */, + 7CB58D842CE662BB00929205 /* TagsView.swift in Sources */, D1352D382BBD73CD00104E7B /* ColoredProgressBarView.swift in Sources */, D1A226FD2AEF04F900669D6D /* BaseViewController.swift in Sources */, ); diff --git a/iTorrent/Components/Views/TagsView.swift b/iTorrent/Components/Views/TagsView.swift new file mode 100644 index 00000000..d920bd8e --- /dev/null +++ b/iTorrent/Components/Views/TagsView.swift @@ -0,0 +1,186 @@ +// +// TagsView.swift +// Apple-Music-Search-Chips-Demo +// +// Created by Seb Vidal on 07/09/2024. +// Modified by XITRIX on 14/11/2024. +// + +import Combine +import UIKit + +class TagsView: UIScrollView { + private var lastUpdatedFrame: CGRect = .zero + private var bottomStackView: UIStackView! + private var topStackView: UIStackView! + private var backgroundView: UIView! + private var tagMaskView: UIView! + + var titles: [String] = [] { + didSet { updateButtons(for: titles) } + } + + @Published var selectedTagIndex: Int = 0 { + didSet { + guard selectedTagIndex != oldValue else { return } + updateSelection(for: selectedTagIndex, animated: true) + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupScrollView() + setupBottomStackView() + setupTopStackView() + setupBackgroundView() + setupTagMaskView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupScrollView() { + alwaysBounceHorizontal = true + showsHorizontalScrollIndicator = false + contentInsetAdjustmentBehavior = .automatic + } + + private func setupBottomStackView() { + bottomStackView = UIStackView() + bottomStackView.axis = .horizontal + bottomStackView.distribution = .fillProportionally + bottomStackView.isLayoutMarginsRelativeArrangement = true + bottomStackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(bottomStackView) + + NSLayoutConstraint.activate([ + bottomStackView.topAnchor.constraint(equalTo: topAnchor), + bottomStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + private func setupTopStackView() { + topStackView = UIStackView() + topStackView.axis = .horizontal + topStackView.isUserInteractionEnabled = false + topStackView.distribution = .fillProportionally + topStackView.isLayoutMarginsRelativeArrangement = true + topStackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(topStackView) + + NSLayoutConstraint.activate([ + topStackView.topAnchor.constraint(equalTo: topAnchor), + topStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + topStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + topStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + private func setupBackgroundView() { + backgroundView = UIView() + backgroundView.clipsToBounds = true + backgroundView.backgroundColor = .tintColor + backgroundView.layer.cornerCurve = .continuous + + insertSubview(backgroundView, aboveSubview: bottomStackView) + } + + private func setupTagMaskView() { + tagMaskView = UIView() + tagMaskView.clipsToBounds = true + tagMaskView.backgroundColor = .black + tagMaskView.layer.cornerCurve = .continuous + + topStackView.mask = tagMaskView + } + + private func updateButtons(for titles: [String]) { + bottomStackView.arrangedSubviews.forEach { subview in + subview.removeFromSuperview() + } + + topStackView.arrangedSubviews.forEach { subview in + subview.removeFromSuperview() + } + + for title in titles { + let bottomButton = button(with: title, foregroundColor: .label) + bottomStackView.addArrangedSubview(bottomButton) + + let topButton = button(with: title, foregroundColor: .white) + topStackView.addArrangedSubview(topButton) + } + + layoutSubviews() + + updateSelection(for: selectedTagIndex, animated: false) + } + + private func button(with title: String, foregroundColor: UIColor) -> UIButton { + let titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { container in + var container = container + container.font = UIFont.systemFont(ofSize: 13, weight: .semibold) + + return container + } + + let button = UIButton(type: .system) + button.configuration = .plain() + button.configuration?.title = title + button.configuration?.cornerStyle = .capsule + button.configuration?.baseForegroundColor = foregroundColor + button.configuration?.titleTextAttributesTransformer = titleTextAttributesTransformer + button.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 8.33, leading: 12, bottom: 8, trailing: 12.66) + button.addTarget(self, action: #selector(tagButtonTapped), for: .touchUpInside) + + return button + } + + @objc private func tagButtonTapped(_ sender: UIButton) { + selectedTagIndex = bottomStackView.arrangedSubviews.firstIndex(of: sender)! + } + + private func updateSelection(for selectedTagIndex: Int, animated: Bool) { + let update = { [self] in + if bottomStackView.arrangedSubviews.indices.contains(selectedTagIndex) { + let button = bottomStackView.arrangedSubviews[selectedTagIndex] + + tagMaskView.layer.cornerRadius = button.frame.height / 2 + tagMaskView.frame = button.frame + + backgroundView.layer.cornerRadius = button.frame.height / 2 + backgroundView.frame = button.frame + + scrollRectToVisible(button.frame, animated: true) + } + } + + guard animated + else { return update() } + + if #available(iOS 17.0, *) { + UIView.animate(springDuration: 0.25, bounce: 0.25) { + update() + } + } else { + UIView.animate(withDuration: 0.25) { + update() + } + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard backgroundView.frame == .zero + else { return } + + updateSelection(for: selectedTagIndex, animated: false) + } +} diff --git a/iTorrent/Core/Assets/Localizable.xcstrings b/iTorrent/Core/Assets/Localizable.xcstrings index 0cb212bb..71f909c5 100644 --- a/iTorrent/Core/Assets/Localizable.xcstrings +++ b/iTorrent/Core/Assets/Localizable.xcstrings @@ -255,6 +255,34 @@ } } }, + "common.all" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "All" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "All" + } + } + } + }, "common.cancel" : { "localizations" : { "en" : { @@ -3231,6 +3259,34 @@ } } }, + "noContent.search.%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Results for “%@”" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay resultados para “%@”" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет результатов по запросу «%@»" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到“%@”的相关结果" + } + } + } + }, "notification.done.message_%@" : { "localizations" : { "en" : { diff --git a/iTorrent/Screens/Rss/Search/RssSearchViewController.swift b/iTorrent/Screens/Rss/Search/RssSearchViewController.swift index 95409ce9..eb9de0ca 100644 --- a/iTorrent/Screens/Rss/Search/RssSearchViewController.swift +++ b/iTorrent/Screens/Rss/Search/RssSearchViewController.swift @@ -22,8 +22,12 @@ class RssSearchViewController: BaseCollectionViewControl config.text = %"rssSearch.empty.title" config.secondaryText = %"rssSearch.empty.subtitle" contentUnavailableConfiguration = config - case .badSearch: - contentUnavailableConfiguration = UIContentUnavailableConfiguration.search() + case .badSearch(let search): + var configuration = UIContentUnavailableConfiguration.search() + configuration.text = %"noContent.search.\(search)" //"No Results for “\(search)”" + contentUnavailableConfiguration = configuration + case .badFilter: + contentUnavailableConfiguration = UIContentUnavailableConfiguration.empty() case nil: contentUnavailableConfiguration = nil } diff --git a/iTorrent/Screens/Rss/Search/RssSearchViewModel.swift b/iTorrent/Screens/Rss/Search/RssSearchViewModel.swift index dc1eb047..98b3d3f9 100644 --- a/iTorrent/Screens/Rss/Search/RssSearchViewModel.swift +++ b/iTorrent/Screens/Rss/Search/RssSearchViewModel.swift @@ -25,7 +25,7 @@ extension RssSearchViewModel { var emptyContentType: AnyPublisher { Publishers.combineLatest($sections, $searchQuery) { sections, searchQuery in if sections.isEmpty || sections.allSatisfy({ $0.items.isEmpty }) { - if !searchQuery.isEmpty { return EmptyType.badSearch } + if !searchQuery.isEmpty { return EmptyType.badSearch(searchQuery) } return EmptyType.noData } return nil diff --git a/iTorrent/Screens/TorrentList/TorrentListViewController.swift b/iTorrent/Screens/TorrentList/TorrentListViewController.swift index a1cb491c..48f83ac4 100644 --- a/iTorrent/Screens/TorrentList/TorrentListViewController.swift +++ b/iTorrent/Screens/TorrentList/TorrentListViewController.swift @@ -11,6 +11,25 @@ import MvvmFoundation import SwiftUI import UIKit +class TLSearchController: UISearchController { + let isActivePublisher = PassthroughRelay() + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + isActivePublisher.send(true) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + isActivePublisher.send(false) + transitionCoordinator?.animate(alongsideTransition: { _ in }, completion: { [self] ctx in + if ctx.isCancelled { + isActivePublisher.send(true) + } + }) + } +} + class TorrentListViewController: BaseViewController { @IBOutlet private var collectionView: MvvmCollectionView! @IBOutlet private var adView: AdView! @@ -27,13 +46,14 @@ class TorrentListViewController: BaseViewController: BaseViewController: BaseViewController: BaseViewController: BaseViewController, UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } @@ -322,6 +366,14 @@ extension TorrentListViewController { }) return alert } + + static func makeTagsView() -> TagsView { + let tagsView = TagsView() + tagsView.translatesAutoresizingMaskIntoConstraints = false + + tagsView.titles = [%"common.all"] + TorrentHandle.State.filterArray.map { $0.name } + return tagsView + } } private extension TorrentListViewController { @@ -346,3 +398,20 @@ private extension TorrentListViewModel.Sort { } } } + +extension TorrentHandle.State { + static var filterArray: [TorrentHandle.State] { + [ + .finished, + .downloading, + .seeding, + .paused, + .checkingFiles, + .downloadingMetadata, + .checkingResumeData, + + // Custom state for storage error + .storageError + ] + } +} diff --git a/iTorrent/Screens/TorrentList/TorrentListViewModel.swift b/iTorrent/Screens/TorrentList/TorrentListViewModel.swift index d4a13ffe..709e0807 100644 --- a/iTorrent/Screens/TorrentList/TorrentListViewModel.swift +++ b/iTorrent/Screens/TorrentList/TorrentListViewModel.swift @@ -12,7 +12,8 @@ import SwiftData enum EmptyType { case noData - case badSearch + case badSearch(String) + case badFilter(Int) } extension TorrentListViewModel { @@ -26,9 +27,11 @@ extension TorrentListViewModel { class TorrentListViewModel: BaseViewModel { @Published var sections: [MvvmCollectionSectionModel] = [] + @Published var searchPresented: Bool = false @Published var searchQuery: String = "" @Published var title: String = "" @Published var hasRssNews: Bool = false + @Published var filterIndex: Int = 0 lazy var rssSearchViewModel: RssSearchViewModel = { let vm = RssSearchViewModel() @@ -70,22 +73,24 @@ class TorrentListViewModel: BaseViewModel { torrentSectionChanged, TorrentService.shared.$torrents.map { Array($0.values) }, $searchQuery, + $searchPresented, sortingType, sortingReverced, isGroupedByState, - groupsSortingArray - ) { _, torrentHandles, searchQuery, sortingType, sortingReverced, isGrouping, sortingArray in + groupsSortingArray, + $filterIndex + ) { _, torrentHandles, searchQuery, searchPresented, sortingType, sortingReverced, isGrouping, sortingArray, filterIndex in var torrentHandles = torrentHandles if !searchQuery.isEmpty { torrentHandles = torrentHandles.filter { Self.searchFilter($0.snapshot.name, by: searchQuery) } } - return (torrentHandles.sorted(by: sortingType, reverced: sortingReverced), isGrouping, sortingArray) + return (torrentHandles.sorted(by: sortingType, reverced: sortingReverced), isGrouping, sortingArray, filterIndex, searchPresented) } - .map { [unowned self] torrents, isGrouping, sortingArray in + .map { [unowned self] torrents, isGrouping, sortingArray, filterIndex, searchPresented in if isGrouping { return makeGroupedSections(with: torrents, by: sortingArray) } else { - return makeUngroupedSection(with: torrents) + return makeUngroupedSection(with: torrents, filterIndex: filterIndex, searchPresented: searchPresented) } }.assign(to: &$sections) $searchQuery.assign(to: &rssSearchViewModel.$searchQuery) @@ -100,9 +105,10 @@ class TorrentListViewModel: BaseViewModel { extension TorrentListViewModel { var emptyContentType: AnyPublisher { - Publishers.combineLatest($sections, $searchQuery) { sections, searchQuery in + Publishers.combineLatest($sections, $searchQuery, $filterIndex) { sections, searchQuery, filterIndex in if sections.isEmpty || sections.allSatisfy({ $0.items.isEmpty }) { - if !searchQuery.isEmpty { return EmptyType.badSearch } + if !searchQuery.isEmpty { return EmptyType.badSearch(searchQuery) } + if filterIndex > 0 { return EmptyType.badFilter(filterIndex) } return EmptyType.noData } return nil @@ -179,8 +185,11 @@ extension TorrentListViewModel { } private extension TorrentListViewModel { - func makeUngroupedSection(with torrents: [TorrentHandle]) -> [MvvmCollectionSectionModel] { - [.init(id: "torrents", style: .platformPlain, showsSeparators: true, items: torrents.map { + func makeUngroupedSection(with torrents: [TorrentHandle], filterIndex: Int, searchPresented: Bool) -> [MvvmCollectionSectionModel] { + [.init(id: "torrents", style: .platformPlain, showsSeparators: true, items: torrents.filter { torrent in + guard filterIndex > 0 && !searchPresented else { return true } + return torrent.snapshot.friendlyState == TorrentHandle.State.filterArray[filterIndex - 1] + }.map { let vm = TorrentListItemViewModel(with: $0) vm.setNavigationService { [weak self] in self?.navigationService?() } return vm diff --git a/iTorrent/Utils/Extensions/Combine/CombineLatest/CombineLatest.swift b/iTorrent/Utils/Extensions/Combine/CombineLatest/CombineLatest.swift index 099e8770..cb112288 100644 --- a/iTorrent/Utils/Extensions/Combine/CombineLatest/CombineLatest.swift +++ b/iTorrent/Utils/Extensions/Combine/CombineLatest/CombineLatest.swift @@ -73,6 +73,60 @@ public extension Publishers { transform(a, b, c, d.0, d.1, d.2, d.3) } } + + // 8 + static func combineLatest( + _ publisher1: A, + _ publisher2: B, + _ publisher3: C, + _ publisher4: D, + _ publisher5: E, + _ publisher6: F, + _ publisher7: G, + _ publisher8: H, + _ transform: @escaping (A.Output, B.Output, C.Output, D.Output, E.Output, F.Output, G.Output, H.Output) -> Result + ) -> Publishers.Map, E, F, Publishers.CombineLatest>, Result> + where A.Failure == B.Failure, + B.Failure == C.Failure, + C.Failure == D.Failure, + D.Failure == E.Failure, + E.Failure == F.Failure, + F.Failure == G.Failure, + G.Failure == H.Failure + { + return Publishers.CombineLatest4(Publishers.CombineLatest4(publisher1, publisher2, publisher3, publisher4), publisher5, publisher6, Publishers.CombineLatest(publisher7, publisher8)) + .map { a, b, c, d in + transform(a.0, a.1, a.2, a.3, b, c, d.0, d.1) + } + } + + // 9 + static func combineLatest( + _ publisher1: A, + _ publisher2: B, + _ publisher3: C, + _ publisher4: D, + _ publisher5: E, + _ publisher6: F, + _ publisher7: G, + _ publisher8: H, + _ publisher9: I, + _ transform: @escaping (A.Output, B.Output, C.Output, D.Output, E.Output, F.Output, G.Output, H.Output, I.Output) -> Result + ) -> Publishers.Map, E, F, Publishers.CombineLatest3>, Result> + where A.Failure == B.Failure, + B.Failure == C.Failure, + C.Failure == D.Failure, + D.Failure == E.Failure, + E.Failure == F.Failure, + F.Failure == G.Failure, + G.Failure == H.Failure, + H.Failure == I.Failure + { + return Publishers.CombineLatest4(Publishers.CombineLatest4(publisher1, publisher2, publisher3, publisher4), publisher5, publisher6, Publishers.CombineLatest3(publisher7, publisher8, publisher9)) + .map { a, b, c, d in + transform(a.0, a.1, a.2, a.3, b, c, d.0, d.1, d.2) + } + } } public extension Publishers { diff --git a/iTorrent/Utils/Extensions/UIKit/NavigationItemPalette/NavigationItemPalette.swift b/iTorrent/Utils/Extensions/UIKit/NavigationItemPalette/NavigationItemPalette.swift new file mode 100644 index 00000000..c1f85d18 --- /dev/null +++ b/iTorrent/Utils/Extensions/UIKit/NavigationItemPalette/NavigationItemPalette.swift @@ -0,0 +1,64 @@ +// +// NavigationItemPalette.swift +// iTorrent +// +// Created by Даниил Виноградов on 14.11.2024. +// + +import UIKit + +extension UIView { +} + +extension UINavigationItem { + func setBottomPalette(_ contentView: UIView?, height: CGFloat = 44) { + /// "_setBottomPalette:" + let selector = NSSelectorFromBase64String("X3NldEJvdHRvbVBhbGV0dGU6") + guard responds(to: selector) else { return } + perform(selector, with: Self.makeNavigationItemPalette(with: contentView, height: height)) + } + + private static func makeNavigationItemPalette(with contentView: UIView?, height: CGFloat) -> UIView? { + guard let contentView else { return nil } + contentView.translatesAutoresizingMaskIntoConstraints = false + + let contentViewHolder = UIView(frame: .init(x: 0, y: 0, width: 0, height: height)) + contentViewHolder.autoresizingMask = [.flexibleHeight] + contentViewHolder.addSubview(contentView) + NSLayoutConstraint.activate([ + contentViewHolder.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentViewHolder.topAnchor.constraint(equalTo: contentView.topAnchor), + contentView.trailingAnchor.constraint(equalTo: contentViewHolder.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: contentViewHolder.bottomAnchor), + ]) + + /// "_UINavigationBarPalette" + guard let paletteClass = NSClassFromBase64String("X1VJTmF2aWdhdGlvbkJhclBhbGV0dGU=") as? UIView.Type + else { return nil } + + /// "alloc" + /// "initWithContentView:" + guard let palette = paletteClass.perform(NSSelectorFromBase64String("YWxsb2M=")) + .takeUnretainedValue() + .perform(NSSelectorFromBase64String("aW5pdFdpdGhDb250ZW50Vmlldzo="), with: contentViewHolder) + .takeUnretainedValue() as? UIView + else { return nil } + + palette.preservesSuperviewLayoutMargins = true + return palette + } +} + +func NSSelectorFromBase64String(_ base64String: String) -> Selector { + NSSelectorFromString(String(base64: base64String)) +} + +func NSClassFromBase64String(_ aBase64ClassName: String) -> AnyClass? { + NSClassFromString(String(base64: aBase64ClassName)) +} + +extension String { + init(base64: String) { + self.init(data: Data(base64Encoded: base64)!, encoding: .utf8)! + } +}