diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 99c369c428d7..dabfa71078a4 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -58,7 +58,7 @@ 44DF8AC42BF20BD200869CA4 /* PacketTunnelActor+PostQuantum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DF8AC32BF20BD200869CA4 /* PacketTunnelActor+PostQuantum.swift */; }; 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4AF2940A47300C23744 /* TunnelConfiguration.swift */; }; 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4B12940A48700C23744 /* TunnelStore.swift */; }; - 5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Split.swift */; }; + 5807E2C02432038B00F5FF30 /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */; }; 580810E52A30E13A00B74552 /* DeviceStateAccessorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580810E42A30E13A00B74552 /* DeviceStateAccessorProtocol.swift */; }; 580810E82A30E15500B74552 /* DeviceCheckRemoteServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580810E72A30E15500B74552 /* DeviceCheckRemoteServiceProtocol.swift */; }; 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580909D22876D09A0078138D /* RevokedDeviceViewController.swift */; }; @@ -620,7 +620,7 @@ 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */; }; 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; }; 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */; }; - 7AF9BE972A41C71F00DBFEDB /* RelayFilterChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */; }; + 7AF9BE972A41C71F00DBFEDB /* ChipViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */; }; 850201DB2B503D7700EF8C96 /* RelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DA2B503D7700EF8C96 /* RelayTests.swift */; }; 850201DD2B503D8C00EF8C96 /* SelectLocationPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */; }; 850201DF2B5040A500EF8C96 /* TunnelControlPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DE2B5040A500EF8C96 /* TunnelControlPage.swift */; }; @@ -740,7 +740,7 @@ A9A5F9EE2ACB05160083449F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FAE67828F83CA50033DD93 /* RESTCreateApplePaymentResponse+Localization.swift */; }; A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; }; A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */; }; - A9A5F9F12ACB05160083449F /* String+Split.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Split.swift */; }; + A9A5F9F12ACB05160083449F /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */; }; A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C8191729FAA2C400DEB1B4 /* NotificationConfiguration.swift */; }; A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B75402668FD7700DEF7E9 /* AccountExpirySystemNotificationProvider.swift */; }; A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */; }; @@ -953,6 +953,8 @@ F0ACE3332BE516F1006D5333 /* RESTRequestExecutor+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */; }; F0ACE3362BE517D6006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; }; F0ACE3372BE517F1006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; }; + F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */; }; + F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */; }; F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */; }; F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */; }; F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F02BF751E300817A42 /* RelayWithDistance.swift */; }; @@ -1407,7 +1409,7 @@ 5803B4AF2940A47300C23744 /* TunnelConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelConfiguration.swift; sourceTree = ""; }; 5803B4B12940A48700C23744 /* TunnelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStore.swift; sourceTree = ""; }; 58059DDD28468158002B1049 /* OutputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputOperation.swift; sourceTree = ""; }; - 5807E2BF2432038B00F5FF30 /* String+Split.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Split.swift"; sourceTree = ""; }; + 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; 5807E2C1243203D000F5FF30 /* StringTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = ""; }; 580810E42A30E13A00B74552 /* DeviceStateAccessorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStateAccessorProtocol.swift; sourceTree = ""; }; 580810E72A30E15500B74552 /* DeviceCheckRemoteServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceCheckRemoteServiceProtocol.swift; sourceTree = ""; }; @@ -1937,7 +1939,7 @@ 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = ""; }; 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = ""; }; 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; - 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterChipView.swift; sourceTree = ""; }; + 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewCell.swift; sourceTree = ""; }; 85006A8E2B73EF67004AD8FB /* MullvadVPNUITestsSmoke.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MullvadVPNUITestsSmoke.xctestplan; sourceTree = ""; }; 850201DA2B503D7700EF8C96 /* RelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayTests.swift; sourceTree = ""; }; 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationPage.swift; sourceTree = ""; }; @@ -2150,6 +2152,8 @@ F0ACE30A2BE4E478006D5333 /* MullvadMockData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadMockData.h; sourceTree = ""; }; F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProxyFactory.swift; sourceTree = ""; }; F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ServerRelaysResponse+Stubs.swift"; sourceTree = ""; }; + F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipCollectionView.swift; sourceTree = ""; }; + F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFlowLayout.swift; sourceTree = ""; }; F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = ""; }; F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = ""; }; F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = ""; }; @@ -2987,7 +2991,7 @@ 58A8EE5D2976DB00009C0F8D /* StorePaymentManagerError+Display.swift */, E158B35F285381C60002F069 /* String+AccountFormatting.swift */, 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */, - 5807E2BF2432038B00F5FF30 /* String+Split.swift */, + 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */, 58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */, 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */, 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */, @@ -3917,8 +3921,10 @@ 7AF9BE912A39F47D00DBFEDB /* RelayFilter */ = { isa = PBXGroup; children = ( + F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */, + F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */, + 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */, 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */, - 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */, 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */, 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */, 7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */, @@ -5337,7 +5343,7 @@ A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */, A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */, F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */, - A9A5F9F12ACB05160083449F /* String+Split.swift in Sources */, + A9A5F9F12ACB05160083449F /* String+Helpers.swift in Sources */, A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */, A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */, A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, @@ -5612,6 +5618,7 @@ 58C76A0B2A338E4300100D75 /* BackgroundTask.swift in Sources */, 7A9CCCC32A96302800DD6A34 /* ApplicationCoordinator.swift in Sources */, 5864AF0729C78843005B0CD9 /* SettingsCellFactory.swift in Sources */, + F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */, 587B75412668FD7800DEF7E9 /* AccountExpirySystemNotificationProvider.swift in Sources */, 587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */, F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */, @@ -5623,6 +5630,7 @@ F0EF50D32A8FA47E0031E8DF /* ChangeLogInteractor.swift in Sources */, 7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */, 58FF9FE22B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift in Sources */, + F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */, F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */, F041BE4F2C983C2B0083EC28 /* DAITASettingsPromptItem.swift in Sources */, 7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */, @@ -5791,7 +5799,7 @@ 585E820327F3285E00939F0E /* SendStoreReceiptOperation.swift in Sources */, 5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */, 585B4B8726D9098900555C4C /* TunnelStatusNotificationProvider.swift in Sources */, - 7AF9BE972A41C71F00DBFEDB /* RelayFilterChipView.swift in Sources */, + 7AF9BE972A41C71F00DBFEDB /* ChipViewCell.swift in Sources */, 063F026628FFE11C001FA09F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */, 58DF28A52417CB4B00E836B0 /* StorePaymentManager.swift in Sources */, 583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */, @@ -5825,7 +5833,7 @@ 7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */, 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */, 58CAFA002983FF0200BE19F7 /* LoginInteractor.swift in Sources */, - 5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */, + 5807E2C02432038B00F5FF30 /* String+Helpers.swift in Sources */, 58B26E242943520C00D5980C /* NotificationProviderProtocol.swift in Sources */, 5877F94E2A0A59AA0052D9E9 /* NotificationResponse.swift in Sources */, 7A6389E52B7E4247008E77E1 /* EditCustomListCoordinator.swift in Sources */, diff --git a/ios/MullvadVPN/Extensions/String+Split.swift b/ios/MullvadVPN/Extensions/String+Helpers.swift similarity index 93% rename from ios/MullvadVPN/Extensions/String+Split.swift rename to ios/MullvadVPN/Extensions/String+Helpers.swift index f62317343bbd..a3112819405b 100644 --- a/ios/MullvadVPN/Extensions/String+Split.swift +++ b/ios/MullvadVPN/Extensions/String+Helpers.swift @@ -1,5 +1,5 @@ // -// String+Split.swift +// String+Helpers.swift // MullvadVPN // // Created by pronebird on 27/03/2020. @@ -7,6 +7,7 @@ // import Foundation +import UIKit extension String { /// Returns the array of the longest possible subsequences of the given length. diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index ce108dceb84d..e1be91814cb3 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -110,10 +110,9 @@ enum UIMetrics { } enum FilterView { - static let labelSpacing: CGFloat = 5 static let interChipViewSpacing: CGFloat = 8 static let chipViewCornerRadius: CGFloat = 8 - static let chipViewLayoutMargins = UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8) + static let chipViewLayoutMargins = UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8) static let chipViewLabelSpacing: CGFloat = 7 } diff --git a/ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift b/ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift new file mode 100644 index 000000000000..f4c845064754 --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift @@ -0,0 +1,61 @@ +// +// ChipCollectionView.swift +// MullvadVPN +// +// Created by Mojgan on 2024-10-31. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UIKit + +class ChipCollectionView: UIView { + private var chips: [ChipConfiguration] = [] + private let cellReuseIdentifier = String(describing: ChipViewCell.self) + + private(set) lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: ChipFlowLayout()) + collectionView.contentInset = .zero + collectionView.backgroundColor = .clear + collectionView.translatesAutoresizingMaskIntoConstraints = false + return collectionView + }() + + init() { + super.init(frame: .zero) + setupCollectionView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupCollectionView() + } + + private func setupCollectionView() { + collectionView.dataSource = self + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: cellReuseIdentifier) + addConstrainedSubviews([collectionView]) { + collectionView.pinEdgesToSuperview() + } + } + + func setChips(_ values: [ChipConfiguration]) { + chips = values + collectionView.reloadData() + } +} + +extension ChipCollectionView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return chips.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath) + cell.contentConfiguration = chips[indexPath.row] + return cell + } +} diff --git a/ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift b/ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift new file mode 100644 index 000000000000..f54abf609569 --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift @@ -0,0 +1,45 @@ +// +// ChipFlowLayout.swift +// MullvadVPN +// +// Created by Mojgan on 2024-10-31. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class ChipFlowLayout: UICollectionViewCompositionalLayout { + init() { + super.init { _, _ -> NSCollectionLayoutSection? in + // Create an item with flexible size + let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(50), heightDimension: .estimated(20)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: .fixed(0), + top: .fixed(0), + trailing: .fixed(0), + bottom: .fixed(0) + ) + + // Create a group that fills the available width and wraps items with proper spacing + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(20) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = .fixed(UIMetrics.FilterView.interChipViewSpacing) + group.contentInsets = .zero + + // Create a section with zero inter-group spacing and no content insets + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = UIMetrics.FilterView.interChipViewSpacing + section.contentInsets = .zero + + return section + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift b/ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift new file mode 100644 index 000000000000..96737788a16e --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift @@ -0,0 +1,124 @@ +// +// ChipViewCell.swift +// MullvadVPN +// +// Created by Jon Petersson on 2023-06-20. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class ChipViewCell: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { + set(configuration: configuration) + } + } + + private let container = { + let container = UIView() + container.backgroundColor = .primaryColor + container.layer.cornerRadius = UIMetrics.FilterView.chipViewCornerRadius + container.layoutMargins = UIMetrics.FilterView.chipViewLayoutMargins + return container + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = .relayFilterChipLabel + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 1 + label.setContentCompressionResistancePriority(.required, for: .horizontal) + label.setContentHuggingPriority(.required, for: .horizontal) + return label + }() + + private let closeButton: IncreasedHitButton = { + let button = IncreasedHitButton() + var buttonConfiguration = UIButton.Configuration.plain() + buttonConfiguration.image = UIImage(resource: .iconCloseSml).withTintColor(.white.withAlphaComponent(0.6)) + buttonConfiguration.contentInsets = .zero + button.accessibilityIdentifier = .relayFilterChipCloseButton + button.configuration = buttonConfiguration + return button + }() + + private lazy var closeButtonActionHandler: UIAction = { + return UIAction { [weak self] action in + guard let self, + let chipConfiguration = configuration as? ChipConfiguration, + let action = chipConfiguration.didTapButton else { + return + } + action() + } + }() + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + addSubviews() + set(configuration: configuration) + } + + override init(frame: CGRect) { + self.configuration = ChipConfiguration(group: .filter, title: "", didTapButton: nil) + super.init(frame: .zero) + addSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func addSubviews() { + self.accessibilityIdentifier = .relayFilterChipView + + let stackView = UIStackView(arrangedSubviews: [titleLabel, closeButton]) + stackView.spacing = UIMetrics.FilterView.chipViewLabelSpacing + + container.addConstrainedSubviews([stackView]) { + stackView.pinEdgesToSuperviewMargins() + } + addConstrainedSubviews([container]) { + container.pinEdgesToSuperview() + } + } + + private func set(configuration: UIContentConfiguration) { + guard let chipConfiguration = configuration as? ChipConfiguration else { return } + container.backgroundColor = chipConfiguration.backgroundColor + titleLabel.text = chipConfiguration.title + titleLabel.textColor = chipConfiguration.textColor + titleLabel.font = chipConfiguration.font + closeButton.isHidden = chipConfiguration.didTapButton == nil + if chipConfiguration.didTapButton != nil { + closeButton.addAction(closeButtonActionHandler, for: .touchUpInside) + } else { + closeButton.removeAction(closeButtonActionHandler, for: .touchUpInside) + } + } +} + +// Custom content configuration +struct ChipConfiguration: UIContentConfiguration { + enum Group: Hashable { + case filter, settings + } + + var group: Group + var title: String + var textColor: UIColor = .white + var font = UIFont.preferredFont(forTextStyle: .caption1) + var backgroundColor: UIColor = .primaryColor + let didTapButton: (() -> Void)? + + func makeContentView() -> UIView & UIContentView { + return ChipViewCell(configuration: self) + } + + func updated(for state: UIConfigurationState) -> ChipConfiguration { + return self + } +} diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift deleted file mode 100644 index 3b6191e1aa12..000000000000 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// RelayFilterChipView.swift -// MullvadVPN -// -// Created by Jon Petersson on 2023-06-20. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -class RelayFilterChipView: UIView { - private let titleLabel: UILabel = { - let label = UILabel() - label.accessibilityIdentifier = .relayFilterChipLabel - label.font = UIFont.preferredFont(forTextStyle: .caption1) - label.adjustsFontForContentSizeCategory = true - label.textColor = .white - return label - }() - - let closeButton: IncreasedHitButton = { - let button = IncreasedHitButton() - button.setImage( - UIImage(resource: .iconCloseSml).withTintColor(.white.withAlphaComponent(0.6)), - for: .normal - ) - button.accessibilityIdentifier = .relayFilterChipCloseButton - return button - }() - - var didTapButton: (() -> Void)? - - init() { - super.init(frame: .zero) - - self.accessibilityIdentifier = .relayFilterChipView - - closeButton.addTarget(self, action: #selector(didTapButton(_:)), for: .touchUpInside) - - let container = UIStackView(arrangedSubviews: [titleLabel, closeButton]) - container.spacing = UIMetrics.FilterView.chipViewLabelSpacing - container.backgroundColor = .primaryColor - container.layer.cornerRadius = UIMetrics.FilterView.chipViewCornerRadius - container.layoutMargins = UIMetrics.FilterView.chipViewLayoutMargins - container.isLayoutMarginsRelativeArrangement = true - - addConstrainedSubviews([container]) { - container.pinEdgesToSuperview() - } - } - - func setTitle(_ text: String) { - titleLabel.text = text - } - - @objc private func didTapButton(_ sender: UIButton) { - didTapButton?() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift index e12ce5cf3708..53634790bfec 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift @@ -17,25 +17,23 @@ class RelayFilterView: UIView { private let titleLabel: UILabel = { let label = UILabel() - label.text = NSLocalizedString( "RELAY_FILTER_APPLIED_TITLE", tableName: "RelayFilter", value: "Filtered:", comment: "" ) - label.font = UIFont.preferredFont(forTextStyle: .caption1) label.adjustsFontForContentSizeCategory = true label.textColor = .white - return label }() - private let ownershipView = RelayFilterChipView() - private let providersView = RelayFilterChipView() - private let daitaView = RelayFilterChipView() + private var chips: [ChipConfiguration] = [] + private var chipsView = ChipCollectionView() + private var collectionViewHeightConstraint: NSLayoutConstraint! private var filter: RelayFilter? + private var contentSizeObservation: NSKeyValueObservation? var didUpdateFilter: ((RelayFilter) -> Void)? @@ -50,93 +48,125 @@ class RelayFilterView: UIView { } func setFilter(_ filter: RelayFilter) { + let filterChips = createFilterChips(for: filter) self.filter = filter - - ownershipView.isHidden = filter.ownership == .any - providersView.isHidden = filter.providers == .any - - switch filter.ownership { - case .any: - break - case .owned: - ownershipView.setTitle(localizedOwnershipText(for: "Owned")) - case .rented: - ownershipView.setTitle(localizedOwnershipText(for: "Rented")) - } - - switch filter.providers { - case .any: - providersView.isHidden = true - case let .only(providers): - providersView.setTitle(localizedProvidersText(for: providers.count)) - } + chips.removeAll(where: { $0.group == .filter }) + chips += filterChips + chipsView.setChips(chips) + hideIfNeeded() } func setDaita(_ enabled: Bool) { - daitaView.isHidden = !enabled + let text = NSLocalizedString( + "RELAY_FILTER_APPLIED_DAITA", + tableName: "RelayFilter", + value: "Setting: DAITA", + comment: "" + ) + chips.removeAll(where: { $0.title.contains(text) }) + if enabled { + chips.insert(ChipConfiguration(group: .settings, title: text, didTapButton: nil), at: 0) + } + chipsView.setChips(chips) + hideIfNeeded() } + // MARK: - Private + private func setUpViews() { - daitaView.setTitle(localizedDaitaText()) - daitaView.isHidden = true - daitaView.closeButton.isHidden = true + let dummyView = UIView() + dummyView.layoutMargins = UIMetrics.FilterView.chipViewLayoutMargins - ownershipView.isHidden = true - ownershipView.didTapButton = { [weak self] in - guard var filter = self?.filter else { return } + let contentContainer = UIStackView(arrangedSubviews: [dummyView, chipsView]) + contentContainer.distribution = .fill + contentContainer.alignment = .firstBaseline - filter.ownership = .any - self?.didUpdateFilter?(filter) - } + collectionViewHeightConstraint = chipsView.collectionView.heightAnchor + .constraint(equalToConstant: 8.0) + collectionViewHeightConstraint.isActive = true - providersView.isHidden = true - providersView.didTapButton = { [weak self] in - guard var filter = self?.filter else { return } - - filter.providers = .any - self?.didUpdateFilter?(filter) + dummyView.addConstrainedSubviews([titleLabel]) { + titleLabel.pinEdgesToSuperviewMargins() } - // Add a dummy view at the end to push content to the left. - let filterContainer = UIStackView(arrangedSubviews: [daitaView, ownershipView, providersView, UIView()]) - filterContainer.spacing = UIMetrics.FilterView.interChipViewSpacing - - let contentContainer = UIStackView(arrangedSubviews: [titleLabel, filterContainer]) - contentContainer.spacing = UIMetrics.FilterView.labelSpacing - addConstrainedSubviews([contentContainer]) { - contentContainer.pinEdges(.init([.top(7), .bottom(0)]), to: self) - contentContainer.pinEdges(.init([.leading(4), .trailing(4)]), to: layoutMarginsGuide) + contentContainer.pinEdgesToSuperview(PinnableEdges([.top(8.0), .bottom(8.0), .leading(4), .trailing(4)])) } + + // Add KVO for observing collectionView's contentSize changes + observeContentSize() } - private func localizedDaitaText() -> String { - return NSLocalizedString( - "RELAY_FILTER_APPLIED_DAITA", - tableName: "RelayFilter", - value: "Setting: DAITA", - comment: "" - ) + private func hideIfNeeded() { + isHidden = chips.isEmpty } - private func localizedOwnershipText(for string: String) -> String { - return NSLocalizedString( - "RELAY_FILTER_APPLIED_OWNERSHIP", - tableName: "RelayFilter", - value: string, - comment: "" - ) + private func createFilterChips(for filter: RelayFilter) -> [ChipConfiguration] { + var filterChips: [ChipConfiguration] = [] + + // Ownership Chip + if let ownershipChip = createOwnershipChip(for: filter.ownership) { + filterChips.append(ownershipChip) + } + + // Providers Chip + if let providersChip = createProvidersChip(for: filter.providers) { + filterChips.append(providersChip) + } + + return filterChips } - private func localizedProvidersText(for count: Int) -> String { - return String( - format: NSLocalizedString( - "RELAY_FILTER_APPLIED_PROVIDERS", + private func createOwnershipChip(for ownership: RelayFilter.Ownership) -> ChipConfiguration? { + switch ownership { + case .any: + return nil + case .owned, .rented: + let title = NSLocalizedString( + "RELAY_FILTER_APPLIED_OWNERSHIP", tableName: "RelayFilter", - value: "Providers: %d", + value: ownership == .owned ? "Owned" : "Rented", comment: "" - ), - count - ) + ) + return ChipConfiguration(group: .filter, title: title, didTapButton: { [weak self] in + guard var filter = self?.filter else { return } + filter.ownership = .any + self?.didUpdateFilter?(filter) + }) + } + } + + private func createProvidersChip(for providers: RelayConstraint<[String]>) -> ChipConfiguration? { + switch providers { + case .any: + return nil + case let .only(providerList): + let title = String( + format: NSLocalizedString( + "RELAY_FILTER_APPLIED_PROVIDERS", + tableName: "RelayFilter", + value: "Providers: %d", + comment: "" + ), + providerList.count + ) + return ChipConfiguration(group: .filter, title: title, didTapButton: { [weak self] in + guard var filter = self?.filter else { return } + filter.providers = .any + self?.didUpdateFilter?(filter) + }) + } + } + + private func observeContentSize() { + contentSizeObservation = chipsView.collectionView.observe(\.contentSize, options: [ + .new, + .old, + ]) { [weak self] _, change in + guard let self, let newSize = change.newValue else { return } + let height = newSize.height == .zero ? 8 : newSize.height + collectionViewHeightConstraint.constant = height > 80 ? 80 : height + layoutIfNeeded() // Update the layout + } } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index 5bd6021b96bc..16529c234003 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift @@ -36,10 +36,6 @@ final class LocationViewController: UIViewController { .lightContent } - var filterViewShouldBeHidden: Bool { - !shouldFilterDaita && (filter.ownership == .any) && (filter.providers == .any) - } - init( customListRepository: CustomListRepositoryProtocol, selectedRelays: RelaySelection, @@ -91,21 +87,12 @@ final class LocationViewController: UIViewController { func setRelaysWithLocation(_ relaysWithLocation: LocationRelays, filter: RelayFilter) { self.relaysWithLocation = relaysWithLocation self.filter = filter - filterView.setFilter(filter) - if filterViewShouldBeHidden { - filterView.isHidden = true - } else { - filterView.isHidden = false - } - dataSource?.setRelays(relaysWithLocation, selectedRelays: selectedRelays) } func setShouldFilterDaita(_ shouldFilterDaita: Bool) { self.shouldFilterDaita = shouldFilterDaita - - filterView.isHidden = filterViewShouldBeHidden filterView.setDaita(shouldFilterDaita) } @@ -187,8 +174,6 @@ final class LocationViewController: UIViewController { topContentView.axis = .vertical topContentView.addArrangedSubview(filterView) topContentView.addArrangedSubview(searchBar) - - filterView.isHidden = filterViewShouldBeHidden filterView.setDaita(shouldFilterDaita) filterView.didUpdateFilter = { [weak self] in