summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-04-06 14:00:32 +0200
committerAndrej Mihajlov <and@mullvad.net>2023-04-06 14:00:32 +0200
commit2cc9013dfe0b5336d7b7c3146a5c8702e3007477 (patch)
tree301fc97135385fc854dbf88d150315c97a208723
parent1b5f141c9d4f05698a02455d000231ce70350379 (diff)
parent97580ad1aacb78bac6a261ba48a9f87f29595df1 (diff)
downloadmullvadvpn-2cc9013dfe0b5336d7b7c3146a5c8702e3007477.tar.xz
mullvadvpn-2cc9013dfe0b5336d7b7c3146a5c8702e3007477.zip
Merge branch 'add-a-search-bar-to-location-selection-ios-42'
-rw-r--r--ios/CHANGELOG.md3
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj10
-rw-r--r--ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift8
-rw-r--r--ios/MullvadVPN/Extensions/String+FuzzyMatch.swift36
-rw-r--r--ios/MullvadVPN/Extensions/UISearchBar+Appearance.swift51
-rw-r--r--ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift4
-rw-r--r--ios/MullvadVPN/UI appearance/UIColor+Palette.swift11
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift193
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift125
9 files changed, 306 insertions, 135 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 6572a6bd59..bcaecc10dc 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -23,6 +23,9 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
+### Added
+- Add search functionality to location selection view.
+
### Changed
- Changed key rotation interval from 4 to 14 days.
- Delay tunnel reconnection after a WireGuard private key rotates. Accounts for latency in key
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 9931a2cbac..4841866011 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -358,6 +358,8 @@
58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */; };
58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */; };
58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF2C02281BDE02009EF542 /* SettingsManager.swift */; };
+ 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */; };
+ 7AD2DA1529DC4EB900250737 /* UISearchBar+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AD2DA1429DC4EB900250737 /* UISearchBar+Appearance.swift */; };
E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */; };
E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */; };
E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; };
@@ -939,6 +941,8 @@
58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseButton.swift; sourceTree = "<group>"; };
58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyboardResponder.swift; sourceTree = "<group>"; };
58FF2C02281BDE02009EF542 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
+ 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FuzzyMatch.swift"; sourceTree = "<group>"; };
+ 7AD2DA1429DC4EB900250737 /* UISearchBar+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISearchBar+Appearance.swift"; sourceTree = "<group>"; };
7AD8490C29BA1EC500878E53 /* SettingsCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCellFactory.swift; sourceTree = "<group>"; };
7AD8490E29BA26B000878E53 /* CellFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellFactoryProtocol.swift; sourceTree = "<group>"; };
7AD8491029BA316500878E53 /* PreferencesCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesCellFactory.swift; sourceTree = "<group>"; };
@@ -1421,10 +1425,12 @@
58A8EE592976BFBB009C0F8D /* SKError+Localized.swift */,
58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */,
58A8EE5D2976DB00009C0F8D /* StorePaymentManagerError+Display.swift */,
- 5807E2BF2432038B00F5FF30 /* String+Split.swift */,
E158B35F285381C60002F069 /* String+AccountFormatting.swift */,
+ 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */,
+ 5807E2BF2432038B00F5FF30 /* String+Split.swift */,
5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */,
587CBFE222807F530028DED3 /* UIColor+Helpers.swift */,
+ 7AD2DA1429DC4EB900250737 /* UISearchBar+Appearance.swift */,
5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */,
);
path = Extensions;
@@ -2594,6 +2600,7 @@
5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */,
58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */,
58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */,
+ 7AD2DA1529DC4EB900250737 /* UISearchBar+Appearance.swift in Sources */,
5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */,
587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */,
586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */,
@@ -2755,6 +2762,7 @@
58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */,
58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */,
583FE00E29C0D586006E85F9 /* OutOfTimeCoordinator.swift in Sources */,
+ 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */,
58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */,
580F8B8328197881002E0998 /* TunnelSettingsV2.swift in Sources */,
58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */,
diff --git a/ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift b/ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift
index ff7b4a7164..f5a69fce9d 100644
--- a/ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift
@@ -59,13 +59,7 @@ class SelectLocationCoordinator: Coordinator, Presentable, RelayCacheTrackerObse
controller.setCachedRelays(cachedRelays)
}
- let relayConstraints = tunnelManager.settings.relayConstraints
-
- controller.setSelectedRelayLocation(
- relayConstraints.location.value,
- animated: false,
- scrollPosition: .middle
- )
+ controller.relayLocation = tunnelManager.settings.relayConstraints.location.value
navigationController.pushViewController(controller, animated: false)
}
diff --git a/ios/MullvadVPN/Extensions/String+FuzzyMatch.swift b/ios/MullvadVPN/Extensions/String+FuzzyMatch.swift
new file mode 100644
index 0000000000..3f6b377f5c
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/String+FuzzyMatch.swift
@@ -0,0 +1,36 @@
+//
+// String+FuzzyMatch.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-04-02.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+extension String {
+ func fuzzyMatch(_ needle: String) -> Bool {
+ guard !needle.isEmpty else { return false }
+
+ let haystack = lowercased()
+ let needle = needle.lowercased()
+
+ var indices: [Index] = []
+ var remainder = needle[...].utf8
+
+ for index in haystack.utf8.indices {
+ let character = haystack.utf8[index]
+
+ if character == remainder[remainder.startIndex] {
+ indices.append(index)
+ remainder.removeFirst()
+
+ if remainder.isEmpty {
+ return !indices.isEmpty
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/ios/MullvadVPN/Extensions/UISearchBar+Appearance.swift b/ios/MullvadVPN/Extensions/UISearchBar+Appearance.swift
new file mode 100644
index 0000000000..065293575f
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/UISearchBar+Appearance.swift
@@ -0,0 +1,51 @@
+//
+// UISearchBar+Appearance.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-04-04.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+extension UISearchBar {
+ struct SearchBarAppearance {
+ let placeholderTextColor: UIColor
+ let textColor: UIColor
+ let backgroundColor: UIColor
+ let leftViewTintColor: UIColor
+
+ static var active: SearchBarAppearance {
+ return SearchBarAppearance(
+ placeholderTextColor: .SearchTextField.placeholderTextColor,
+ textColor: .SearchTextField.textColor,
+ backgroundColor: .SearchTextField.backgroundColor,
+ leftViewTintColor: .SearchTextField.leftViewTintColor
+ )
+ }
+
+ static var inactive: SearchBarAppearance {
+ return SearchBarAppearance(
+ placeholderTextColor: .SearchTextField.inactivePlaceholderTextColor,
+ textColor: .SearchTextField.inactiveTextColor,
+ backgroundColor: .SearchTextField.inactiveBackgroundColor,
+ leftViewTintColor: .SearchTextField.inactiveLeftViewTintColor
+ )
+ }
+
+ func apply(to searchBar: UISearchBar) {
+ let textField = searchBar.searchTextField
+
+ textField.leftView?.tintColor = leftViewTintColor
+ textField.tintColor = textColor
+ textField.textColor = textColor
+ textField.backgroundColor = backgroundColor
+ textField.attributedPlaceholder = NSAttributedString(
+ string: searchBar.placeholder ?? "",
+ attributes: [
+ .foregroundColor: placeholderTextColor,
+ ]
+ )
+ }
+ }
+}
diff --git a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift
index 17c6be3c0a..a2a4d4ef32 100644
--- a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift
+++ b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift
@@ -192,13 +192,13 @@ struct PinnableEdges {
return firstView.topAnchor.constraint(equalTo: secondView.topAnchor, constant: inset)
case let .bottom(inset):
- return firstView.bottomAnchor.constraint(equalTo: secondView.bottomAnchor, constant: inset)
+ return firstView.bottomAnchor.constraint(equalTo: secondView.bottomAnchor, constant: -inset)
case let .leading(inset):
return firstView.leadingAnchor.constraint(equalTo: secondView.leadingAnchor, constant: inset)
case let .trailing(inset):
- return firstView.trailingAnchor.constraint(equalTo: secondView.trailingAnchor, constant: inset)
+ return firstView.trailingAnchor.constraint(equalTo: secondView.trailingAnchor, constant: -inset)
}
}
}
diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift
index 07522f6cf5..de2472680a 100644
--- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift
+++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift
@@ -36,6 +36,17 @@ extension UIColor {
static let invalidInputTextColor = UIColor.dangerColor
}
+ enum SearchTextField {
+ static let placeholderTextColor = TextField.placeholderTextColor
+ static let inactivePlaceholderTextColor = UIColor.white
+ static let textColor = TextField.textColor
+ static let inactiveTextColor = UIColor.white
+ static let backgroundColor = TextField.backgroundColor
+ static let inactiveBackgroundColor = UIColor.secondaryColor
+ static let leftViewTintColor = UIColor.primaryColor
+ static let inactiveLeftViewTintColor = UIColor.white
+ }
+
enum AppButton {
static let normalTitleColor = UIColor.white
static let highlightedTitleColor = UIColor.lightGray
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
index 62d9b3a4e8..9366879b89 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
@@ -34,8 +34,7 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation
private var nodeByLocation = [RelayLocation: Node]()
private var locationList = [RelayLocation]()
- private var rootNode = makeRootNode()
- private(set) var selectedRelayLocation: RelayLocation?
+ private var currentSearchString = ""
private let tableView: UITableView
private let locationCellFactory: LocationCellFactory
@@ -51,6 +50,7 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation
)
}
+ var selectedRelayLocation: RelayLocation?
var didSelectRelayLocation: ((RelayLocation) -> Void)?
init(tableView: UITableView) {
@@ -73,21 +73,6 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation
registerClasses()
}
- func setSelectedRelayLocation(
- _ relayLocation: RelayLocation?,
- animated: Bool
- ) {
- selectedRelayLocation = relayLocation
-
- let selectedLocationTree = selectedRelayLocation?.ascendants ?? []
- selectedLocationTree.forEach { location in
- nodeByLocation[location]?.showsChildren = true
- }
-
- updateCellFactory(with: nodeByLocation)
- updateDataSnapshot(with: locationList, animated: animated)
- }
-
func setRelays(_ response: REST.ServerRelaysResponse) {
let rootNode = Self.makeRootNode()
var nodeByLocation = [RelayLocation: Node]()
@@ -153,43 +138,77 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation
rootNode.sortChildrenRecursive()
rootNode.computeActiveChildrenRecursive()
self.nodeByLocation = nodeByLocation
- self.rootNode = rootNode
locationList = rootNode.flatRelayLocationList()
- updateCellFactory(with: nodeByLocation)
- updateDataSnapshot(with: locationList)
+ filterRelays(by: currentSearchString)
}
func indexPathForSelectedRelay() -> IndexPath? {
return selectedRelayLocation.flatMap { indexPath(for: $0) }
}
- private func updateDataSnapshot(
- with locations: [RelayLocation],
- animated: Bool = false,
- completion: (() -> Void)? = nil
- ) {
- var snapshot = NSDiffableDataSourceSnapshot<Int, RelayLocation>()
- snapshot.appendSections([0])
+ func filterRelays(by searchString: String) {
+ currentSearchString = searchString
- for location in locations {
- snapshot.appendItems([location])
+ if currentSearchString.isEmpty {
+ return resetLocationList()
+ }
- guard let countryNode = nodeByLocation[location], countryNode.showsChildren else {
- continue
+ var filteredLocations = [RelayLocation]()
+
+ locationList.forEach { location in
+ guard let countryNode = nodeByLocation[location] else { return }
+ countryNode.showsChildren = false
+
+ if searchString.isEmpty || countryNode.displayName.fuzzyMatch(searchString) {
+ filteredLocations.append(countryNode.location)
}
for cityNode in countryNode.children {
- snapshot.appendItems([cityNode.location])
+ cityNode.showsChildren = false
- guard cityNode.showsChildren else {
- continue
- }
+ let relaysContainSearchString = cityNode.children.contains(where: { node in
+ node.displayName.fuzzyMatch(searchString)
+ })
- snapshot.appendItems(cityNode.children.map { $0.location })
+ if cityNode.displayName.fuzzyMatch(searchString) || relaysContainSearchString {
+ if !filteredLocations.contains(countryNode.location) {
+ filteredLocations.append(countryNode.location)
+ }
+
+ filteredLocations.append(cityNode.location)
+ countryNode.showsChildren = true
+
+ if relaysContainSearchString {
+ filteredLocations.append(contentsOf: cityNode.children.map { $0.location })
+ cityNode.showsChildren = true
+ }
+ }
}
}
+ updateDataSnapshot(with: filteredLocations, reloadExisting: true) { [weak self] in
+ self?.scrollToTop(animated: false)
+ }
+ }
+
+ private func updateDataSnapshot(
+ with locations: [RelayLocation],
+ reloadExisting: Bool = false,
+ animated: Bool = false,
+ completion: (() -> Void)? = nil
+ ) {
+ updateCellFactory(with: nodeByLocation)
+
+ var snapshot = NSDiffableDataSourceSnapshot<Int, RelayLocation>()
+
+ snapshot.appendSections([0])
+ snapshot.appendItems(locations)
+
+ if reloadExisting {
+ snapshot.reloadItems(locations)
+ }
+
apply(snapshot, animatingDifferences: animated, completion: completion)
}
@@ -206,22 +225,56 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation
locationCellFactory.nodeByLocation = nodeByLocation
}
+ private func setSelectedRelayLocation(
+ _ relayLocation: RelayLocation?,
+ animated: Bool,
+ completion: (() -> Void)? = nil
+ ) {
+ selectedRelayLocation = relayLocation
+ var locationList = snapshot().itemIdentifiers
+
+ guard let selectedRelayLocation = selectedRelayLocation,
+ !locationList.contains(selectedRelayLocation) else { return }
+
+ let selectedLocationTree = selectedRelayLocation.ascendants + [selectedRelayLocation]
+
+ guard let topLocation = selectedLocationTree.first,
+ let topNode = nodeByLocation[topLocation],
+ let indexPath = indexPath(for: topLocation)
+ else {
+ return
+ }
+
+ selectedLocationTree.forEach { location in
+ nodeByLocation[location]?.showsChildren = true
+ }
+
+ locationList.addLocations(topNode.flatRelayLocationList(), at: indexPath.row + 1)
+ updateDataSnapshot(with: locationList, reloadExisting: true, animated: animated, completion: completion)
+ }
+
private func toggleChildren(
_ relayLocation: RelayLocation,
show: Bool,
animated: Bool
) {
- guard let node = nodeByLocation[relayLocation] else { return }
+ guard let node = nodeByLocation[relayLocation],
+ let indexPath = indexPath(for: node.location),
+ let cell = tableView.cellForRow(at: indexPath) else { return }
node.showsChildren = show
+ locationCellFactory.configureCell(cell, item: node.location, indexPath: indexPath)
- if let indexPath = indexPath(for: node.location),
- let cell = tableView.cellForRow(at: indexPath)
- {
- locationCellFactory.configureCell(cell, item: node.location, indexPath: indexPath)
+ var locationList = snapshot().itemIdentifiers
+ let locationsToEdit = node.flatRelayLocationList()
+
+ if show {
+ locationList.addLocations(locationsToEdit, at: indexPath.row + 1)
+ } else {
+ locationsToEdit.forEach { nodeByLocation[$0]?.showsChildren = false }
+ locationList.removeLocations(locationsToEdit)
}
- updateCellFactory(with: nodeByLocation)
updateDataSnapshot(with: locationList, animated: animated) { [weak self] in
guard let visibleIndexPaths = self?.tableView.indexPathsForVisibleRows else { return }
@@ -257,9 +310,24 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation
}
}
+ private func resetLocationList() {
+ nodeByLocation.values.forEach { $0.showsChildren = false }
+
+ updateDataSnapshot(with: locationList, reloadExisting: true)
+ setSelectedRelayLocation(selectedRelayLocation, animated: false)
+
+ if let indexPath = indexPathForSelectedRelay() {
+ tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
+ }
+ }
+
private func item(for indexPath: IndexPath) -> LocationDataSourceItemProtocol? {
return itemIdentifier(for: indexPath).flatMap { nodeByLocation[$0] }
}
+
+ private func scrollToTop(animated: Bool) {
+ tableView.setContentOffset(.zero, animated: animated)
+ }
}
extension LocationDataSource: UITableViewDelegate {
@@ -284,7 +352,11 @@ extension LocationDataSource: UITableViewDelegate {
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- guard let item = item(for: indexPath) else { return }
+ guard let item = item(for: indexPath),
+ item.location != selectedRelayLocation
+ else {
+ return
+ }
if let indexPath = indexPathForSelectedRelay(),
let cell = tableView.cellForRow(at: indexPath)
@@ -392,18 +464,9 @@ extension LocationDataSource {
}
}
- func countChildrenRecursive(where condition: @escaping (Node) -> Bool) -> Int {
- return children.reduce(into: 0) { numVisibleChildren, node in
- numVisibleChildren += 1
- if condition(node) {
- numVisibleChildren += node.countChildrenRecursive(where: condition)
- }
- }
- }
-
- func flatRelayLocationList() -> [RelayLocation] {
+ func flatRelayLocationList(includeHiddenChildren: Bool = false) -> [RelayLocation] {
return children.reduce(into: []) { array, node in
- Self.flatten(node: node, into: &array)
+ Self.flatten(node: node, into: &array, includeHiddenChildren: includeHiddenChildren)
}
}
@@ -425,11 +488,11 @@ extension LocationDataSource {
}
}
- private class func flatten(node: Node, into array: inout [RelayLocation]) {
+ private class func flatten(node: Node, into array: inout [RelayLocation], includeHiddenChildren: Bool) {
array.append(node.location)
- if node.showsChildren {
+ if includeHiddenChildren || node.showsChildren {
for child in node.children {
- Self.flatten(node: child, into: &array)
+ Self.flatten(node: child, into: &array, includeHiddenChildren: includeHiddenChildren)
}
}
}
@@ -443,3 +506,19 @@ private func lexicalSortComparator(_ a: String, _ b: String) -> Bool {
private func fileSortComparator(_ a: String, _ b: String) -> Bool {
return a.localizedStandardCompare(b) == .orderedAscending
}
+
+private extension Array where Element == RelayLocation {
+ mutating func addLocations(_ locations: [RelayLocation], at index: Int) {
+ if index < count {
+ insert(contentsOf: locations, at: index)
+ } else {
+ append(contentsOf: locations)
+ }
+ }
+
+ mutating func removeLocations(_ locations: [RelayLocation]) {
+ removeAll(where: { location in
+ locations.contains(location)
+ })
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift
index e846213c03..608aeb5db3 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift
@@ -11,13 +11,12 @@ import MullvadTypes
import RelayCache
import UIKit
-final class SelectLocationViewController: UIViewController, UITableViewDelegate {
- private var tableView: UITableView?
+final class SelectLocationViewController: UIViewController {
+ private let searchBar = UISearchBar()
+ private let tableView = UITableView()
private var dataSource: LocationDataSource?
private var cachedRelays: CachedRelays?
- private var relayLocation: RelayLocation?
- private var scrollPosition: UITableView.ScrollPosition?
- private var isViewAppeared = false
+ var relayLocation: RelayLocation?
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
@@ -26,21 +25,13 @@ final class SelectLocationViewController: UIViewController, UITableViewDelegate
var didSelectRelay: ((RelayLocation) -> Void)?
var didFinish: (() -> Void)?
- var scrollToSelectedRelayOnViewWillAppear = true
-
- init() {
- super.init(nibName: nil, bundle: nil)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
// MARK: - View lifecycle
override func viewDidLoad() {
super.viewDidLoad()
+ view.backgroundColor = .secondaryColor
+
navigationItem.title = NSLocalizedString(
"NAVIGATION_TITLE",
tableName: "SelectLocation",
@@ -53,32 +44,26 @@ final class SelectLocationViewController: UIViewController, UITableViewDelegate
action: #selector(handleDone)
)
- setupTableView()
setupDataSource()
- }
+ setupTableView()
+ setupSearchBar()
- override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
+ let searchBarTopMargin: CGFloat = splitViewController == nil ? -16 : 0
+ view.addConstrainedSubviews([searchBar, tableView]) {
+ searchBar.pinEdges(
+ .init([.top(searchBarTopMargin), .leading(8), .trailing(8)]),
+ to: view.safeAreaLayoutGuide
+ )
- if let indexPath = dataSource?.indexPathForSelectedRelay(),
- scrollToSelectedRelayOnViewWillAppear, !isViewAppeared
- {
- tableView?.scrollToRow(at: indexPath, at: scrollPosition ?? .middle, animated: false)
+ tableView.pinEdgesToSuperview(.all().excluding(.top))
+ tableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
- isViewAppeared = true
-
- tableView?.flashScrollIndicators()
- }
-
- override func viewDidDisappear(_ animated: Bool) {
- super.viewDidDisappear(animated)
-
- isViewAppeared = false
+ tableView.flashScrollIndicators()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
@@ -87,7 +72,7 @@ final class SelectLocationViewController: UIViewController, UITableViewDelegate
coordinator.animate(alongsideTransition: nil) { context in
guard let indexPath = self.dataSource?.indexPathForSelectedRelay() else { return }
- self.tableView?.scrollToRow(at: indexPath, at: .middle, animated: false)
+ self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
}
@@ -99,56 +84,60 @@ final class SelectLocationViewController: UIViewController, UITableViewDelegate
dataSource?.setRelays(cachedRelays.relays)
}
- func setSelectedRelayLocation(
- _ relayLocation: RelayLocation?,
- animated: Bool,
- scrollPosition: UITableView.ScrollPosition
- ) {
- self.relayLocation = relayLocation
- self.scrollPosition = scrollPosition
-
- dataSource?.setSelectedRelayLocation(relayLocation, animated: animated)
- }
-
// MARK: - Private
- private func setupTableView() {
- let tableView = UITableView(frame: view.bounds, style: .plain)
- tableView.backgroundColor = .clear
- tableView.backgroundColor = .secondaryColor
- tableView.separatorColor = .secondaryColor
- tableView.separatorInset = .zero
- tableView.estimatedRowHeight = 53
- tableView.indicatorStyle = .white
- tableView.delegate = self
-
- view.backgroundColor = .secondaryColor
-
- view.addConstrainedSubviews([tableView]) {
- tableView.pinEdgesToSuperview()
- }
-
- self.tableView = tableView
+ @objc private func handleDone() {
+ didFinish?()
}
private func setupDataSource() {
- guard let tableView = tableView else { return }
-
dataSource = LocationDataSource(tableView: tableView)
dataSource?.didSelectRelayLocation = { [weak self] location in
self?.didSelectRelay?(location)
}
+ dataSource?.selectedRelayLocation = relayLocation
+
if let cachedRelays = cachedRelays {
dataSource?.setRelays(cachedRelays.relays)
}
+ }
- if let relayLocation = relayLocation {
- dataSource?.setSelectedRelayLocation(relayLocation, animated: false)
- }
+ private func setupTableView() {
+ tableView.backgroundColor = view.backgroundColor
+ tableView.separatorColor = .secondaryColor
+ tableView.separatorInset = .zero
+ tableView.estimatedRowHeight = 53
+ tableView.indicatorStyle = .white
+ tableView.keyboardDismissMode = .onDrag
}
- @objc private func handleDone() {
- didFinish?()
+ private func setupSearchBar() {
+ searchBar.delegate = self
+ searchBar.searchBarStyle = .minimal
+ searchBar.layer.cornerRadius = 8
+ searchBar.clipsToBounds = true
+ searchBar.placeholder = NSLocalizedString(
+ "SEARCHBAR_PLACEHOLDER",
+ tableName: "SelectLocation",
+ value: "Search for...",
+ comment: ""
+ )
+
+ UISearchBar.SearchBarAppearance.inactive.apply(to: searchBar)
+ }
+}
+
+extension SelectLocationViewController: UISearchBarDelegate {
+ func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
+ dataSource?.filterRelays(by: searchText)
+ }
+
+ func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
+ UISearchBar.SearchBarAppearance.active.apply(to: searchBar)
+ }
+
+ func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
+ UISearchBar.SearchBarAppearance.inactive.apply(to: searchBar)
}
}