diff options
| author | mojganii <mojgan.jelodar@codic.se> | 2025-03-10 10:40:28 +0100 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-03-18 09:05:07 +0100 |
| commit | b260625e65350af228528b9b76e55ef50be4b10d (patch) | |
| tree | f712fb2db09047154779bd8e9e2be6c938a7dcec | |
| parent | 4d92aff083ed802a53459bd3ca6ecf9254439d07 (diff) | |
| download | mullvadvpn-b260625e65350af228528b9b76e55ef50be4b10d.tar.xz mullvadvpn-b260625e65350af228528b9b76e55ef50be4b10d.zip | |
Refactor RelayFilterDataSource
15 files changed, 439 insertions, 413 deletions
diff --git a/ios/MullvadREST/Relay/RelayCandidates.swift b/ios/MullvadREST/Relay/RelayCandidates.swift index 42567690a4..9ebb765d18 100644 --- a/ios/MullvadREST/Relay/RelayCandidates.swift +++ b/ios/MullvadREST/Relay/RelayCandidates.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Mullvad VPN AB. All rights reserved. // -public struct RelayCandidates { +public struct RelayCandidates: Equatable { public let entryRelays: [RelayWithLocation<REST.ServerRelay>]? public let exitRelays: [RelayWithLocation<REST.ServerRelay>] public init( diff --git a/ios/MullvadREST/Relay/RelaySelector.swift b/ios/MullvadREST/Relay/RelaySelector.swift index 620daba28d..14742aef09 100644 --- a/ios/MullvadREST/Relay/RelaySelector.swift +++ b/ios/MullvadREST/Relay/RelaySelector.swift @@ -15,7 +15,7 @@ public enum RelaySelector { // MARK: - public /// Determines whether a `REST.ServerRelay` satisfies the given relay filter. - static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool { + public static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool { if case let .only(providers) = filter.providers, providers.contains(relay.provider) == false { return false } diff --git a/ios/MullvadREST/Relay/RelayWithLocation.swift b/ios/MullvadREST/Relay/RelayWithLocation.swift index c4d82c07f8..4b3e7bac4e 100644 --- a/ios/MullvadREST/Relay/RelayWithLocation.swift +++ b/ios/MullvadREST/Relay/RelayWithLocation.swift @@ -60,8 +60,12 @@ public struct RelayWithLocation<T: AnyRelay> { } } -extension RelayWithLocation: Equatable { +extension RelayWithLocation: Hashable { public static func == (lhs: RelayWithLocation<T>, rhs: RelayWithLocation<T>) -> Bool { lhs.relay.hostname == rhs.relay.hostname } + + public func hash(into hasher: inout Hasher) { + hasher.combine(relay.hostname) + } } diff --git a/ios/MullvadTypes/RelayConstraint.swift b/ios/MullvadTypes/RelayConstraint.swift index 9b01a513aa..96bf81f100 100644 --- a/ios/MullvadTypes/RelayConstraint.swift +++ b/ios/MullvadTypes/RelayConstraint.swift @@ -10,8 +10,8 @@ import Foundation private let anyConstraint = "any" -public enum RelayConstraint<T>: Codable, Equatable, - CustomDebugStringConvertible where T: Codable & Equatable { +public enum RelayConstraint<T>: Codable, Equatable, CustomDebugStringConvertible, Sendable + where T: Codable & Equatable & Sendable { case any case only(T) @@ -34,7 +34,7 @@ public enum RelayConstraint<T>: Codable, Equatable, return output } - private struct OnlyRepr: Codable { + private struct OnlyRepr: Codable, Sendable { var only: T } @@ -46,7 +46,6 @@ public enum RelayConstraint<T>: Codable, Equatable, self = .any } else { let onlyVariant = try container.decode(OnlyRepr.self) - self = .only(onlyVariant.only) } } diff --git a/ios/MullvadTypes/RelayFilter.swift b/ios/MullvadTypes/RelayFilter.swift index ae02fde5d3..59ed6be2fe 100644 --- a/ios/MullvadTypes/RelayFilter.swift +++ b/ios/MullvadTypes/RelayFilter.swift @@ -8,8 +8,8 @@ import Foundation -public struct RelayFilter: Codable, Equatable { - public enum Ownership: Codable { +public struct RelayFilter: Codable, Equatable, Sendable { + public enum Ownership: Codable, Sendable { case any case owned case rented diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index ada2b8e01d..7e97cdd207 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -928,6 +928,7 @@ F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */; }; F0164EC32B4C49D30020268D /* ShadowsocksLoaderStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */; }; F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */; }; + F017F8E02D78AC020076EC01 /* RelayFilterDataSourceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */; }; F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01DAE322C2B032A00521E46 /* RelaySelection.swift */; }; F022EBA62CF0C6AE009484B9 /* ConsolidatedApplicationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */; }; F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */; }; @@ -2346,6 +2347,7 @@ F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = "<group>"; }; F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoaderStub.swift; sourceTree = "<group>"; }; F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIterator.swift; sourceTree = "<group>"; }; + F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterDataSourceItem.swift; sourceTree = "<group>"; }; F01DAE322C2B032A00521E46 /* RelaySelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelaySelection.swift; sourceTree = "<group>"; }; F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = "<group>"; }; F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededViewController.swift; sourceTree = "<group>"; }; @@ -4319,6 +4321,7 @@ F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */, 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */, 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */, + F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */, 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */, 7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */, 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */, @@ -6385,6 +6388,7 @@ 7A5869B92B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift in Sources */, 586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, + F017F8E02D78AC020076EC01 /* RelayFilterDataSourceItem.swift in Sources */, 586C0D832B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift index 51eb06d698..666fd12c98 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift @@ -13,16 +13,32 @@ struct FilterDescriptor { let settings: LatestTunnelSettings var isEnabled: Bool { - let exitCount = relayFilterResult.exitRelays.count - let entryCount = relayFilterResult.entryRelays?.count ?? 0 - let totalcount = exitCount + entryCount + // Check if multihop is enabled via settings let isMultihopEnabled = settings.tunnelMultihopState.isEnabled - return (isMultihopEnabled && totalcount > 1) || (!isMultihopEnabled && totalcount > 0) + let isSmartRoutingEnabled = settings.daita.isAutomaticRouting + + /// Closure to check if there are enough relays available for multihoping + let hasSufficientRelays: () -> Bool = { + (relayFilterResult.entryRelays ?? []).count >= 1 && + relayFilterResult.exitRelays.count >= 1 && + numberOfServers > 1 + } + + if isMultihopEnabled { + // Multihop mode requires at least one entry relay, one exit relay, + // and more than one unique server. + return hasSufficientRelays() + } else if isSmartRoutingEnabled { + // Smart Routing mode: Enabled only if there is NO daita server in the exit relays + let isSmartRoutingNeeded = !relayFilterResult.exitRelays.contains { $0.relay.daita == true } + return isSmartRoutingNeeded ? hasSufficientRelays() : true + } else { + // Single-hop mode: The filter is enabled if at least one available exit relay exists. + return !relayFilterResult.exitRelays.isEmpty + } } var title: String { - let exitCount = relayFilterResult.exitRelays.count - let entryCount = relayFilterResult.entryRelays?.count ?? 0 guard isEnabled else { return NSLocalizedString( "RELAY_FILTER_BUTTON_TITLE", @@ -31,29 +47,17 @@ struct FilterDescriptor { comment: "" ) } - return createTitleForAvailableServers( - entryCount: entryCount, - exitCount: exitCount, - isMultihopEnabled: settings.tunnelMultihopState.isEnabled, - isDirectOnly: settings.daita.isDirectOnly - ) + return createTitleForAvailableServers() } var description: String { - guard settings.daita.isDirectOnly else { - return settings.daita.daitaState.isEnabled - ? NSLocalizedString( - "RELAY_FILTER_BUTTON_DESCRIPTION", - tableName: "RelayFilter", - value: "DAITA is enabled, affecting your filters.", - comment: "" - ) - : "" + guard settings.daita.daitaState.isEnabled else { + return "" } return NSLocalizedString( "RELAY_FILTER_BUTTON_DESCRIPTION", tableName: "RelayFilter", - value: "Direct only DAITA is enabled, affecting your filters.", + value: "When using DAITA, one provider with DAITA-enabled servers is required.", comment: "" ) } @@ -63,23 +67,14 @@ struct FilterDescriptor { self.relayFilterResult = relayFilterResult } - private func createTitleForAvailableServers( - entryCount: Int, - exitCount: Int, - isMultihopEnabled: Bool, - isDirectOnly: Bool - ) -> String { - let displayNumber: (Int) -> String = { number in - number > 100 ? "99+" : "\(number)" - } + private var numberOfServers: Int { + Set(relayFilterResult.entryRelays ?? []).union(relayFilterResult.exitRelays).count + } - if isMultihopEnabled && isDirectOnly { - return String( - format: "Show %@ entry & %@ exit servers", - displayNumber(entryCount), - displayNumber(exitCount) - ) + private func createTitleForAvailableServers() -> String { + let displayNumber: (Int) -> String = { number in + number >= 100 ? "99+" : "\(number)" } - return String(format: "Show %@ servers", displayNumber(exitCount)) + return String(format: "Show %@ servers", displayNumber(numberOfServers)) } } diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift index f6cba86133..c26c50655c 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift @@ -13,78 +13,77 @@ struct RelayFilterCellFactory: @preconcurrency CellFactoryProtocol { let tableView: UITableView func makeCell(for item: RelayFilterDataSource.Item, indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: item.reuseIdentifier.rawValue, for: indexPath) + let cell = tableView.dequeueReusableCell( + withIdentifier: RelayFilterDataSource.CellReuseIdentifiers.allCases[indexPath.section].rawValue, + for: indexPath + ) configureCell(cell, item: item, indexPath: indexPath) return cell } func configureCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item, indexPath: IndexPath) { - switch item { + switch item.type { case .ownershipAny, .ownershipOwned, .ownershipRented: - configureOwnershipCell(cell, item: item) + configureOwnershipCell(cell as? SelectableSettingsCell, item: item) case .allProviders, .provider: - configureProviderCell(cell, item: item) + configureProviderCell(cell as? CheckableSettingsCell, item: item) } } - private func configureOwnershipCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item) { - guard let cell = cell as? SelectableSettingsCell else { return } - - var title = "" - switch item { - case .ownershipAny: - title = "Any" - cell.setAccessibilityIdentifier(.ownershipAnyCell) - case .ownershipOwned: - title = "Mullvad owned only" - cell.setAccessibilityIdentifier(.ownershipMullvadOwnedCell) - case .ownershipRented: - title = "Rented only" - cell.setAccessibilityIdentifier(.ownershipRentedCell) - default: - assertionFailure("Item mismatch. Got: \(item)") - } + private func configureOwnershipCell(_ cell: SelectableSettingsCell?, item: RelayFilterDataSource.Item) { + guard let cell = cell else { return } cell.titleLabel.text = NSLocalizedString( "RELAY_FILTER_CELL_LABEL", tableName: "Relay filter ownership cell", - value: title, + value: item.name, comment: "" ) + let accessibilityIdentifier: AccessibilityIdentifier + switch item.type { + case .ownershipAny: + accessibilityIdentifier = .ownershipAnyCell + case .ownershipOwned: + accessibilityIdentifier = .ownershipMullvadOwnedCell + case .ownershipRented: + accessibilityIdentifier = .ownershipRentedCell + default: + assertionFailure("Unexpected ownership item: \(item)") + return + } + + cell.setAccessibilityIdentifier(accessibilityIdentifier) cell.applySubCellStyling() } - private func configureProviderCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item) { - guard let cell = cell as? CheckableSettingsCell else { return } - - let title: String - - switch item { - case .allProviders: - title = "All providers" - setFontWeight(.semibold, to: cell.titleLabel) - case let .provider(name): - title = name - setFontWeight(.regular, to: cell.titleLabel) - default: - title = "" - assertionFailure("Item mismatch. Got: \(item)") - } + private func configureProviderCell(_ cell: CheckableSettingsCell?, item: RelayFilterDataSource.Item) { + guard let cell = cell else { return } + let alpha = item.isEnabled ? 1.0 : 0.5 cell.titleLabel.text = NSLocalizedString( "RELAY_FILTER_CELL_LABEL", tableName: "Relay filter provider cell", - value: title, + value: item.name, comment: "" ) + cell.detailTitleLabel.text = item.description + + if item.type == .allProviders { + setFontWeight(.semibold, to: cell.titleLabel) + } else { + setFontWeight(.regular, to: cell.titleLabel) + } cell.applySubCellStyling() cell.setAccessibilityIdentifier(.relayFilterProviderCell) + cell.titleLabel.alpha = alpha + cell.detailTitleLabel.alpha = alpha + cell.detailTitleLabel.textColor = cell.titleLabel.textColor } private func setFontWeight(_ weight: UIFont.Weight, to label: UILabel) { - label.font = UIFont.systemFont(ofSize: label.font.pointSize, weight: .semibold) + label.font = UIFont.systemFont(ofSize: label.font.pointSize, weight: weight) } } diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift index 4c1905cf53..d90bf865c0 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift @@ -10,31 +10,14 @@ import Combine import MullvadREST import MullvadTypes import UIKit - final class RelayFilterDataSource: UITableViewDiffableDataSource< RelayFilterDataSource.Section, RelayFilterDataSource.Item > { - private var tableView: UITableView? + private weak var tableView: UITableView? private var viewModel: RelayFilterViewModel - private var disposeBag = Set<Combine.AnyCancellable>() private let relayFilterCellFactory: RelayFilterCellFactory - - var selectedOwnershipItem: Item { - guard let selectedIndexPath = getSelectedIndexPaths(in: .ownership).first, - let selectedItem = itemIdentifier(for: selectedIndexPath) - else { - return .ownershipAny - } - - return selectedItem - } - - var selectedProviderItems: [Item] { - return getSelectedIndexPaths(in: .providers).compactMap { indexPath in - itemIdentifier(for: indexPath) - } - } + private var disposeBag = Set<Combine.AnyCancellable>() init(tableView: UITableView, viewModel: RelayFilterViewModel) { self.tableView = tableView @@ -47,72 +30,43 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource< relayFilterCellFactory.makeCell(for: itemIdentifier, indexPath: indexPath) } - registerClasses() + registerCells() createDataSnapshot() - tableView.delegate = self + setupBindings() + } + + private func registerCells() { + CellReuseIdentifiers.allCases.forEach { tableView?.register( + $0.reusableViewClass, + forCellReuseIdentifier: $0.rawValue + ) } + HeaderFooterReuseIdentifiers.allCases.forEach { tableView?.register( + $0.reusableViewClass, + forHeaderFooterViewReuseIdentifier: $0.rawValue + ) } + } - viewModel.$relayFilter + private func setupBindings() { + viewModel + .$relayFilter + .dropFirst() + .removeDuplicates() + .receive(on: DispatchQueue.main) .sink { [weak self] filter in self?.updateDataSnapshot(filter: filter) } .store(in: &disposeBag) } - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - switch getSection(for: indexPath) { - case .ownership: - if viewModel.ownership(for: itemIdentifier(for: indexPath)) == viewModel.relayFilter.ownership { - cell.setSelected(true, animated: false) - } - case .providers: - switch viewModel.relayFilter.providers { - case .any: - cell.setSelected(true, animated: false) - case let .only(providers): - switch itemIdentifier(for: indexPath) { - case .allProviders: - let allProvidersAreSelected = providers.count == viewModel.uniqueProviders.count - if allProvidersAreSelected { - cell.setSelected(true, animated: false) - } - case let .provider(name): - if providers.contains(name) { - cell.setSelected(true, animated: false) - } - default: - break - } - } - } - } - - private func registerClasses() { - CellReuseIdentifiers.allCases.forEach { cellIdentifier in - tableView?.register( - cellIdentifier.reusableViewClass, - forCellReuseIdentifier: cellIdentifier.rawValue - ) - } - - HeaderFooterReuseIdentifiers.allCases.forEach { reuseIdentifier in - tableView?.register( - reuseIdentifier.reusableViewClass, - forHeaderFooterViewReuseIdentifier: reuseIdentifier.rawValue - ) - } - } - private func createDataSnapshot() { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() snapshot.appendSections(Section.allCases) - - applySnapshot(snapshot, animated: false) + apply(snapshot, animatingDifferences: false) } - func updateDataSnapshot(filter: RelayFilter? = nil) { + private func updateDataSnapshot(filter: RelayFilter) { let oldSnapshot = snapshot() - var newSnapshot = NSDiffableDataSourceSnapshot<Section, Item>() newSnapshot.appendSections(Section.allCases) @@ -124,16 +78,14 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource< } case .providers: if !oldSnapshot.itemIdentifiers(inSection: section).isEmpty { - let ownership = (filter ?? viewModel.relayFilter).ownership - let items = viewModel.availableProviders(for: ownership).map { Item.provider($0) } - - newSnapshot.appendItems([.allProviders], toSection: .providers) - newSnapshot.appendItems(items, toSection: .providers) + newSnapshot.appendItems( + [Item.allProviders] + viewModel.availableProviders(for: filter.ownership), + toSection: .providers + ) + applySnapshot(newSnapshot, animated: false) } } } - - applySnapshot(newSnapshot, animated: false) } private func applySnapshot( @@ -143,48 +95,117 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource< ) { apply(snapshot, animatingDifferences: animated) { [weak self] in guard let self else { return } - updateSelection(from: viewModel.relayFilter) completion?() } } private func updateSelection(from filter: RelayFilter) { - if let ownershipItem = viewModel.ownershipItem(for: filter.ownership) { - selectRow(true, at: indexPath(for: ownershipItem)) + tableView?.indexPathsForSelectedRows?.forEach { selectRow(false, at: $0) } + + if let ownership = viewModel.ownershipItem(for: filter.ownership), + let ownershipIndexPath = indexPath(for: ownership) { + selectRow(true, at: ownershipIndexPath) } switch filter.providers { case .any: selectAllProviders(true) case let .only(providers): + selectAllProviders(false) providers.forEach { providerName in - if let providerItem = viewModel.providerItem(for: providerName) { - selectRow(true, at: indexPath(for: providerItem)) - } + selectRow(true, at: indexPath(for: viewModel.providerItem(for: providerName))) } - updateAllProvidersSelection() } } + private func isItemSelected(_ item: Item, for filter: RelayFilter) -> Bool { + switch item.type { + case .ownershipAny, .ownershipOwned, .ownershipRented: + return viewModel.ownership(for: item) == filter.ownership + case .allProviders: + return filter.providers == .any + case .provider: + return switch filter.providers { + case .any: + true + case let .only(providers): + providers.contains(item.name) + } + } + } + private func updateAllProvidersSelection() { let selectedCount = getSelectedIndexPaths(in: .providers).count let providerCount = viewModel.availableProviders(for: viewModel.relayFilter.ownership).count + selectRow(selectedCount == providerCount, at: indexPath(for: .allProviders)) + } + + private func handleCollapseOwnership(isExpanded: Bool) { + var newSnapshot = snapshot() + if isExpanded { + newSnapshot.deleteItems(Item.ownerships) + } else { + newSnapshot.appendItems(Item.ownerships, toSection: .ownership) + } + applySnapshot(newSnapshot, animated: !isExpanded) + } + + private func handleCollapseProviders(isExpanded: Bool) { + let currentSnapshot = snapshot() + var newSnapshot = currentSnapshot + + if isExpanded { + let items = newSnapshot.itemIdentifiers(inSection: .providers) + newSnapshot.deleteItems(items) + } else { + newSnapshot.appendItems( + [Item.allProviders] + viewModel.availableProviders(for: viewModel.relayFilter.ownership), + toSection: .providers + ) + } + applySnapshot(newSnapshot, animated: !isExpanded) + } + + private func selectRow(_ select: Bool, at indexPath: IndexPath?) { + guard let indexPath else { return } + + if select { + tableView?.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } else { + tableView?.deselectRow(at: indexPath, animated: false) + } + } - if selectedCount == providerCount { - selectRow(true, at: indexPath(for: .allProviders)) + private func selectAllProviders(_ select: Bool) { + let providerItems = snapshot().itemIdentifiers(inSection: .providers) + + providerItems.forEach { providerItem in + selectRow(select, at: indexPath(for: providerItem)) } } + + private func getSelectedIndexPaths(in section: Section) -> [IndexPath] { + let sectionIndex = snapshot().indexOfSection(section) + + return tableView?.indexPathsForSelectedRows?.filter { indexPath in + indexPath.section == sectionIndex + } ?? [] + } + + private func getSection(for indexPath: IndexPath) -> Section { + return snapshot().sectionIdentifiers[indexPath.section] + } } +// MARK: - UITableViewDelegate + extension RelayFilterDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { switch getSection(for: indexPath) { case .ownership: - if let selectedIndexPath = self.indexPath(for: selectedOwnershipItem) { - selectRow(false, at: selectedIndexPath) - } + selectRow(false, at: getSelectedIndexPaths(in: .ownership).first) case .providers: break } @@ -203,36 +224,17 @@ extension RelayFilterDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = itemIdentifier(for: indexPath) else { return } - - switch getSection(for: indexPath) { - case .ownership: - break - case .providers: - if item == .allProviders { - selectAllProviders(true) - } else { - updateAllProvidersSelection() - } - } - - viewModel.addItemToFilter(item) + viewModel.toggleItem(item) } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { guard let item = itemIdentifier(for: indexPath) else { return } + viewModel.toggleItem(item) + } - switch getSection(for: indexPath) { - case .ownership: - break - case .providers: - if item == .allProviders { - selectAllProviders(false) - } else { - selectRow(false, at: self.indexPath(for: .allProviders)) - } - } - - viewModel.removeItemFromFilter(item) + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let item = itemIdentifier(for: indexPath) else { return } + cell.setSelected(isItemSelected(item, for: viewModel.relayFilter), animated: false) } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { @@ -263,18 +265,14 @@ extension RelayFilterDataSource: UITableViewDelegate { view.didCollapseHandler = { [weak self] headerView in guard let self else { return } - - var snapshot = snapshot() - switch sectionId { case .ownership: - handleCollapseOwnership(snapshot: &snapshot, isExpanded: headerView.isExpanded) + handleCollapseOwnership(isExpanded: headerView.isExpanded) case .providers: - handleCollapseProviders(snapshot: &snapshot, isExpanded: headerView.isExpanded) + handleCollapseProviders(isExpanded: headerView.isExpanded) } headerView.isExpanded.toggle() - applySnapshot(snapshot, animated: true) } return view @@ -287,109 +285,4 @@ extension RelayFilterDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { return UIMetrics.TableView.separatorHeight } - - private func selectRow(_ select: Bool, at indexPath: IndexPath?) { - guard let indexPath else { return } - - if select { - tableView?.selectRow(at: indexPath, animated: false, scrollPosition: .none) - } else { - tableView?.deselectRow(at: indexPath, animated: false) - } - } - - private func getSelectedIndexPaths(in section: Section) -> [IndexPath] { - let sectionIndex = snapshot().indexOfSection(section) - - return tableView?.indexPathsForSelectedRows?.filter { indexPath in - indexPath.section == sectionIndex - } ?? [] - } - - private func getSection(for indexPath: IndexPath) -> Section { - return snapshot().sectionIdentifiers[indexPath.section] - } - - private func selectAllProviders(_ select: Bool) { - let providerItems = snapshot().itemIdentifiers(inSection: .providers) - - providerItems.forEach { providerItem in - selectRow(select, at: indexPath(for: providerItem)) - } - } - - private func handleCollapseOwnership( - snapshot: inout NSDiffableDataSourceSnapshot<RelayFilterDataSource.Section, RelayFilterDataSource.Item>, - isExpanded: Bool - ) { - if isExpanded { - snapshot.deleteItems(Item.ownerships) - } else { - snapshot.appendItems(Item.ownerships, toSection: .ownership) - } - } - - private func handleCollapseProviders( - snapshot: inout NSDiffableDataSourceSnapshot<RelayFilterDataSource.Section, RelayFilterDataSource.Item>, - isExpanded: Bool - ) { - if isExpanded { - let items = snapshot.itemIdentifiers(inSection: .providers) - snapshot.deleteItems(items) - } else { - let items = viewModel.availableProviders(for: viewModel.relayFilter.ownership).map { Item.provider($0) } - snapshot.appendItems([.allProviders], toSection: .providers) - snapshot.appendItems(items, toSection: .providers) - } - } -} - -extension RelayFilterDataSource { - enum CellReuseIdentifiers: String, CaseIterable { - case ownershipCell - case providerCell - - var reusableViewClass: AnyClass { - switch self { - case .ownershipCell: - return SelectableSettingsCell.self - case .providerCell: - return CheckableSettingsCell.self - } - } - } - - enum HeaderFooterReuseIdentifiers: String, CaseIterable { - case section - - var reusableViewClass: AnyClass { - return SettingsHeaderView.self - } - } - - enum Section: Hashable, CaseIterable { - case ownership - case providers - } - - enum Item: Hashable { - case ownershipAny - case ownershipOwned - case ownershipRented - case allProviders - case provider(_ name: String) - - static var ownerships: [Item] { - return [.ownershipAny, .ownershipOwned, .ownershipRented] - } - - var reuseIdentifier: CellReuseIdentifiers { - switch self { - case .ownershipAny, .ownershipOwned, .ownershipRented: - return .ownershipCell - case .allProviders, .provider: - return .providerCell - } - } - } } diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift new file mode 100644 index 0000000000..cc590c0105 --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift @@ -0,0 +1,83 @@ +// +// RelayFilterDataSourceItem.swift +// MullvadVPN +// +// Created by Mojgan on 2025-03-05. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension RelayFilterDataSource { + enum Section: CaseIterable { case ownership, providers } + + struct Item: Hashable, Comparable { + let name: String + var description = "" + let type: ItemType + let isEnabled: Bool + + enum ItemType: Hashable { + case ownershipAny, ownershipOwned, ownershipRented, allProviders, provider + } + + static var ownerships: [Item] { + [ + Item(name: NSLocalizedString( + "RELAY_FILTER_ANY_LABEL", + tableName: "RelayFilter", + value: "Any", + comment: "" + ), type: .ownershipAny, isEnabled: true), + + Item(name: NSLocalizedString( + "RELAY_FILTER_OWNED_LABEL", + tableName: "RelayFilter", + value: "Owned", + comment: "" + ), type: .ownershipOwned, isEnabled: true), + Item(name: NSLocalizedString( + "RELAY_FILTER_RENTED_LABEL", + tableName: "RelayFilter", + value: "Rented", + comment: "" + ), type: .ownershipRented, isEnabled: true), + ] + } + + static var allProviders: Item { + Item(name: NSLocalizedString( + "RELAY_FILTER_ALL_PROVIDERS_LABEL", + tableName: "RelayFilter", + value: "All Providers", + comment: "" + ), type: .allProviders, isEnabled: true) + } + + static func < (lhs: Item, rhs: Item) -> Bool { + let nameComparison = lhs.name.caseInsensitiveCompare(rhs.name) + return nameComparison == .orderedAscending + } + } +} + +// MARK: - Cell Identifiers + +extension RelayFilterDataSource { + enum CellReuseIdentifiers: String, CaseIterable { + case ownershipCell, providerCell + + var reusableViewClass: AnyClass { + switch self { + case .ownershipCell: return SelectableSettingsCell.self + case .providerCell: return CheckableSettingsCell.self + } + } + } + + enum HeaderFooterReuseIdentifiers: String, CaseIterable { + case section + + var reusableViewClass: AnyClass { SettingsHeaderView.self } + } +} diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift index 9873aa8dfa..510b617f8f 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift @@ -31,7 +31,7 @@ class RelayFilterViewController: UIViewController { label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.font = .preferredFont(forTextStyle: .body) - label.textColor = .secondaryTextColor + label.textColor = .primaryTextColor label.textAlignment = .center return label }() @@ -85,6 +85,7 @@ class RelayFilterViewController: UIViewController { tableView.estimatedRowHeight = 60 tableView.estimatedSectionHeaderHeight = tableView.estimatedRowHeight tableView.allowsMultipleSelection = true + tableView.isMultipleTouchEnabled = false view.addSubview(tableView) buttonContainerView.addArrangedSubview(descriptionLabel) @@ -105,15 +106,14 @@ class RelayFilterViewController: UIViewController { private func setupDataSource() { viewModel .$relayFilter - .receive(on: DispatchQueue.main) .removeDuplicates() .sink { [weak self] filter in guard let self else { return } let filterDescriptor = viewModel.getFilteredRelays(filter) + descriptionLabel.alpha = filterDescriptor.isEnabled ? 1.0 : 0.5 applyButton.isEnabled = filterDescriptor.isEnabled applyButton.setTitle(filterDescriptor.title, for: .normal) descriptionLabel.text = filterDescriptor.description - descriptionLabel.isEnabled = filterDescriptor.isEnabled } .store(in: &disposeBag) dataSource = RelayFilterDataSource(tableView: tableView, viewModel: viewModel) diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift index 267242cc8d..b5c64ffe6b 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift @@ -11,139 +11,187 @@ import MullvadREST import MullvadSettings import MullvadTypes -class RelayFilterViewModel { +final class RelayFilterViewModel { + @Published var relayFilter: RelayFilter + private var settings: LatestTunnelSettings - private let relaysWithLocation: LocationRelays private let relaySelectorWrapper: RelaySelectorWrapper - @Published var relayFilter: RelayFilter + private let relaysWithLocation: LocationRelays + private var relayCandidatesForAny: RelayCandidates init(settings: LatestTunnelSettings, relaySelectorWrapper: RelaySelectorWrapper) { self.settings = settings self.relaySelectorWrapper = relaySelectorWrapper - relaysWithLocation = if let cachedResponse = try? relaySelectorWrapper.relayCache.read().relays { - LocationRelays(relays: cachedResponse.wireguard.relays, locations: cachedResponse.locations) + self.relayFilter = settings.relayConstraints.filter.value ?? RelayFilter() + + // Retrieve all available relays that satisfy the `any` constraint. + // This constraint ensures that the selected relays are associated with the current tunnel settings + // and serve as the primary source of truth for subsequent filtering operations. + // Further filtering will be applied based on specific criteria such as `ownership` or `provider`. + var copy = settings + copy.relayConstraints.filter = .any + if let relayCandidatesForAny = try? relaySelectorWrapper.findCandidates(tunnelSettings: copy) { + self.relayCandidatesForAny = relayCandidatesForAny } else { - LocationRelays(relays: [], locations: [:]) + self.relayCandidatesForAny = RelayCandidates(entryRelays: nil, exitRelays: []) } - self.relayFilter = if case let .only(filter) = settings.relayConstraints.filter { - filter + // Directly setting relaysWithLocation in constructor + if let cachedResponse = try? relaySelectorWrapper.relayCache.read().relays { + self.relaysWithLocation = LocationRelays( + relays: cachedResponse.wireguard.relays, + locations: cachedResponse.locations + ) } else { - RelayFilter() + self.relaysWithLocation = LocationRelays(relays: [], locations: [:]) } } - private var relays: [REST.ServerRelay] { - relaysWithLocation.relays - } + private var relays: [REST.ServerRelay] { relaysWithLocation.relays } var uniqueProviders: [String] { - Set(relays.map { $0.provider }).caseInsensitiveSorted() + extractProviders(from: relays) } var ownedProviders: [String] { - Set(relays.filter { $0.owned == true }.map { $0.provider }).caseInsensitiveSorted() + extractProviders(from: relays.filter { $0.owned == true }) } var rentedProviders: [String] { - Set(relays.filter { $0.owned == false }.map { $0.provider }).caseInsensitiveSorted() + extractProviders(from: relays.filter { $0.owned == false }) } - func addItemToFilter(_ item: RelayFilterDataSource.Item) { - switch item { + // MARK: - public Methods + + func toggleItem(_ item: RelayFilterDataSource.Item) { + switch item.type { case .ownershipAny, .ownershipOwned, .ownershipRented: relayFilter.ownership = ownership(for: item) ?? .any case .allProviders: - relayFilter.providers = .any - case let .provider(name): - switch relayFilter.providers { - case .any: - relayFilter.providers = .only([name]) - case var .only(providers): - if !providers.contains(name) { - providers.append(name) - providers.caseInsensitiveSort() - - if providers == availableProviders(for: relayFilter.ownership) { - relayFilter.providers = .any - } else { - relayFilter.providers = .only(providers) - } - } - } + relayFilter.providers = relayFilter.providers == .any ? .only([]) : .any + case .provider: + toggleProvider(item.name) } } - func removeItemFromFilter(_ item: RelayFilterDataSource.Item) { - switch item { - case .ownershipAny, .ownershipOwned, .ownershipRented: - break - case .allProviders: - relayFilter.providers = .only([]) - case let .provider(name): - switch relayFilter.providers { - case .any: - var providers = availableProviders(for: relayFilter.ownership) - providers.removeAll { $0 == name } - relayFilter.providers = .only(providers) - case var .only(providers): - providers.removeAll { $0 == name } - relayFilter.providers = .only(providers) - } - } + func availableProviders(for ownership: RelayFilter.Ownership) -> [RelayFilterDataSource.Item] { + providers(for: ownership) + .map { + providerItem(for: $0) + }.sorted() } - func providerItem(for providerName: String?) -> RelayFilterDataSource.Item? { - return .provider(providerName ?? "") + func ownership(for item: RelayFilterDataSource.Item) -> RelayFilter.Ownership? { + let ownershipMapping: [RelayFilterDataSource.Item.ItemType: RelayFilter.Ownership] = [ + .ownershipAny: .any, + .ownershipOwned: .owned, + .ownershipRented: .rented, + ] + + return ownershipMapping[item.type] } - func availableProviders(for ownership: RelayFilter.Ownership) -> [String] { - switch ownership { - case .any: - return uniqueProviders - case .owned: - return ownedProviders - case .rented: - return rentedProviders - } + func ownershipItem(for ownership: RelayFilter.Ownership) -> RelayFilterDataSource.Item? { + let ownershipMapping: [RelayFilter.Ownership: RelayFilterDataSource.Item.ItemType] = [ + .any: .ownershipAny, + .owned: .ownershipOwned, + .rented: .ownershipRented, + ] + + return RelayFilterDataSource.Item.ownerships.first { $0.type == ownershipMapping[ownership] } } - func ownership(for item: RelayFilterDataSource.Item?) -> RelayFilter.Ownership? { - switch item { - case .ownershipAny: - return .any - case .ownershipOwned: - return .owned - case .ownershipRented: - return .rented - default: - return nil - } + func providerItem(for providerName: String) -> RelayFilterDataSource.Item { + let isDaitaEnabled = settings.daita.daitaState.isEnabled + let isProviderEnabled = isProviderEnabled(for: providerName) + let isFilterable = getFilteredRelays(relayFilter).isEnabled + + let statusText = isDaitaEnabled + ? NSLocalizedString( + "ENABLED_LABEL", + tableName: "RelayFilter", + value: "enabled", + comment: "" + ) + : "" + + return RelayFilterDataSource.Item( + name: providerName, + description: isDaitaEnabled && isProviderEnabled + ? String( + format: NSLocalizedString( + "RELAY_FILTER_PROVIDER_DESCRIPTION_FORMAT_LABEL", + tableName: "RelayFilter", + value: "DAITA-%@", + comment: "Format for DAITA provider description" + ), statusText + ) + : "", + type: .provider, + // If the current filter is valid, return true immediately. + // Otherwise, check if the provider is enabled when filtering specifically by the given provider name. + isEnabled: isFilterable || isProviderEnabled + ) } - func ownershipItem(for ownership: RelayFilter.Ownership?) -> RelayFilterDataSource.Item? { + func getFilteredRelays(_ relayFilter: RelayFilter) -> FilterDescriptor { + return FilterDescriptor( + relayFilterResult: RelayCandidates( + entryRelays: relayCandidatesForAny.entryRelays?.filter { + RelaySelector.relayMatchesFilter($0.relay, filter: relayFilter) + }, + exitRelays: relayCandidatesForAny.exitRelays.filter { + RelaySelector.relayMatchesFilter($0.relay, filter: relayFilter) + } + ), + settings: settings + ) + } + + // MARK: - private Methods + + private func providers(for ownership: RelayFilter.Ownership) -> [String] { switch ownership { case .any: - return .ownershipAny + uniqueProviders case .owned: - return .ownershipOwned + ownedProviders case .rented: - return .ownershipRented - default: - return nil + rentedProviders } } - func getFilteredRelays(_ relayFilter: RelayFilter) -> FilterDescriptor { - settings.relayConstraints.filter = .only(relayFilter) - do { - let result = try relaySelectorWrapper.findCandidates(tunnelSettings: settings) - return FilterDescriptor(relayFilterResult: result, settings: settings) - } catch { - return FilterDescriptor( - relayFilterResult: RelayCandidates(entryRelays: [], exitRelays: []), - settings: settings - ) + private func toggleProvider(_ name: String) { + switch relayFilter.providers { + case .any: + // If currently "any", switch to only the selected provider + var providers = providers(for: relayFilter.ownership) + providers.removeAll { $0 == name } + relayFilter.providers = .only(providers.map { $0 }) + case var .only(selectedProviders): + if selectedProviders.contains(name) { + // If provider exists, remove it + selectedProviders.removeAll { $0 == name } + } else { + // Otherwise, add it + selectedProviders.append(name) + } + + // If all available providers are selected, switch back to "any" + relayFilter.providers = selectedProviders.isEmpty + ? .only([]) + : (selectedProviders == providers(for: relayFilter.ownership) ? .any : .only(selectedProviders)) } } + + private func extractProviders(from relays: [REST.ServerRelay]) -> [String] { + Set(relays.map { $0.provider }).caseInsensitiveSorted() + } + + private func isProviderEnabled(for providerName: String) -> Bool { + // Check if the provider is enabled when filtering specifically by the given provider name. + return getFilteredRelays( + RelayFilter(ownership: relayFilter.ownership, providers: .only([providerName])) + ).isEnabled + } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift index 9cb5f0bec4..40775cbea2 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift @@ -28,7 +28,6 @@ class LocationSectionHeaderFooterView: UIView, UIContentView { containerView.axis = .horizontal containerView.spacing = 8 containerView.isLayoutMarginsRelativeArrangement = true - containerView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) return containerView }() @@ -64,7 +63,7 @@ class LocationSectionHeaderFooterView: UIView, UIContentView { containerView.addArrangedSubview(nameLabel) containerView.addArrangedSubview(actionButton) addConstrainedSubviews([containerView]) { - containerView.pinEdgesToSuperview() + containerView.pinEdgesToSuperviewMargins() actionButton.heightAnchor.constraint(equalTo: heightAnchor) actionButton.widthAnchor.constraint(equalTo: actionButton.heightAnchor) } @@ -86,8 +85,7 @@ class LocationSectionHeaderFooterView: UIView, UIContentView { } private func applyAppearance() { - let leadingInset = UIMetrics.locationCellLayoutMargins.leading + 6 - directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: leadingInset, bottom: 8, trailing: 24) + directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift index 31cbea093e..5fa22383bf 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift @@ -60,10 +60,7 @@ final class LocationViewControllerWrapper: UIViewController { var onNewSettings: ((LatestTunnelSettings) -> Void)? private var relayFilter: RelayFilter { - if case let .only(filter) = settings.relayConstraints.filter { - return filter - } - return RelayFilter() + settings.relayConstraints.filter.value ?? RelayFilter() } init( @@ -126,7 +123,7 @@ final class LocationViewControllerWrapper: UIViewController { private func setRelaysWithLocation() { let emptyResult = LocationRelays(relays: [], locations: [:]) - let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: self.settings) + let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: settings) entryLocationViewController?.setDaitaChip(settings.daita.isDirectOnly) exitLocationViewController.setDaitaChip(settings.daita.isDirectOnly && !settings.tunnelMultihopState.isEnabled) entryLocationViewController?.toggleDaitaAutomaticRouting(isEnabled: settings.daita.isAutomaticRouting) diff --git a/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift index 9eb5dfe1ad..64812ff6c5 100644 --- a/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift +++ b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift @@ -11,6 +11,12 @@ import UIKit class CheckableSettingsCell: SettingsCell { let checkboxView = CheckboxView() + var isEnabled = true { + didSet { + titleLabel.isEnabled = isEnabled + } + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) |
