summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authormojganii <mojgan.jelodar@codic.se>2024-11-05 14:12:23 +0100
committerBug Magnet <marco.nikic@mullvad.net>2024-11-05 16:44:40 +0100
commitf93536f6b59278ef9f36fd0e557ea535e39c727b (patch)
tree538d24479450ba7d1cfd5ded1e794de1677a4b07
parent2d5c81078c9bbc5981197b6fe04fca224eb942b7 (diff)
downloadmullvadvpn-f93536f6b59278ef9f36fd0e557ea535e39c727b.tar.xz
mullvadvpn-f93536f6b59278ef9f36fd0e557ea535e39c727b.zip
Refactor chip view
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj28
-rw-r--r--ios/MullvadVPN/Extensions/String+Helpers.swift (renamed from ios/MullvadVPN/Extensions/String+Split.swift)3
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift3
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift61
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift45
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift124
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift63
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift174
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift15
9 files changed, 353 insertions, 163 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 99c369c428..dabfa71078 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 = "<group>"; };
5803B4B12940A48700C23744 /* TunnelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStore.swift; sourceTree = "<group>"; };
58059DDD28468158002B1049 /* OutputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputOperation.swift; sourceTree = "<group>"; };
- 5807E2BF2432038B00F5FF30 /* String+Split.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Split.swift"; sourceTree = "<group>"; };
+ 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
5807E2C1243203D000F5FF30 /* StringTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = "<group>"; };
580810E42A30E13A00B74552 /* DeviceStateAccessorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStateAccessorProtocol.swift; sourceTree = "<group>"; };
580810E72A30E15500B74552 /* DeviceCheckRemoteServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceCheckRemoteServiceProtocol.swift; sourceTree = "<group>"; };
@@ -1937,7 +1939,7 @@
7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = "<group>"; };
7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = "<group>"; };
7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; };
- 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterChipView.swift; sourceTree = "<group>"; };
+ 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewCell.swift; sourceTree = "<group>"; };
85006A8E2B73EF67004AD8FB /* MullvadVPNUITestsSmoke.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MullvadVPNUITestsSmoke.xctestplan; sourceTree = "<group>"; };
850201DA2B503D7700EF8C96 /* RelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayTests.swift; sourceTree = "<group>"; };
850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationPage.swift; sourceTree = "<group>"; };
@@ -2150,6 +2152,8 @@
F0ACE30A2BE4E478006D5333 /* MullvadMockData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadMockData.h; sourceTree = "<group>"; };
F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProxyFactory.swift; sourceTree = "<group>"; };
F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ServerRelaysResponse+Stubs.swift"; sourceTree = "<group>"; };
+ F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipCollectionView.swift; sourceTree = "<group>"; };
+ F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFlowLayout.swift; sourceTree = "<group>"; };
F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = "<group>"; };
F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = "<group>"; };
F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = "<group>"; };
@@ -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
index f62317343b..a311281940 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 ce108dceb8..e1be91814c 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 0000000000..f4c8450647
--- /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 0000000000..f54abf6095
--- /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 0000000000..96737788a1
--- /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 3b6191e1aa..0000000000
--- 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 e12ce5cf37..53634790bf 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 5bd6021b96..16529c2340 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