diff options
| author | Emīls <emils@mullvad.net> | 2026-04-23 23:59:07 +0200 |
|---|---|---|
| committer | Emīls <emils@mullvad.net> | 2026-04-24 10:55:08 +0200 |
| commit | a82e1d006268adbbb4afb24b3a9fcf47d979df74 (patch) | |
| tree | ea2e3d85b396a27798c4cdc3045be31e171c08aa | |
| parent | e68ffb15280f0ed93cc447da83ee9ee615fb3b21 (diff) | |
| download | mullvadvpn-a82e1d006268adbbb4afb24b3a9fcf47d979df74.tar.xz mullvadvpn-a82e1d006268adbbb4afb24b3a9fcf47d979df74.zip | |
Add floating search bar
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) |
