summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEmīls <emils@mullvad.net>2026-04-23 23:59:07 +0200
committerEmīls <emils@mullvad.net>2026-04-24 10:55:08 +0200
commita82e1d006268adbbb4afb24b3a9fcf47d979df74 (patch)
treeea2e3d85b396a27798c4cdc3045be31e171c08aa
parente68ffb15280f0ed93cc447da83ee9ee615fb3b21 (diff)
downloadmullvadvpn-a82e1d006268adbbb4afb24b3a9fcf47d979df74.tar.xz
mullvadvpn-a82e1d006268adbbb4afb24b3a9fcf47d979df74.zip
Add floating search bar
-rw-r--r--ios/Assets/Localizable.xcstrings134
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj8
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift1
-rw-r--r--ios/MullvadVPN/Extensions/EnvironmentValues+DismissSearchFocus.swift12
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/FloatingSearchBar.swift86
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift2
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift34
7 files changed, 144 insertions, 133 deletions
diff --git a/ios/Assets/Localizable.xcstrings b/ios/Assets/Localizable.xcstrings
index bd526a0ae6..243f8c1fc3 100644
--- a/ios/Assets/Localizable.xcstrings
+++ b/ios/Assets/Localizable.xcstrings
@@ -12867,6 +12867,10 @@
}
}
},
+ "Close search" : {
+ "comment" : "A label for a button that closes a search interface.",
+ "isCommentAutoGenerated" : true
+ },
"Collapse" : {
"comment" : "Name of the accessibility action that collapses a location.",
"isCommentAutoGenerated" : true,
@@ -53156,129 +53160,13 @@
}
}
},
- "Search for locations or servers" : {
- "localizations" : {
- "da" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Søg efter placeringer eller servere"
- }
- },
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Suche nach Standorten oder Servern"
- }
- },
- "es" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Buscar ubicaciones o servidores"
- }
- },
- "fi" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Hae sijainteja tai palvelimia"
- }
- },
- "fr" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Chercher des emplacements ou des serveurs"
- }
- },
- "it" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Cerca posizioni o server"
- }
- },
- "ja" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "場所またはサーバーを検索"
- }
- },
- "ko" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "지역 또는 서버 검색"
- }
- },
- "my" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "တည်နေရာများ သို့မဟုတ် ဆာဗာများ ရှာဖွေရန်"
- }
- },
- "nb" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Søk etter plasseringer eller servere"
- }
- },
- "nl" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Zoek naar locaties of servers"
- }
- },
- "pl" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Wyszukaj lokalizacje lub serwery"
- }
- },
- "pt" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Pesquisar localizações ou servidores"
- }
- },
- "ru" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Поиск локаций или серверов"
- }
- },
- "sv" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Sök efter platser eller servrar"
- }
- },
- "th" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "ค้นหาตำแหน่งที่ตั้งหรือเซิร์ฟเวอร์"
- }
- },
- "tr" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Konumları veya sunucuları arayın"
- }
- },
- "uk" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Пошук розташувань або серверів"
- }
- },
- "zh-Hans" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "搜索位置或服务器"
- }
- },
- "zh-Hant" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "搜尋地點或伺服器"
- }
- }
- }
+ "Search location or server" : {
+ "comment" : "A placeholder text for the search bar.",
+ "isCommentAutoGenerated" : true
+ },
+ "Search locations" : {
+ "comment" : "A button label that says \"Search locations\".",
+ "isCommentAutoGenerated" : true
},
"Seattle, WA" : {
"localizations" : {
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 425342cd1d..f4c2de7f10 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -1133,6 +1133,8 @@
F0FA16152D7F3E16007E2546 /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; };
F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
F0FADDEC2BE90AB0000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
+ BF434ABF6F9543C9BABD3CBA /* FloatingSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B364DC07F3CF4B63A5B1BB6C /* FloatingSearchBar.swift */; };
+ C1EA193A1103489C981B6E59 /* EnvironmentValues+DismissSearchFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 323AB4F0478E4995ABAF99AF /* EnvironmentValues+DismissSearchFocus.swift */; };
F90052522E6B06AD0085C80E /* SelectLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90052512E6B06AA0085C80E /* SelectLocationView.swift */; };
F90052562E6EEB290085C80E /* SelectLocationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90052552E6EEB290085C80E /* SelectLocationViewModel.swift */; };
F90A988A2E042D040020F64F /* ClearBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90A98892E042D040020F64F /* ClearBackgroundView.swift */; };
@@ -2681,6 +2683,8 @@
F0FA160D2D7F2C3D007E2546 /* MockRelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRelayCache.swift; sourceTree = "<group>"; };
F0FA160F2D7F2FC0007E2546 /* RelayFilterViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModelTests.swift; sourceTree = "<group>"; };
F0FBD98E2C4A60CC00EE5323 /* KeyExchangingResultStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyExchangingResultStub.swift; sourceTree = "<group>"; };
+ 323AB4F0478E4995ABAF99AF /* EnvironmentValues+DismissSearchFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+DismissSearchFocus.swift"; sourceTree = "<group>"; };
+ B364DC07F3CF4B63A5B1BB6C /* FloatingSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingSearchBar.swift; sourceTree = "<group>"; };
F90052512E6B06AA0085C80E /* SelectLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationView.swift; sourceTree = "<group>"; };
F90052552E6EEB290085C80E /* SelectLocationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewModel.swift; sourceTree = "<group>"; };
F90A98892E042D040020F64F /* ClearBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearBackgroundView.swift; sourceTree = "<group>"; };
@@ -3595,6 +3599,7 @@
7A0EAE9F2D0333CB00D3EB8B /* Color+Helpers.swift */,
F95CDD9F2EE02B2F000E9D97 /* CoordinateSpace+Names.swift */,
7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */,
+ 323AB4F0478E4995ABAF99AF /* EnvironmentValues+DismissSearchFocus.swift */,
F97C38E42DEEDFD2006DCB08 /* Image+Assets.swift */,
5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */,
58DFF7CF2B02560400F864E0 /* NSAttributedString+Extensions.swift */,
@@ -5097,6 +5102,7 @@
F9C579BC2E8E9ADE00C90C50 /* DaitaWarningView.swift */,
F96D04EC2EC318D8004A4D48 /* EntryLocationView.swift */,
F96D04E82EC317B9004A4D48 /* ExitLocationView.swift */,
+ B364DC07F3CF4B63A5B1BB6C /* FloatingSearchBar.swift */,
7A81C3F52F6C26EF008D7ADC /* ListItem.swift */,
7A81C3F12F6BE4E3008D7ADC /* ListItemFactory.swift */,
F96D04E62EC31743004A4D48 /* LocationContextMenu.swift */,
@@ -6808,6 +6814,8 @@
440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */,
588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */,
5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */,
+ BF434ABF6F9543C9BABD3CBA /* FloatingSearchBar.swift in Sources */,
+ C1EA193A1103489C981B6E59 /* EnvironmentValues+DismissSearchFocus.swift in Sources */,
F90052522E6B06AD0085C80E /* SelectLocationView.swift in Sources */,
588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */,
F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */,
diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
index 254154abf8..6966425b15 100644
--- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
+++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
@@ -52,6 +52,7 @@ public enum AccessibilityIdentifier: Equatable {
case restorePurchasesButton
case connectButton
case selectLocationButton
+ case closeSearchButton
case closeSelectLocationButton
case settingsButton
case startUsingTheAppButton
diff --git a/ios/MullvadVPN/Extensions/EnvironmentValues+DismissSearchFocus.swift b/ios/MullvadVPN/Extensions/EnvironmentValues+DismissSearchFocus.swift
new file mode 100644
index 0000000000..67ddaa83ca
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/EnvironmentValues+DismissSearchFocus.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+private struct DismissSearchFocusKey: EnvironmentKey {
+ static let defaultValue: (@MainActor () -> Void)? = nil
+}
+
+extension EnvironmentValues {
+ var dismissSearchFocus: (@MainActor () -> Void)? {
+ get { self[DismissSearchFocusKey.self] }
+ set { self[DismissSearchFocusKey.self] = newValue }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/FloatingSearchBar.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/FloatingSearchBar.swift
new file mode 100644
index 0000000000..bdc02afbb4
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/FloatingSearchBar.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+
+struct FloatingSearchBar: View {
+ @Binding var searchText: String
+ @Binding var isExpanded: Bool
+ var isFocused: FocusState<Bool>.Binding
+
+ @Namespace private var animation
+
+ var body: some View {
+ HStack {
+ if isExpanded {
+ HStack(spacing: 8) {
+ searchIcon
+ TextField(
+ "Search location or server",
+ text: $searchText,
+ prompt: Text("Search location or server")
+ .foregroundColor(.MullvadTextField.inputPlaceholder)
+ )
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .focused(isFocused)
+ .foregroundColor(.MullvadTextField.textInput)
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 56)
+ .background {
+ RoundedRectangle(cornerRadius: 28)
+ .fill(Color.mullvadDarkBackground)
+ .matchedGeometryEffect(id: "searchBackground", in: animation)
+ }
+ .accessibilityAddTraits(.isSearchField)
+ .accessibilityIdentifier(.selectLocationSearchTextField)
+
+ Button {
+ searchText = ""
+ withAnimation {
+ isExpanded = false
+ isFocused.wrappedValue = false
+ }
+ } label: {
+ Image.mullvadIconCross
+ .foregroundColor(.mullvadTextPrimary)
+ .frame(width: 56, height: 56)
+ .background(Color.mullvadDarkBackground)
+ .clipShape(Circle())
+ }
+ .accessibilityLabel(Text("Close search"))
+ .accessibilityIdentifier(.closeSearchButton)
+ .transition(.opacity)
+ } else {
+ Spacer()
+ Button {
+ withAnimation {
+ isExpanded = true
+ }
+ } label: {
+ searchIcon
+ .frame(width: 56, height: 56)
+ .background {
+ RoundedRectangle(cornerRadius: 28)
+ .fill(Color.mullvadDarkBackground)
+ .matchedGeometryEffect(id: "searchBackground", in: animation)
+ }
+ }
+ .accessibilityLabel(Text("Search locations"))
+ .accessibilityIdentifier(.selectLocationSearchTextField)
+ }
+ }
+ .onChange(of: isExpanded) { _, expanded in
+ if expanded {
+ isFocused.wrappedValue = true
+ }
+ }
+ // Prevents the keyboard safe area animation from compounding with the bar's
+ // layout animation, which otherwise causes a visible bounce on the text field.
+ .transformEffect(.identity)
+ }
+
+ private var searchIcon: some View {
+ Image.mullvadIconSearch
+ .foregroundColor(.mullvadTextPrimary)
+ .matchedGeometryEffect(id: "searchIcon", in: animation)
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
index bd99099e77..57925a60f6 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct LocationListItem<ContextMenu>: View where ContextMenu: View {
@State private var alert: MullvadAlert?
+ @Environment(\.dismissSearchFocus) private var dismissSearchFocus
private let itemFactory = ListItemFactory()
@Binding var location: LocationNode
@@ -90,6 +91,7 @@ struct LocationListItem<ContextMenu>: View where ContextMenu: View {
}
func toggleChildren() {
+ dismissSearchFocus?()
withAnimation(.default.speed(3)) {
location.showsChildren.toggle()
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift
index 801ad9cf3e..532e02f709 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift
@@ -7,7 +7,10 @@ struct SelectLocationView<ViewModel>: View where ViewModel: SelectLocationViewMo
@State private var headerIsExpandedForExit: Bool = false
@State private var disablingRecentConnectionsAlert: MullvadAlert?
@FocusState private var focusSearchField: Bool
+ @State private var isSearchExpanded: Bool = false
@State private var headerHeight: CGFloat = 0
+ @State private var floatingBarHeight: CGFloat = 0
+ @ScaledMetric(relativeTo: .body) private var listBottomInset: CGFloat = 56
private var headerIsExpanded: Bool {
switch viewModel.multihopContext {
@@ -48,16 +51,6 @@ struct SelectLocationView<ViewModel>: View where ViewModel: SelectLocationViewMo
isExpanded: headerIsExpanded
)
.padding(.horizontal, 16)
- if showSearchField {
- MullvadSecondaryTextField(
- placeholder: "Search for locations or servers",
- text: $viewModel.searchText
- )
- .focused($focusSearchField)
- .accessibilityAddTraits(.isSearchField)
- .padding(.horizontal)
- .transition(.move(edge: .top).combined(with: .opacity))
- }
}
.padding(.vertical)
.background(Color.mullvadDarkBackground)
@@ -109,13 +102,34 @@ struct SelectLocationView<ViewModel>: View where ViewModel: SelectLocationViewMo
focusSearchField = false
}
)
+ .environment(\.dismissSearchFocus, { focusSearchField = false })
.geometryGroup()
// Adds margin to the top of the scroll content. The scroll views size stays untouched
// which seems to be the solution to animation issues.
.contentMargins(.top, headerHeight - 1)
+ .contentMargins(.bottom, showSearchField ? floatingBarHeight + listBottomInset : 0)
.zIndex(0)
}
}
+ .overlay(alignment: .bottom) {
+ FloatingSearchBar(
+ searchText: $viewModel.searchText,
+ isExpanded: $isSearchExpanded,
+ isFocused: $focusSearchField
+ )
+ .showIf(showSearchField)
+ .padding(.horizontal, 16)
+ .padding(.bottom, 16)
+ .sizeOfView { floatingBarHeight = $0.height }
+ .accessibilitySortPriority(1)
+ }
+ .onChange(of: showSearchField) { _, newValue in
+ if !newValue {
+ isSearchExpanded = false
+ viewModel.searchText = ""
+ }
+ }
+ .animation(.default, value: isSearchExpanded)
.animation(.default, value: showSearchField)
.animation(.default, value: viewModel.multihopContext)
.animation(.default, value: viewModel.isMultihopEnabled)